Выделение сообщений + пересылка вложений (фото/голосовые/файлы/инвайты)

This commit is contained in:
2026-04-16 11:54:41 +05:00
parent 8a1afd8262
commit 0046ebd9fe
7 changed files with 315 additions and 18 deletions

View File

@@ -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 {

View File

@@ -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+ тестов.
"""
)
]

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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

View File

@@ -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