Групповые аватарки: отправка + шифрование + Desktop parity
This commit is contained in:
@@ -22,6 +22,37 @@ enum TransportError: LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - TransportLimiter
|
||||||
|
|
||||||
|
/// Limits concurrent CDN upload/download operations to prevent I/O storms
|
||||||
|
/// when user scrolls through media-heavy chats. Without this, unbounded
|
||||||
|
/// Task.detached can spawn 50+ concurrent transfers → CPU spike → phone overheating.
|
||||||
|
private actor TransportLimiter {
|
||||||
|
static let shared = TransportLimiter()
|
||||||
|
private let maxConcurrent = 4
|
||||||
|
private var running = 0
|
||||||
|
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
func acquire() async {
|
||||||
|
if running < maxConcurrent {
|
||||||
|
running += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
waiters.append(continuation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
if let next = waiters.first {
|
||||||
|
waiters.removeFirst()
|
||||||
|
next.resume()
|
||||||
|
} else {
|
||||||
|
running -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - TransportManager
|
// MARK: - TransportManager
|
||||||
|
|
||||||
/// Manages file upload/download to the transport server.
|
/// Manages file upload/download to the transport server.
|
||||||
@@ -81,6 +112,9 @@ final class TransportManager: @unchecked Sendable {
|
|||||||
private static let maxUploadRetries = 3
|
private static let maxUploadRetries = 3
|
||||||
|
|
||||||
func uploadFile(id: String, content: Data) async throws -> (tag: String, server: String) {
|
func uploadFile(id: String, content: Data) async throws -> (tag: String, server: String) {
|
||||||
|
await TransportLimiter.shared.acquire()
|
||||||
|
defer { Task { await TransportLimiter.shared.release() } }
|
||||||
|
|
||||||
guard let serverUrl = await MainActor.run(body: { transportServer }) else {
|
guard let serverUrl = await MainActor.run(body: { transportServer }) else {
|
||||||
throw TransportError.noTransportServer
|
throw TransportError.noTransportServer
|
||||||
}
|
}
|
||||||
@@ -162,6 +196,9 @@ final class TransportManager: @unchecked Sendable {
|
|||||||
server: String? = nil,
|
server: String? = nil,
|
||||||
onProgress: (@MainActor (Double) -> Void)?
|
onProgress: (@MainActor (Double) -> Void)?
|
||||||
) async throws -> Data {
|
) async throws -> Data {
|
||||||
|
await TransportLimiter.shared.acquire()
|
||||||
|
defer { Task { await TransportLimiter.shared.release() } }
|
||||||
|
|
||||||
let serverUrl: String
|
let serverUrl: String
|
||||||
if let explicit = server, !explicit.isEmpty {
|
if let explicit = server, !explicit.isEmpty {
|
||||||
serverUrl = explicit
|
serverUrl = explicit
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ enum InAppNotificationManager {
|
|||||||
static func shouldSuppress(senderKey: String) -> Bool {
|
static func shouldSuppress(senderKey: String) -> Bool {
|
||||||
if senderKey.isEmpty { return true }
|
if senderKey.isEmpty { return true }
|
||||||
if MessageRepository.shared.isDialogActive(senderKey) { return true }
|
if MessageRepository.shared.isDialogActive(senderKey) { return true }
|
||||||
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
// PERF: use in-memory dialog state instead of UserDefaults I/O per notification check.
|
||||||
.stringArray(forKey: "muted_chats_keys") ?? []
|
if DialogRepository.shared.dialogs[senderKey]?.isMuted == true { return true }
|
||||||
if mutedKeys.contains(senderKey) { return true }
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,11 +425,7 @@ final class SessionManager {
|
|||||||
|
|
||||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
let randomPart = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
|
let attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
|
||||||
// Mark group avatar attachments with "ga_" prefix so UI can distinguish
|
|
||||||
// "Shared group photo" vs "Shared profile photo" in the same group chat.
|
|
||||||
let isGroupAvatarSource = avatarSourceKey != nil && DatabaseManager.isGroupDialogKey(avatarSourceKey!)
|
|
||||||
let attachmentId = isGroupAvatarSource ? "ga_\(randomPart)" : randomPart
|
|
||||||
let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)
|
let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)
|
||||||
|
|
||||||
// Android/Desktop parity: avatar messages have empty text.
|
// Android/Desktop parity: avatar messages have empty text.
|
||||||
@@ -453,7 +449,9 @@ final class SessionManager {
|
|||||||
encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||||
Data("".utf8), password: groupKey
|
Data("".utf8), password: groupKey
|
||||||
)
|
)
|
||||||
attachmentPassword = groupKey
|
// Desktop parity: Buffer.from(groupKey).toString('hex') — hex of UTF-8 bytes.
|
||||||
|
// Desktop stores chacha_key = hex(groupKey) and uses it for attachment decryption.
|
||||||
|
attachmentPassword = Data(groupKey.utf8).hexString
|
||||||
outChachaKey = ""
|
outChachaKey = ""
|
||||||
outAesChachaKey = ""
|
outAesChachaKey = ""
|
||||||
} else {
|
} else {
|
||||||
@@ -1951,11 +1949,8 @@ final class SessionManager {
|
|||||||
|
|
||||||
// Telegram parity: show in-app banner for foreground messages in non-active chats.
|
// Telegram parity: show in-app banner for foreground messages in non-active chats.
|
||||||
if !fromMe && !effectiveFromSync && isAppInForeground {
|
if !fromMe && !effectiveFromSync && isAppInForeground {
|
||||||
let isMuted: Bool = {
|
// PERF: use in-memory dialog state instead of UserDefaults I/O per message.
|
||||||
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
let isMuted = dialog?.isMuted == true
|
||||||
.stringArray(forKey: "muted_chats_keys") ?? []
|
|
||||||
return mutedKeys.contains(opponentKey)
|
|
||||||
}()
|
|
||||||
// Desktop-active suppression: skip banner if dialog was read on another device < 30s ago.
|
// Desktop-active suppression: skip banner if dialog was read on another device < 30s ago.
|
||||||
let isDesktopActive: Bool = {
|
let isDesktopActive: Bool = {
|
||||||
if let readDate = self.desktopActiveDialogs[opponentKey] {
|
if let readDate = self.desktopActiveDialogs[opponentKey] {
|
||||||
@@ -2684,7 +2679,9 @@ final class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority: fetch missing names first (generous 200ms stagger)
|
// Priority: fetch missing names first (generous 250ms stagger).
|
||||||
|
// PERF: increased stagger from 200→250ms to reduce CPU/network overhead
|
||||||
|
// that contributes to phone overheating with many chats.
|
||||||
var count = 0
|
var count = 0
|
||||||
for key in missingName {
|
for key in missingName {
|
||||||
guard ProtocolManager.shared.connectionState == .authenticated else { break }
|
guard ProtocolManager.shared.connectionState == .authenticated else { break }
|
||||||
@@ -2692,11 +2689,11 @@ final class SessionManager {
|
|||||||
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
||||||
count += 1
|
count += 1
|
||||||
if count > 1 {
|
if count > 1 {
|
||||||
try? await Task.sleep(for: .milliseconds(200))
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then refresh online status for recently active dialogs only (300ms stagger).
|
// Then refresh online status for recently active dialogs only (400ms stagger).
|
||||||
// Sort by lastMessageTimestamp descending — most recent chats first.
|
// Sort by lastMessageTimestamp descending — most recent chats first.
|
||||||
let recentKeys = hasName
|
let recentKeys = hasName
|
||||||
.compactMap { key -> (String, Int64)? in
|
.compactMap { key -> (String, Int64)? in
|
||||||
@@ -2712,7 +2709,7 @@ final class SessionManager {
|
|||||||
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
||||||
count += 1
|
count += 1
|
||||||
if count > 1 {
|
if count > 1 {
|
||||||
try? await Task.sleep(for: .milliseconds(300))
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(recentKeys.count) online status = \(count) total (capped at 30)")
|
Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(recentKeys.count) online status = \(count) total (capped at 30)")
|
||||||
|
|||||||
@@ -835,9 +835,14 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
onSendAvatar: { [weak self] in
|
onSendAvatar: { [weak self] in
|
||||||
self?.sendAvatarToChat()
|
self?.sendAvatarToChat()
|
||||||
},
|
},
|
||||||
hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil,
|
hasAvatar: avatarAvailableForSending(),
|
||||||
onSetAvatar: { [weak self] in
|
onSetAvatar: { [weak self] in
|
||||||
self?.showAlert(title: "No Avatar", message: "Set a profile photo in Settings to share it with contacts.")
|
guard let self else { return }
|
||||||
|
if self.route.isGroup {
|
||||||
|
self.showAlert(title: "No Group Avatar", message: "Set a group photo first to share it with members.")
|
||||||
|
} else {
|
||||||
|
self.showAlert(title: "No Avatar", message: "Set a profile photo in Settings to share it with contacts.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
let hosting = UIHostingController(rootView: panel)
|
let hosting = UIHostingController(rootView: panel)
|
||||||
@@ -1318,59 +1323,17 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
sendCurrentMessage()
|
sendCurrentMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Desktop parity: in groups only admin can send, and only group avatar.
|
||||||
|
/// In 1:1 chats — sends personal avatar.
|
||||||
private func sendAvatarToChat() {
|
private func sendAvatarToChat() {
|
||||||
if route.isGroup {
|
let sourceKey = route.isGroup ? route.publicKey : nil
|
||||||
let cached = GroupRepository.shared.cachedMembers(
|
|
||||||
account: currentPublicKey,
|
|
||||||
groupDialogKey: route.publicKey
|
|
||||||
)
|
|
||||||
if cached?.adminKey == currentPublicKey {
|
|
||||||
showAvatarActionSheet()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
performSendAvatar()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performSendAvatar() {
|
|
||||||
Task { @MainActor in
|
|
||||||
do {
|
|
||||||
try await SessionManager.shared.sendAvatar(
|
|
||||||
toPublicKey: route.publicKey,
|
|
||||||
opponentTitle: route.title,
|
|
||||||
opponentUsername: route.username
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
showAlert(title: "Send Error", message: error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showAvatarActionSheet() {
|
|
||||||
let hasGroupAvatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil
|
|
||||||
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
|
||||||
sheet.addAction(UIAlertAction(title: "Send My Avatar", style: .default) { [weak self] _ in
|
|
||||||
self?.performSendAvatar()
|
|
||||||
})
|
|
||||||
if hasGroupAvatar {
|
|
||||||
sheet.addAction(UIAlertAction(title: "Share Group Avatar", style: .default) { [weak self] _ in
|
|
||||||
self?.shareGroupAvatar()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.present(sheet, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shareGroupAvatar() {
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
try await SessionManager.shared.sendAvatar(
|
try await SessionManager.shared.sendAvatar(
|
||||||
toPublicKey: route.publicKey,
|
toPublicKey: route.publicKey,
|
||||||
opponentTitle: route.title,
|
opponentTitle: route.title,
|
||||||
opponentUsername: route.username,
|
opponentUsername: route.username,
|
||||||
avatarSourceKey: route.publicKey
|
avatarSourceKey: sourceKey
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
showAlert(title: "Send Error", message: error.localizedDescription)
|
showAlert(title: "Send Error", message: error.localizedDescription)
|
||||||
@@ -1378,6 +1341,20 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if avatar tab should show "Send" (not "Set").
|
||||||
|
/// Groups: only admin + group has avatar. Personal: user has avatar.
|
||||||
|
private func avatarAvailableForSending() -> Bool {
|
||||||
|
if route.isGroup {
|
||||||
|
let cached = GroupRepository.shared.cachedMembers(
|
||||||
|
account: currentPublicKey,
|
||||||
|
groupDialogKey: route.publicKey
|
||||||
|
)
|
||||||
|
guard cached?.adminKey == currentPublicKey else { return false }
|
||||||
|
return AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil
|
||||||
|
}
|
||||||
|
return AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil
|
||||||
|
}
|
||||||
|
|
||||||
private func handleComposerUserTyping() {
|
private func handleComposerUserTyping() {
|
||||||
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
||||||
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ struct MessageAvatarView: View {
|
|||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(RosettaColors.error)
|
.foregroundStyle(RosettaColors.error)
|
||||||
} else if avatarImage != nil {
|
} else if avatarImage != nil {
|
||||||
Text(attachment.id.hasPrefix("ga_")
|
Text(DatabaseManager.isGroupDialogKey(message.toPublicKey)
|
||||||
? "Shared group photo."
|
? "Shared group photo."
|
||||||
: "Shared profile photo.")
|
: "Shared profile photo.")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
@@ -264,8 +264,8 @@ struct MessageAvatarView: View {
|
|||||||
if let downloadedImage {
|
if let downloadedImage {
|
||||||
avatarImage = downloadedImage
|
avatarImage = downloadedImage
|
||||||
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
||||||
// Desktop parity: in group chats save as group avatar,
|
// Desktop parity: in groups, avatar saves as group avatar.
|
||||||
// in personal chats save as sender's avatar
|
// In personal chats, saves as sender's avatar.
|
||||||
let avatarKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
|
let avatarKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
|
||||||
? message.toPublicKey
|
? message.toPublicKey
|
||||||
: message.fromPublicKey
|
: message.fromPublicKey
|
||||||
|
|||||||
@@ -1160,12 +1160,12 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
avatarImageView.image = cached
|
avatarImageView.image = cached
|
||||||
avatarImageView.isHidden = false
|
avatarImageView.isHidden = false
|
||||||
fileIconView.isHidden = true
|
fileIconView.isHidden = true
|
||||||
fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
|
fileSizeLabel.text = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? "Shared group photo" : "Shared profile photo"
|
||||||
fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
|
fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
|
||||||
} else {
|
} else {
|
||||||
if isOutgoing {
|
if isOutgoing {
|
||||||
// Own avatar — already uploaded, just loading from disk
|
// Own avatar — already uploaded, just loading from disk
|
||||||
fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
|
fileSizeLabel.text = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? "Shared group photo" : "Shared profile photo"
|
||||||
} else {
|
} else {
|
||||||
// Incoming avatar — needs download on tap (Android parity)
|
// Incoming avatar — needs download on tap (Android parity)
|
||||||
fileSizeLabel.text = "Tap to download"
|
fileSizeLabel.text = "Tap to download"
|
||||||
@@ -1208,7 +1208,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
self.avatarImageView.image = diskImage
|
self.avatarImageView.image = diskImage
|
||||||
self.avatarImageView.isHidden = false
|
self.avatarImageView.isHidden = false
|
||||||
self.fileIconView.isHidden = true
|
self.fileIconView.isHidden = true
|
||||||
self.fileSizeLabel.text = attId.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
|
let isGroupMsg = DatabaseManager.isGroupDialogKey(self.message?.toPublicKey ?? "")
|
||||||
|
self.fileSizeLabel.text = isGroupMsg ? "Shared group photo" : "Shared profile photo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// CDN download is triggered by user tap via .triggerAttachmentDownload
|
// CDN download is triggered by user tap via .triggerAttachmentDownload
|
||||||
@@ -2275,6 +2276,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
fileSizeLabel.text = "Downloading..."
|
fileSizeLabel.text = "Downloading..."
|
||||||
let messageId = message.id
|
let messageId = message.id
|
||||||
// Desktop parity: group avatar saves to group dialog key, not sender key
|
// Desktop parity: group avatar saves to group dialog key, not sender key
|
||||||
|
// Desktop parity: in groups, avatar saves as group avatar.
|
||||||
let avatarTargetKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
|
let avatarTargetKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
|
||||||
? message.toPublicKey : message.fromPublicKey
|
? message.toPublicKey : message.fromPublicKey
|
||||||
let server = avatarAtt.transportServer
|
let server = avatarAtt.transportServer
|
||||||
@@ -2290,7 +2292,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
self.avatarImageView.image = downloaded
|
self.avatarImageView.image = downloaded
|
||||||
self.avatarImageView.isHidden = false
|
self.avatarImageView.isHidden = false
|
||||||
self.fileIconView.isHidden = true
|
self.fileIconView.isHidden = true
|
||||||
self.fileSizeLabel.text = id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
|
let isGroupMsg = DatabaseManager.isGroupDialogKey(self.message?.toPublicKey ?? "")
|
||||||
|
self.fileSizeLabel.text = isGroupMsg ? "Shared group photo" : "Shared profile photo"
|
||||||
// Trigger refresh of sender avatar circles in visible cells
|
// Trigger refresh of sender avatar circles in visible cells
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: Notification.Name("avatarDidUpdate"), object: nil
|
name: Notification.Name("avatarDidUpdate"), object: nil
|
||||||
@@ -3193,6 +3196,10 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
|
|
||||||
let server = attachment.transportServer
|
let server = attachment.transportServer
|
||||||
photoDownloadTasks[attachmentId] = Task { [weak self] in
|
photoDownloadTasks[attachmentId] = Task { [weak self] in
|
||||||
|
// PERF: concurrent CDN downloads are capped by TransportLimiter (max 4) inside
|
||||||
|
// TransportManager.downloadFile(). Do NOT use ImageLoadLimiter here — it holds
|
||||||
|
// a slot (max 3) for the entire download duration (seconds), starving fast
|
||||||
|
// disk loads in startPhotoLoadTask() and causing cached photos to lag.
|
||||||
do {
|
do {
|
||||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
|
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
|
||||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||||
|
|||||||
@@ -1743,7 +1743,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom)
|
let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom)
|
||||||
let composerHeight = lastComposerHeight
|
let composerHeight = lastComposerHeight
|
||||||
let newInsetTop = composerHeight + composerBottom + UIConstants.messageToComposerGap
|
let newInsetTop = composerHeight + composerBottom + UIConstants.messageToComposerGap
|
||||||
let topInset = view.safeAreaInsets.top + 6
|
let topInset = view.safeAreaInsets.top + topStickyOffset + 6
|
||||||
|
|
||||||
let oldInsetTop = collectionView.contentInset.top
|
let oldInsetTop = collectionView.contentInset.top
|
||||||
let delta = newInsetTop - oldInsetTop
|
let delta = newInsetTop - oldInsetTop
|
||||||
@@ -1889,7 +1889,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
// Explicit composerHeightConstraint prevents the 372pt inflation bug.
|
// Explicit composerHeightConstraint prevents the 372pt inflation bug.
|
||||||
let composerH = lastComposerHeight
|
let composerH = lastComposerHeight
|
||||||
let newInsetTop = composerH + composerBottom + UIConstants.messageToComposerGap
|
let newInsetTop = composerH + composerBottom + UIConstants.messageToComposerGap
|
||||||
let topInset = view.safeAreaInsets.top + 6
|
let topInset = view.safeAreaInsets.top + topStickyOffset + 6
|
||||||
let oldInsetTop = collectionView.contentInset.top
|
let oldInsetTop = collectionView.contentInset.top
|
||||||
let delta = newInsetTop - oldInsetTop
|
let delta = newInsetTop - oldInsetTop
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import UIKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// Pure UIKit peer profile screen. Phase 2: data + interactivity.
|
/// Pure UIKit peer profile screen. Phase 3: expand/collapse header + haptic.
|
||||||
final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate,
|
final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate,
|
||||||
ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate {
|
ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate,
|
||||||
|
UIScrollViewDelegate {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
@@ -50,6 +51,15 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
|||||||
private let emptyIcon = UIImageView()
|
private let emptyIcon = UIImageView()
|
||||||
private let emptyLabel = UILabel()
|
private let emptyLabel = UILabel()
|
||||||
|
|
||||||
|
// Expand/collapse header
|
||||||
|
private let headerImageView = UIImageView()
|
||||||
|
private let scrimView = UIView()
|
||||||
|
private let scrimGradient = CAGradientLayer()
|
||||||
|
private var isLargeHeader = false
|
||||||
|
private var canExpand = false
|
||||||
|
private var profileImage: UIImage?
|
||||||
|
private let expandFeedback = UIImpactFeedbackGenerator(style: .light)
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
private let hPad: CGFloat = 16
|
private let hPad: CGFloat = 16
|
||||||
@@ -102,7 +112,9 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
|||||||
setupMediaGrid()
|
setupMediaGrid()
|
||||||
setupListContainers()
|
setupListContainers()
|
||||||
setupEmptyState()
|
setupEmptyState()
|
||||||
|
setupHeaderImage()
|
||||||
|
|
||||||
|
scrollView.delegate = self
|
||||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||||
view.addSubview(backButton)
|
view.addSubview(backButton)
|
||||||
|
|
||||||
@@ -139,7 +151,13 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
|||||||
viewModel.$isOnline
|
viewModel.$isOnline
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] online in
|
.sink { [weak self] online in
|
||||||
self?.subtitleLabel.text = online ? "online" : "offline"
|
guard let self else { return }
|
||||||
|
self.subtitleLabel.text = online ? "online" : "offline"
|
||||||
|
if self.isLargeHeader {
|
||||||
|
self.subtitleLabel.textColor = online ? self.onlineColor : .white.withAlphaComponent(0.7)
|
||||||
|
} else {
|
||||||
|
self.subtitleLabel.textColor = online ? self.onlineColor : self.textSecondary
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
@@ -294,6 +312,41 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
|||||||
contentView.addSubview(emptyLabel)
|
contentView.addSubview(emptyLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupHeaderImage() {
|
||||||
|
headerImageView.contentMode = .scaleAspectFill
|
||||||
|
headerImageView.clipsToBounds = true
|
||||||
|
headerImageView.alpha = 0
|
||||||
|
contentView.insertSubview(headerImageView, at: 0)
|
||||||
|
|
||||||
|
scrimView.isUserInteractionEnabled = false
|
||||||
|
scrimView.alpha = 0
|
||||||
|
contentView.insertSubview(scrimView, aboveSubview: headerImageView)
|
||||||
|
|
||||||
|
// Bottom gradient — for name readability
|
||||||
|
scrimGradient.colors = [
|
||||||
|
UIColor.clear.cgColor,
|
||||||
|
UIColor.black.withAlphaComponent(0.05).cgColor,
|
||||||
|
UIColor.black.withAlphaComponent(0.6).cgColor
|
||||||
|
]
|
||||||
|
scrimGradient.locations = [0.4, 0.65, 1.0]
|
||||||
|
scrimView.layer.addSublayer(scrimGradient)
|
||||||
|
|
||||||
|
// Top gradient — for status bar readability
|
||||||
|
let topGradient = CAGradientLayer()
|
||||||
|
topGradient.colors = [
|
||||||
|
UIColor.black.withAlphaComponent(0.4).cgColor,
|
||||||
|
UIColor.clear.cgColor
|
||||||
|
]
|
||||||
|
topGradient.locations = [0.0, 0.3]
|
||||||
|
topGradient.accessibilityHint = "topScrim"
|
||||||
|
scrimView.layer.addSublayer(topGradient)
|
||||||
|
|
||||||
|
profileImage = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
|
||||||
|
canExpand = profileImage != nil
|
||||||
|
if canExpand { headerImageView.image = profileImage }
|
||||||
|
expandFeedback.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Layout
|
// MARK: - Layout
|
||||||
|
|
||||||
private func layoutAll() {
|
private func layoutAll() {
|
||||||
@@ -301,25 +354,64 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
|||||||
let safeTop = view.safeAreaInsets.top
|
let safeTop = view.safeAreaInsets.top
|
||||||
scrollView.frame = view.bounds
|
scrollView.frame = view.bounds
|
||||||
contentView.frame.size.width = w
|
contentView.frame.size.width = w
|
||||||
backButton.frame = CGRect(x: 7, y: safeTop - 10, width: 44, height: 44)
|
|
||||||
|
|
||||||
var y: CGFloat = safeTop - 8
|
// Back button — same position as ChatDetailViewController (centered in 44pt header bar)
|
||||||
|
let headerCenterY = safeTop + 22
|
||||||
|
backButton.frame = CGRect(x: 8, y: headerCenterY - 22, width: 44, height: 44)
|
||||||
|
|
||||||
// Avatar
|
var y: CGFloat
|
||||||
|
let expandedH = w // square photo, like Telegram
|
||||||
|
|
||||||
|
if isLargeHeader {
|
||||||
|
// ── Expanded: full-width photo from y=0 (behind Dynamic Island) ──
|
||||||
|
headerImageView.frame = CGRect(x: 0, y: 0, width: w, height: expandedH)
|
||||||
|
headerImageView.alpha = 1
|
||||||
|
scrimView.frame = headerImageView.frame
|
||||||
|
scrimView.alpha = 1
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
scrimGradient.frame = scrimView.bounds
|
||||||
|
CATransaction.commit()
|
||||||
|
|
||||||
|
avatarContainer.alpha = 0
|
||||||
|
|
||||||
|
// Name + subtitle overlaid at bottom of photo (inside gradient)
|
||||||
|
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 40, height: 30)).height
|
||||||
|
nameLabel.frame = CGRect(x: 16, y: expandedH - 50, width: w - 32, height: nameH)
|
||||||
|
nameLabel.textAlignment = .left
|
||||||
|
nameLabel.textColor = .white
|
||||||
|
nameLabel.font = .systemFont(ofSize: 24, weight: .bold)
|
||||||
|
|
||||||
|
subtitleLabel.frame = CGRect(x: 16, y: expandedH - 26, width: w - 32, height: 20)
|
||||||
|
subtitleLabel.textAlignment = .left
|
||||||
|
subtitleLabel.textColor = viewModel.isOnline ? onlineColor : .white.withAlphaComponent(0.7)
|
||||||
|
|
||||||
|
y = expandedH + 16
|
||||||
|
} else {
|
||||||
|
// ── Collapsed: small avatar ──
|
||||||
|
headerImageView.alpha = 0
|
||||||
|
scrimView.alpha = 0
|
||||||
|
avatarContainer.alpha = 1
|
||||||
|
|
||||||
|
y = safeTop + 8
|
||||||
avatarContainer.frame = CGRect(x: (w - avatarSize) / 2, y: y, width: avatarSize, height: avatarSize)
|
avatarContainer.frame = CGRect(x: (w - avatarSize) / 2, y: y, width: avatarSize, height: avatarSize)
|
||||||
avatarHosting?.view.frame = avatarContainer.bounds
|
avatarHosting?.view.frame = avatarContainer.bounds
|
||||||
y += avatarSize + 12
|
y += avatarSize + 12
|
||||||
|
|
||||||
// Name
|
|
||||||
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height
|
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height
|
||||||
nameLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: nameH)
|
nameLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: nameH)
|
||||||
|
nameLabel.textAlignment = .center
|
||||||
|
nameLabel.textColor = textPrimary
|
||||||
|
nameLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
y += nameH + 1
|
y += nameH + 1
|
||||||
|
|
||||||
// Subtitle
|
|
||||||
subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20)
|
subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20)
|
||||||
|
subtitleLabel.textAlignment = .center
|
||||||
|
subtitleLabel.textColor = viewModel.isOnline ? onlineColor : textSecondary
|
||||||
y += 20 + 20
|
y += 20 + 20
|
||||||
|
}
|
||||||
|
|
||||||
// Buttons
|
// ── Buttons (both states) ──
|
||||||
let btnCount = CGFloat(actionButtonViews.count)
|
let btnCount = CGFloat(actionButtonViews.count)
|
||||||
let btnW = (w - hPad * 2 - buttonSpacing * (btnCount - 1)) / btnCount
|
let btnW = (w - hPad * 2 - buttonSpacing * (btnCount - 1)) / btnCount
|
||||||
for (i, (c, iv, lbl)) in actionButtonViews.enumerated() {
|
for (i, (c, iv, lbl)) in actionButtonViews.enumerated() {
|
||||||
@@ -345,6 +437,41 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
|||||||
scrollView.contentSize = CGSize(width: w, height: y)
|
scrollView.contentSize = CGSize(width: w, height: y)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Expand / Collapse
|
||||||
|
|
||||||
|
private func expandHeader() {
|
||||||
|
guard canExpand, !isLargeHeader else { return }
|
||||||
|
isLargeHeader = true
|
||||||
|
expandFeedback.impactOccurred()
|
||||||
|
scrollView.setContentOffset(.zero, animated: false)
|
||||||
|
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.86,
|
||||||
|
initialSpringVelocity: 0.5, options: []) {
|
||||||
|
self.layoutAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func collapseHeader() {
|
||||||
|
guard isLargeHeader else { return }
|
||||||
|
isLargeHeader = false
|
||||||
|
scrollView.setContentOffset(.zero, animated: false)
|
||||||
|
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.86,
|
||||||
|
initialSpringVelocity: 0.5, options: []) {
|
||||||
|
self.layoutAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIScrollViewDelegate
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
let offsetY = scrollView.contentOffset.y
|
||||||
|
if offsetY <= -32 && scrollView.isDragging && scrollView.isTracking
|
||||||
|
&& canExpand && !isLargeHeader {
|
||||||
|
expandHeader()
|
||||||
|
} else if offsetY >= 1 && isLargeHeader {
|
||||||
|
collapseHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func layoutInfoCard(width: CGFloat) -> CGFloat {
|
private func layoutInfoCard(width: CGFloat) -> CGFloat {
|
||||||
infoCard.subviews.forEach { $0.removeFromSuperview() }
|
infoCard.subviews.forEach { $0.removeFromSuperview() }
|
||||||
let dialog = DialogRepository.shared.dialogs[route.publicKey]
|
let dialog = DialogRepository.shared.dialogs[route.publicKey]
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ final class VoiceRecordingPanel: UIView {
|
|||||||
|
|
||||||
// Red dot: 10×10, centered vertically with timer
|
// Red dot: 10×10, centered vertically with timer
|
||||||
let timerSize = timerLabel.sizeThatFits(CGSize(width: 100, height: h))
|
let timerSize = timerLabel.sizeThatFits(CGSize(width: 100, height: h))
|
||||||
let timerY = floor((h - timerSize.height) / 2) + 1 // +1pt baseline offset (Telegram)
|
let timerY = floor((h - timerSize.height) / 2)
|
||||||
|
|
||||||
redDot.frame = CGRect(
|
redDot.frame = CGRect(
|
||||||
x: dotX,
|
x: dotX,
|
||||||
@@ -226,7 +226,7 @@ final class VoiceRecordingPanel: UIView {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let timerSize = timerLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: h))
|
let timerSize = timerLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: h))
|
||||||
let timerY = floor((h - timerSize.height) / 2) + 1
|
let timerY = floor((h - timerSize.height) / 2)
|
||||||
let timerWidth = max(timerMinWidth, ceil(timerSize.width + 4))
|
let timerWidth = max(timerMinWidth, ceil(timerSize.width + 4))
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
|
|||||||
@@ -572,7 +572,22 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
// MARK: - Delivery Status
|
// MARK: - Delivery Status
|
||||||
|
|
||||||
/// Cached checkmark images (rendered once from SwiftUI Shapes).
|
/// Cached checkmark images (rendered once from SwiftUI Shapes).
|
||||||
private static let singleCheckImage: UIImage = StatusIconRenderer.renderShape(SingleCheckmarkShape(), size: CGSize(width: 14, height: 10.3))
|
private static let singleCheckImage: UIImage = {
|
||||||
|
// Match double checkmark canvas (17×9.3) so both scale
|
||||||
|
// identically in the 16×16 statusImageView (scaleAspectFit).
|
||||||
|
let canvas = CGSize(width: 17, height: 9.3)
|
||||||
|
let checkW = 16.4 * (9.3 / 12.0)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: canvas)
|
||||||
|
return renderer.image { ctx in
|
||||||
|
ctx.cgContext.translateBy(x: canvas.width - checkW, y: 0)
|
||||||
|
let path = SingleCheckmarkShape().path(
|
||||||
|
in: CGRect(x: 0, y: 0, width: checkW, height: canvas.height)
|
||||||
|
)
|
||||||
|
ctx.cgContext.addPath(path.cgPath)
|
||||||
|
ctx.cgContext.setFillColor(UIColor.black.cgColor)
|
||||||
|
ctx.cgContext.fillPath()
|
||||||
|
}
|
||||||
|
}()
|
||||||
private static let doubleCheckImage: UIImage = StatusIconRenderer.renderShape(DoubleCheckmarkShape(), size: CGSize(width: 17, height: 9.3))
|
private static let doubleCheckImage: UIImage = StatusIconRenderer.renderShape(DoubleCheckmarkShape(), size: CGSize(width: 17, height: 9.3))
|
||||||
|
|
||||||
/// Cached error indicator (Telegram: red circle with white exclamation).
|
/// Cached error indicator (Telegram: red circle with white exclamation).
|
||||||
|
|||||||
Reference in New Issue
Block a user