Выделение сообщений + пересылка вложений (фото/голосовые/файлы/инвайты)
This commit is contained in:
@@ -404,7 +404,7 @@ extension MessageCellLayout {
|
||||
+ CGFloat(config.avatarCount) * 52
|
||||
+ CGFloat(config.voiceCount) * 38
|
||||
+ CGFloat(config.isForward ? config.forwardFileCount : 0) * 52
|
||||
+ CGFloat(config.isForward ? config.forwardVoiceCount : 0) * 38
|
||||
+ CGFloat(config.isForward ? config.forwardVoiceCount : 0) * 44 // 38pt voice + 6pt bottom pad
|
||||
|
||||
// Tiny floor just to prevent zero-width collapse.
|
||||
// Telegram does NOT force a large minW — short messages get tight bubbles.
|
||||
@@ -538,7 +538,8 @@ extension MessageCellLayout {
|
||||
// Telegram: call width = title + button(54) + insets ≈ 200pt
|
||||
// Telegram: file width = icon(55) + filename + insets ≈ 220pt
|
||||
let fileMinW: CGFloat
|
||||
if config.voiceCount > 0 {
|
||||
let hasVoice = config.voiceCount > 0 || (config.isForward && config.forwardVoiceCount > 0)
|
||||
if hasVoice {
|
||||
// Telegram: voice width scales with duration (2-30s range, 120-maxW)
|
||||
let minVoiceW: CGFloat = 120
|
||||
let maxVoiceW = effectiveMaxBubbleWidth - 36
|
||||
@@ -554,20 +555,25 @@ extension MessageCellLayout {
|
||||
// To achieve visual symmetry, fileH spans the ENTIRE bubble
|
||||
// and metadataBottomInset = (fileH - contentH) / 2 (same as content topY).
|
||||
let tsGap: CGFloat = 6
|
||||
let contentH: CGFloat = config.callCount > 0 ? 36 : (config.voiceCount > 0 ? 38 : 44)
|
||||
let tsPad = ceil((fileH + tsGap - contentH) / 2)
|
||||
let contentH: CGFloat = config.callCount > 0 ? 36 : (hasVoice ? 38 : 44)
|
||||
let tsPad: CGFloat
|
||||
if config.isForward {
|
||||
// Forwarded: no vertical centering, tight fit
|
||||
tsPad = 4
|
||||
} else {
|
||||
tsPad = ceil((fileH + tsGap - contentH) / 2)
|
||||
}
|
||||
fileOnlyTsPad = tsPad
|
||||
bubbleH += tsGap + tsSize.height + tsPad
|
||||
fileH = bubbleH // fileContainer spans entire bubble
|
||||
fileH = bubbleH - forwardHeaderH - replyH // fileContainer spans content area (below forward header)
|
||||
} else if config.groupInviteCount > 0 {
|
||||
// Group invite card: icon row + status + button
|
||||
// Group invite card: icon(44) + status + button(28) = ~80pt content
|
||||
let inviteCardH: CGFloat = 80
|
||||
let inviteMinW: CGFloat = 220
|
||||
bubbleW = min(inviteMinW, effectiveMaxBubbleWidth)
|
||||
bubbleW = max(bubbleW, leftPad + metadataWidth + rightPad)
|
||||
let tsGap: CGFloat = 6
|
||||
let contentH: CGFloat = 60
|
||||
let tsPad = ceil((inviteCardH + tsGap - contentH) / 2)
|
||||
let tsGap: CGFloat = 4
|
||||
let tsPad: CGFloat = 4
|
||||
fileOnlyTsPad = tsPad
|
||||
bubbleH += inviteCardH + tsGap + tsSize.height + tsPad
|
||||
} else {
|
||||
|
||||
@@ -12,8 +12,23 @@ enum ReleaseNotes {
|
||||
version: appVersion,
|
||||
body: """
|
||||
|
||||
**Выделение и пересылка сообщений**
|
||||
Долгое нажатие на сообщение для выделения нескольких. Массовая пересылка, удаление и отправка — все типы: текст, фото, голосовые, файлы, приглашения в группы.
|
||||
|
||||
**Голосовые сообщения**
|
||||
Запись, предпросмотр, lock-to-record, кросс-платформенная совместимость Desktop/Android (WebM/Opus ↔ M4A). Waveform, воспроизведение, blob-анимация.
|
||||
|
||||
**Создание групп**
|
||||
Новый флоу: поиск контактов, мульти-выбор, glass UI, фото и описание группы. Можно создать группу без участников.
|
||||
|
||||
**UIKit миграция**
|
||||
Chat Detail, Chat List header, Appearance, Settings — полный перевод на UIKit для Telegram-level производительности.
|
||||
|
||||
**Tab Bar**
|
||||
Редизайн 1:1 Telegram — dual-layer маскировка, Lottie-иконки, плавные анимации и badge.
|
||||
|
||||
**Пуш-уведомления**
|
||||
Аватарки отправителей в системных пушах. In-app баннер 1-в-1 Telegram (glass-фон, жесты, анимации). Тап по групповому пушу теперь открывает группу, а не пустой чат. Надёжность аватарок: fallback scale, timeout safety. Desktop-suppression 30 сек.
|
||||
Аватарки в системных пушах. In-app баннер Telegram parity. Групповые пуши, Desktop-suppression, 65+ тестов.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1769,6 +1769,7 @@ private final class ChatDetailAvatarButton: UIControl {
|
||||
private let avatarBackgroundView = UIView()
|
||||
private let avatarImageView = UIImageView()
|
||||
private let initialsLabel = UILabel()
|
||||
private let bookmarkIcon = UIImageView()
|
||||
private let route: ChatRoute
|
||||
|
||||
init(route: ChatRoute) {
|
||||
@@ -1805,6 +1806,12 @@ private final class ChatDetailAvatarButton: UIControl {
|
||||
initialsLabel.textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
|
||||
initialsLabel.textAlignment = .center
|
||||
addSubview(initialsLabel)
|
||||
|
||||
bookmarkIcon.isUserInteractionEnabled = false
|
||||
bookmarkIcon.contentMode = .center
|
||||
bookmarkIcon.tintColor = .white
|
||||
bookmarkIcon.isHidden = true
|
||||
addSubview(bookmarkIcon)
|
||||
}
|
||||
|
||||
@objc private func avatarChanged() {
|
||||
@@ -1813,6 +1820,21 @@ private final class ChatDetailAvatarButton: UIControl {
|
||||
|
||||
private func updateAvatar() {
|
||||
let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
|
||||
|
||||
// Saved Messages: always show bookmark icon on blue background
|
||||
if route.isSavedMessages {
|
||||
avatarImageView.isHidden = true
|
||||
initialsLabel.isHidden = true
|
||||
bookmarkIcon.isHidden = false
|
||||
bookmarkIcon.image = UIImage(systemName: "bookmark.fill")?.withConfiguration(
|
||||
UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
||||
)
|
||||
avatarBackgroundView.backgroundColor = UIColor(RosettaColors.primaryBlue)
|
||||
return
|
||||
}
|
||||
|
||||
bookmarkIcon.isHidden = true
|
||||
|
||||
if let avatar {
|
||||
avatarImageView.image = avatar
|
||||
avatarImageView.isHidden = false
|
||||
@@ -1831,8 +1853,8 @@ private final class ChatDetailAvatarButton: UIControl {
|
||||
title = meta.title
|
||||
}
|
||||
|
||||
let displayInitial: String = route.isSavedMessages ? "S"
|
||||
: route.isGroup ? RosettaColors.groupInitial(name: title, publicKey: route.publicKey)
|
||||
let displayInitial: String = route.isGroup
|
||||
? RosettaColors.groupInitial(name: title, publicKey: route.publicKey)
|
||||
: RosettaColors.initials(name: title, publicKey: route.publicKey)
|
||||
initialsLabel.text = displayInitial
|
||||
|
||||
@@ -1881,6 +1903,7 @@ private final class ChatDetailAvatarButton: UIControl {
|
||||
avatarImageView.layer.cornerRadius = avatarDiam * 0.5
|
||||
|
||||
initialsLabel.frame = avatarFrame
|
||||
bookmarkIcon.frame = avatarFrame
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
|
||||
@@ -868,8 +868,115 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
configurePhoto(for: message)
|
||||
}
|
||||
|
||||
// File
|
||||
if let layout = currentLayout, layout.hasFile {
|
||||
// File (forwarded voice/file: convert ReplyAttachmentData → MessageAttachment, reuse same rendering)
|
||||
if let layout = currentLayout, layout.hasFile, layout.isForward, !layout.forwardAttachments.isEmpty {
|
||||
// Build a synthetic message with converted forward attachments + chacha password
|
||||
var syntheticMessage = message
|
||||
let convertedAtts: [MessageAttachment] = layout.forwardAttachments.compactMap { reply in
|
||||
guard let attType = reply.attachmentType else { return nil }
|
||||
return MessageAttachment(
|
||||
id: reply.id, preview: reply.preview, blob: "",
|
||||
type: attType,
|
||||
transportTag: reply.transport.transport_tag,
|
||||
transportServer: reply.transport.transport_server
|
||||
)
|
||||
}
|
||||
syntheticMessage.attachments = convertedAtts
|
||||
if !layout.forwardChachaKeyPlain.isEmpty {
|
||||
syntheticMessage.attachmentPassword = "rawkey:\(layout.forwardChachaKeyPlain)"
|
||||
}
|
||||
// Fall through to regular file rendering with synthetic message
|
||||
fileContainer.isHidden = false
|
||||
if let voiceAtt = syntheticMessage.attachments.first(where: { $0.type == .voice }) {
|
||||
let previewParts = Self.parseVoicePreview(voiceAtt.preview)
|
||||
voiceView.isHidden = false
|
||||
voiceView.frame = CGRect(x: 0, y: 0, width: fileContainer.bounds.width, height: 38)
|
||||
voiceView.configure(
|
||||
messageId: syntheticMessage.id,
|
||||
attachmentId: voiceAtt.id,
|
||||
preview: previewParts.waveform,
|
||||
duration: previewParts.duration,
|
||||
isOutgoing: layout.isOutgoing
|
||||
)
|
||||
let voiceAttachment = voiceAtt
|
||||
let storedPassword = syntheticMessage.attachmentPassword
|
||||
let playbackDuration = previewParts.duration
|
||||
let playbackMessageId = syntheticMessage.id
|
||||
|
||||
let voiceFileName = "voice_\(Int(playbackDuration))s.m4a"
|
||||
let isCached = Self.playableVoiceURLFromCache(
|
||||
attachmentId: voiceAttachment.id, fileName: voiceFileName
|
||||
) != nil
|
||||
voiceView.setDownloaded(isCached)
|
||||
|
||||
let isCurrentVoice = VoiceMessagePlayer.shared.currentMessageId == syntheticMessage.id
|
||||
voiceView.updatePlaybackState(
|
||||
isPlaying: isCurrentVoice && VoiceMessagePlayer.shared.isPlaying,
|
||||
progress: isCurrentVoice ? CGFloat(VoiceMessagePlayer.shared.progress) : 0
|
||||
)
|
||||
|
||||
voiceView.onPlayTapped = { [weak self] in
|
||||
guard let self else { return }
|
||||
if let cached = Self.playableVoiceURLFromCache(
|
||||
attachmentId: voiceAttachment.id, fileName: voiceFileName
|
||||
) {
|
||||
self.voiceView.setDownloaded(true)
|
||||
VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: cached)
|
||||
return
|
||||
}
|
||||
self.voiceView.showDownloadProgress(0.027)
|
||||
let downloadTask = Task {
|
||||
let playableURL = await Self.resolvePlayableVoiceURL(
|
||||
attachment: voiceAttachment,
|
||||
duration: playbackDuration,
|
||||
storedPassword: storedPassword,
|
||||
onProgress: { [weak self] progress in
|
||||
self?.voiceView.showDownloadProgress(CGFloat(progress))
|
||||
}
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.voiceView.hideDownloadProgress()
|
||||
if let playableURL, self.message?.id == playbackMessageId {
|
||||
VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: playableURL)
|
||||
}
|
||||
}
|
||||
self.activeVoiceDownloadTask = downloadTask
|
||||
}
|
||||
voiceView.onDownloadCancel = { [weak self] in
|
||||
self?.activeVoiceDownloadTask?.cancel()
|
||||
self?.voiceView.hideDownloadProgress()
|
||||
self?.activeVoiceDownloadTask = nil
|
||||
}
|
||||
fileIconView.isHidden = true
|
||||
fileNameLabel.isHidden = true
|
||||
fileSizeLabel.isHidden = true
|
||||
callArrowView.isHidden = true
|
||||
callBackButton.isHidden = true
|
||||
avatarImageView.isHidden = true
|
||||
} else if let fileAtt = syntheticMessage.attachments.first(where: { $0.type == .file }) {
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
|
||||
let isFileOutgoing = layout.isOutgoing
|
||||
avatarImageView.isHidden = true
|
||||
fileIconView.isHidden = false
|
||||
fileIconView.backgroundColor = isFileOutgoing
|
||||
? UIColor.white.withAlphaComponent(0.2)
|
||||
: UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
|
||||
let isCached = AttachmentCache.shared.fileURL(
|
||||
forAttachmentId: fileAtt.id, fileName: parsed.fileName
|
||||
) != nil
|
||||
let iconName = isCached ? Self.fileIcon(for: parsed.fileName) : "arrow.down"
|
||||
fileIconSymbolView.image = UIImage(systemName: iconName)
|
||||
fileIconSymbolView.tintColor = .white
|
||||
fileNameLabel.font = Self.fileNameFont
|
||||
fileNameLabel.text = parsed.fileName.isEmpty ? "File" : parsed.fileName
|
||||
fileNameLabel.textColor = isFileOutgoing ? .white : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
|
||||
fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
|
||||
fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
|
||||
callArrowView.isHidden = true
|
||||
callBackButton.isHidden = true
|
||||
voiceView.isHidden = true
|
||||
}
|
||||
} else if let layout = currentLayout, layout.hasFile {
|
||||
fileContainer.isHidden = false
|
||||
if let callAtt = message.attachments.first(where: { $0.type == .call }) {
|
||||
let durationSec = AttachmentPreviewCodec.parseCallDurationSeconds(callAtt.preview)
|
||||
@@ -1435,8 +1542,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
}
|
||||
} else {
|
||||
// File layout: vertically centered icon + title + size
|
||||
// For forwarded files, don't center — place at top (forward header above handles offset)
|
||||
let contentH: CGFloat = 44 // icon height dominates
|
||||
let topY = max(0, (centerableH - contentH) / 2)
|
||||
let topY: CGFloat = layout.isForward ? 5 : max(0, (centerableH - contentH) / 2)
|
||||
fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
|
||||
fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22)
|
||||
let textTopY = topY + 4
|
||||
@@ -1452,7 +1560,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
groupInviteContainer.frame = CGRect(x: 0, y: 0, width: layout.bubbleSize.width, height: layout.bubbleSize.height)
|
||||
let cW = layout.bubbleSize.width
|
||||
// Offset for forward header so group invite card doesn't overlap
|
||||
let topY: CGFloat = layout.isForward ? (41 + 10) : 10
|
||||
let topY: CGFloat = layout.isForward ? (41 + 3) : 10
|
||||
groupInviteIconBg.frame = CGRect(x: 10, y: topY, width: 44, height: 44)
|
||||
groupInviteInitialLabel.frame = groupInviteIconBg.bounds
|
||||
let textX: CGFloat = 64
|
||||
@@ -2294,9 +2402,133 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
} else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
|
||||
// Tap on file bubble → trigger download/share
|
||||
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: fileAtt.id)
|
||||
} else if let layout = currentLayout, layout.isForward,
|
||||
let fwdFile = layout.forwardAttachments.first(where: { $0.type == 2 }) {
|
||||
// Tap on forwarded file → download from CDN with forward chacha key, then share
|
||||
downloadAndOpenForwardedFile(replyAtt: fwdFile, chachaKeyPlain: layout.forwardChachaKeyPlain)
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAndOpenForwardedFile(replyAtt: ReplyAttachmentData, chachaKeyPlain: String) {
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(replyAtt.preview)
|
||||
let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName
|
||||
|
||||
// Check cache first — decrypt from .enc and share
|
||||
if AttachmentCache.shared.fileURL(forAttachmentId: replyAtt.id, fileName: fileName) != nil {
|
||||
presentForwardedFileShare(attachmentId: replyAtt.id, fileName: fileName)
|
||||
return
|
||||
}
|
||||
|
||||
let tag = replyAtt.effectiveDownloadTag
|
||||
guard !tag.isEmpty, !chachaKeyPlain.isEmpty else {
|
||||
#if DEBUG
|
||||
print("[FWD-FILE] SKIP: tag=\(replyAtt.effectiveDownloadTag.prefix(12)) chachaEmpty=\(chachaKeyPlain.isEmpty)")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let server = replyAtt.transport.transport_server
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(
|
||||
tag: tag, server: server.isEmpty ? nil : server
|
||||
)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
|
||||
// Use the ORIGINAL message's stored attachment password format
|
||||
// chacha_key_plain is the hex key from the original sender's attachmentPassword
|
||||
let storedPassword = "rawkey:\(chachaKeyPlain)"
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
|
||||
|
||||
#if DEBUG
|
||||
print("[FWD-FILE] tag=\(tag.prefix(12))… chachaKey=\(chachaKeyPlain.prefix(16))… passwords=\(passwords.count) encSize=\(encryptedData.count)")
|
||||
#endif
|
||||
|
||||
// Use exact same decrypt method as MessageFileView
|
||||
let crypto = CryptoManager.shared
|
||||
var decryptedData: Data?
|
||||
|
||||
// 1. Try with requireCompression (primary — prevents wrong-password garbage)
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password, requireCompression: true
|
||||
) {
|
||||
#if DEBUG
|
||||
print("[FWD-FILE] decrypted with compression: \(data.count) bytes")
|
||||
#endif
|
||||
decryptedData = data
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: without compression (legacy uncompressed payloads ONLY)
|
||||
if decryptedData == nil {
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(encryptedString, password: password) {
|
||||
#if DEBUG
|
||||
print("[FWD-FILE] decrypted WITHOUT compression: \(data.count) bytes — may be garbage!")
|
||||
#endif
|
||||
decryptedData = data
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let decryptedData else {
|
||||
#if DEBUG
|
||||
print("[FWD-FILE] ALL decrypt attempts failed")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
// Parse data URI if present (e.g. "data:text/html;base64,..."), otherwise use raw
|
||||
let fileData: Data
|
||||
if let decryptedString = String(data: decryptedData, encoding: .utf8),
|
||||
decryptedString.hasPrefix("data:"),
|
||||
let commaIndex = decryptedString.firstIndex(of: ",") {
|
||||
let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...])
|
||||
fileData = Data(base64Encoded: base64Part, options: .ignoreUnknownCharacters) ?? decryptedData
|
||||
#if DEBUG
|
||||
print("[FWD-FILE] data URI detected, base64 decoded: \(fileData.count) bytes")
|
||||
#endif
|
||||
} else {
|
||||
fileData = decryptedData
|
||||
}
|
||||
|
||||
AttachmentCache.shared.saveFile(fileData, forAttachmentId: replyAtt.id, fileName: fileName)
|
||||
self.presentForwardedFileShare(attachmentId: replyAtt.id, fileName: fileName)
|
||||
self.fileIconSymbolView.image = UIImage(systemName: Self.fileIcon(for: fileName))
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[FWD-FILE] download failed: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shares a forwarded file: decrypts from .enc cache, writes to temp dir with original name.
|
||||
private func presentForwardedFileShare(attachmentId: String, fileName: String) {
|
||||
// Files in AttachmentCache are encrypted at rest (.enc). Decrypt before sharing.
|
||||
guard let data = AttachmentCache.shared.loadFileData(
|
||||
forAttachmentId: attachmentId, fileName: fileName
|
||||
) else { return }
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||||
try? data.write(to: tempURL, options: .atomic)
|
||||
|
||||
guard let vc = findViewController() else { return }
|
||||
let activity = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
|
||||
vc.present(activity, animated: true)
|
||||
}
|
||||
|
||||
private func findViewController() -> UIViewController? {
|
||||
var responder: UIResponder? = self
|
||||
while let next = responder?.next {
|
||||
if let vc = next as? UIViewController { return vc }
|
||||
responder = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc private func handlePhotoTileTap(_ sender: UIButton) {
|
||||
// In selection mode: any tap toggles selection
|
||||
if isInSelectionMode {
|
||||
|
||||
@@ -138,6 +138,7 @@ struct OpponentProfileView: View {
|
||||
avatarInitials: RosettaColors.initials(name: displayName, publicKey: route.publicKey),
|
||||
avatarColorIndex: RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey),
|
||||
isMuted: isMuted,
|
||||
isSavedMessages: route.isSavedMessages,
|
||||
showCallButton: !route.isSavedMessages,
|
||||
showMuteButton: !route.isSavedMessages,
|
||||
showMessageButton: route.isSavedMessages || showMessageButton,
|
||||
|
||||
@@ -13,6 +13,7 @@ struct PeerProfileHeaderView: View {
|
||||
let avatarInitials: String
|
||||
let avatarColorIndex: Int
|
||||
let isMuted: Bool
|
||||
var isSavedMessages: Bool = false
|
||||
var showCallButton: Bool = true
|
||||
var showMuteButton: Bool = true
|
||||
var showMessageButton: Bool = false
|
||||
@@ -75,6 +76,14 @@ struct PeerProfileHeaderView: View {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else if isSavedMessages {
|
||||
// Saved Messages: bookmark icon on blue background
|
||||
ZStack {
|
||||
Rectangle().fill(RosettaColors.primaryBlue)
|
||||
Image(systemName: "bookmark.fill")
|
||||
.font(.system(size: isLargeHeader ? 80 : 38, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
} else {
|
||||
let pair = RosettaColors.avatarColors[avatarColorIndex % RosettaColors.avatarColors.count]
|
||||
let textColor: Color = colorScheme == .dark ? pair.text : pair.tint
|
||||
|
||||
@@ -1706,12 +1706,23 @@ private final class ChatListToolbarTitleView: UIControl {
|
||||
return CGSize(width: fitting.width, height: 30)
|
||||
}
|
||||
|
||||
private static let mantineDarkBody = UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1)
|
||||
|
||||
func configure(title: String, mode: Mode, initials: String, avatarIndex: Int, avatarImage: UIImage?) {
|
||||
titleLabel.text = title
|
||||
avatarInitialsLabel.text = initials
|
||||
|
||||
let tintColor = RosettaColors.avatarColor(for: avatarIndex)
|
||||
avatarContainer.backgroundColor = tintColor
|
||||
// Mantine "light" variant — same as ChatListCell (NOT solid tint)
|
||||
let colorPair = RosettaColors.avatarColors[avatarIndex % RosettaColors.avatarColors.count]
|
||||
let tintUIColor = UIColor(colorPair.tint)
|
||||
avatarContainer.backgroundColor = UIColor { traits in
|
||||
let dark = traits.userInterfaceStyle == .dark
|
||||
let base: UIColor = dark ? Self.mantineDarkBody : .white
|
||||
return base.blended(with: tintUIColor, alpha: dark ? 0.15 : 0.10)
|
||||
}
|
||||
avatarInitialsLabel.textColor = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? UIColor(colorPair.text) : tintUIColor
|
||||
}
|
||||
|
||||
if let avatarImage {
|
||||
avatarImageView.image = avatarImage
|
||||
|
||||
Reference in New Issue
Block a user