Групповые аватарки: отправка + шифрование + 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
|
||||
|
||||
/// Manages file upload/download to the transport server.
|
||||
@@ -81,6 +112,9 @@ final class TransportManager: @unchecked Sendable {
|
||||
private static let maxUploadRetries = 3
|
||||
|
||||
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 {
|
||||
throw TransportError.noTransportServer
|
||||
}
|
||||
@@ -162,6 +196,9 @@ final class TransportManager: @unchecked Sendable {
|
||||
server: String? = nil,
|
||||
onProgress: (@MainActor (Double) -> Void)?
|
||||
) async throws -> Data {
|
||||
await TransportLimiter.shared.acquire()
|
||||
defer { Task { await TransportLimiter.shared.release() } }
|
||||
|
||||
let serverUrl: String
|
||||
if let explicit = server, !explicit.isEmpty {
|
||||
serverUrl = explicit
|
||||
|
||||
@@ -8,9 +8,8 @@ enum InAppNotificationManager {
|
||||
static func shouldSuppress(senderKey: String) -> Bool {
|
||||
if senderKey.isEmpty { return true }
|
||||
if MessageRepository.shared.isDialogActive(senderKey) { return true }
|
||||
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
||||
.stringArray(forKey: "muted_chats_keys") ?? []
|
||||
if mutedKeys.contains(senderKey) { return true }
|
||||
// PERF: use in-memory dialog state instead of UserDefaults I/O per notification check.
|
||||
if DialogRepository.shared.dialogs[senderKey]?.isMuted == true { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,11 +425,7 @@ final class SessionManager {
|
||||
|
||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let randomPart = 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 attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
|
||||
let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)
|
||||
|
||||
// Android/Desktop parity: avatar messages have empty text.
|
||||
@@ -453,7 +449,9 @@ final class SessionManager {
|
||||
encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
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 = ""
|
||||
outAesChachaKey = ""
|
||||
} else {
|
||||
@@ -1951,11 +1949,8 @@ final class SessionManager {
|
||||
|
||||
// Telegram parity: show in-app banner for foreground messages in non-active chats.
|
||||
if !fromMe && !effectiveFromSync && isAppInForeground {
|
||||
let isMuted: Bool = {
|
||||
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
||||
.stringArray(forKey: "muted_chats_keys") ?? []
|
||||
return mutedKeys.contains(opponentKey)
|
||||
}()
|
||||
// PERF: use in-memory dialog state instead of UserDefaults I/O per message.
|
||||
let isMuted = dialog?.isMuted == true
|
||||
// Desktop-active suppression: skip banner if dialog was read on another device < 30s ago.
|
||||
let isDesktopActive: Bool = {
|
||||
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
|
||||
for key in missingName {
|
||||
guard ProtocolManager.shared.connectionState == .authenticated else { break }
|
||||
@@ -2692,11 +2689,11 @@ final class SessionManager {
|
||||
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
||||
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.
|
||||
let recentKeys = hasName
|
||||
.compactMap { key -> (String, Int64)? in
|
||||
@@ -2712,7 +2709,7 @@ final class SessionManager {
|
||||
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
||||
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)")
|
||||
|
||||
@@ -835,9 +835,14 @@ final class ChatDetailViewController: UIViewController {
|
||||
onSendAvatar: { [weak self] in
|
||||
self?.sendAvatarToChat()
|
||||
},
|
||||
hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil,
|
||||
hasAvatar: avatarAvailableForSending(),
|
||||
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)
|
||||
@@ -1318,59 +1323,17 @@ final class ChatDetailViewController: UIViewController {
|
||||
sendCurrentMessage()
|
||||
}
|
||||
|
||||
/// Desktop parity: in groups only admin can send, and only group avatar.
|
||||
/// In 1:1 chats — sends personal avatar.
|
||||
private func sendAvatarToChat() {
|
||||
if route.isGroup {
|
||||
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() {
|
||||
let sourceKey = route.isGroup ? route.publicKey : nil
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await SessionManager.shared.sendAvatar(
|
||||
toPublicKey: route.publicKey,
|
||||
opponentTitle: route.title,
|
||||
opponentUsername: route.username,
|
||||
avatarSourceKey: route.publicKey
|
||||
avatarSourceKey: sourceKey
|
||||
)
|
||||
} catch {
|
||||
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() {
|
||||
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
||||
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
||||
|
||||
@@ -66,7 +66,7 @@ struct MessageAvatarView: View {
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
} else if avatarImage != nil {
|
||||
Text(attachment.id.hasPrefix("ga_")
|
||||
Text(DatabaseManager.isGroupDialogKey(message.toPublicKey)
|
||||
? "Shared group photo."
|
||||
: "Shared profile photo.")
|
||||
.font(.system(size: 12))
|
||||
@@ -264,8 +264,8 @@ struct MessageAvatarView: View {
|
||||
if let downloadedImage {
|
||||
avatarImage = downloadedImage
|
||||
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
||||
// Desktop parity: in group chats save as group avatar,
|
||||
// in personal chats save as sender's avatar
|
||||
// Desktop parity: in groups, avatar saves as group avatar.
|
||||
// In personal chats, saves as sender's avatar.
|
||||
let avatarKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
|
||||
? message.toPublicKey
|
||||
: message.fromPublicKey
|
||||
|
||||
@@ -1160,12 +1160,12 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
avatarImageView.image = cached
|
||||
avatarImageView.isHidden = false
|
||||
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
|
||||
} else {
|
||||
if isOutgoing {
|
||||
// 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 {
|
||||
// Incoming avatar — needs download on tap (Android parity)
|
||||
fileSizeLabel.text = "Tap to download"
|
||||
@@ -1208,7 +1208,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
self.avatarImageView.image = diskImage
|
||||
self.avatarImageView.isHidden = false
|
||||
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
|
||||
@@ -2275,6 +2276,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
fileSizeLabel.text = "Downloading..."
|
||||
let messageId = message.id
|
||||
// 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)
|
||||
? message.toPublicKey : message.fromPublicKey
|
||||
let server = avatarAtt.transportServer
|
||||
@@ -2290,7 +2292,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
self.avatarImageView.image = downloaded
|
||||
self.avatarImageView.isHidden = false
|
||||
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
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.Name("avatarDidUpdate"), object: nil
|
||||
@@ -3193,6 +3196,10 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
let server = attachment.transportServer
|
||||
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 {
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
|
||||
@@ -1743,7 +1743,7 @@ final class NativeMessageListController: UIViewController {
|
||||
let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom)
|
||||
let composerHeight = lastComposerHeight
|
||||
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 delta = newInsetTop - oldInsetTop
|
||||
@@ -1889,7 +1889,7 @@ final class NativeMessageListController: UIViewController {
|
||||
// Explicit composerHeightConstraint prevents the 372pt inflation bug.
|
||||
let composerH = lastComposerHeight
|
||||
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 delta = newInsetTop - oldInsetTop
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import UIKit
|
||||
import SwiftUI
|
||||
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,
|
||||
ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate {
|
||||
ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate,
|
||||
UIScrollViewDelegate {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@@ -50,6 +51,15 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
||||
private let emptyIcon = UIImageView()
|
||||
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
|
||||
|
||||
private let hPad: CGFloat = 16
|
||||
@@ -102,7 +112,9 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
||||
setupMediaGrid()
|
||||
setupListContainers()
|
||||
setupEmptyState()
|
||||
setupHeaderImage()
|
||||
|
||||
scrollView.delegate = self
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
view.addSubview(backButton)
|
||||
|
||||
@@ -139,7 +151,13 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
||||
viewModel.$isOnline
|
||||
.receive(on: DispatchQueue.main)
|
||||
.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)
|
||||
|
||||
@@ -294,6 +312,41 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
||||
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
|
||||
|
||||
private func layoutAll() {
|
||||
@@ -301,25 +354,64 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
||||
let safeTop = view.safeAreaInsets.top
|
||||
scrollView.frame = view.bounds
|
||||
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
|
||||
avatarContainer.frame = CGRect(x: (w - avatarSize) / 2, y: y, width: avatarSize, height: avatarSize)
|
||||
avatarHosting?.view.frame = avatarContainer.bounds
|
||||
y += avatarSize + 12
|
||||
var y: CGFloat
|
||||
let expandedH = w // square photo, like Telegram
|
||||
|
||||
// Name
|
||||
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height
|
||||
nameLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: nameH)
|
||||
y += nameH + 1
|
||||
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()
|
||||
|
||||
// Subtitle
|
||||
subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20)
|
||||
y += 20 + 20
|
||||
avatarContainer.alpha = 0
|
||||
|
||||
// Buttons
|
||||
// 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)
|
||||
avatarHosting?.view.frame = avatarContainer.bounds
|
||||
y += avatarSize + 12
|
||||
|
||||
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height
|
||||
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
|
||||
|
||||
subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20)
|
||||
subtitleLabel.textAlignment = .center
|
||||
subtitleLabel.textColor = viewModel.isOnline ? onlineColor : textSecondary
|
||||
y += 20 + 20
|
||||
}
|
||||
|
||||
// ── Buttons (both states) ──
|
||||
let btnCount = CGFloat(actionButtonViews.count)
|
||||
let btnW = (w - hPad * 2 - buttonSpacing * (btnCount - 1)) / btnCount
|
||||
for (i, (c, iv, lbl)) in actionButtonViews.enumerated() {
|
||||
@@ -345,6 +437,41 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
||||
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 {
|
||||
infoCard.subviews.forEach { $0.removeFromSuperview() }
|
||||
let dialog = DialogRepository.shared.dialogs[route.publicKey]
|
||||
|
||||
@@ -159,7 +159,7 @@ final class VoiceRecordingPanel: UIView {
|
||||
|
||||
// Red dot: 10×10, centered vertically with timer
|
||||
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(
|
||||
x: dotX,
|
||||
@@ -226,7 +226,7 @@ final class VoiceRecordingPanel: UIView {
|
||||
return
|
||||
}
|
||||
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))
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
|
||||
@@ -572,7 +572,22 @@ final class ChatListCell: UICollectionViewCell {
|
||||
// MARK: - Delivery Status
|
||||
|
||||
/// 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))
|
||||
|
||||
/// Cached error indicator (Telegram: red circle with white exclamation).
|
||||
|
||||
Reference in New Issue
Block a user