Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift

1291 lines
48 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
import Combine
import SwiftUI
// MARK: - ChatDetailViewController
/// Pure UIKit replacement for SwiftUI ChatDetailView.
/// Hosts NativeMessageListController as child VC, adds custom toolbar + edge effects.
/// Phase 1: shell + child VC + state + callbacks + Combine subscriptions.
final class ChatDetailViewController: UIViewController {
// MARK: - Route & ViewModel
let route: ChatRoute
private let viewModel: ChatDetailViewModel
private var cancellables = Set<AnyCancellable>()
// MARK: - Child VC
private var messageListController: NativeMessageListController!
private let cellActions = MessageCellActions()
// MARK: - State (mirrors SwiftUI @State)
private var messageText = ""
private var isViewActive = false
private var isInputFocused = false
private var isAtBottom = true
private var composerHeight: CGFloat = 56
private var firstUnreadMessageId: String?
private var replyingToMessage: ChatMessage?
private var scrollToMessageId: String?
private var highlightedMessageId: String?
private var scrollToBottomTrigger: UInt = 0
private var pendingAttachments: [PendingAttachment] = []
private var forwardingMessage: ChatMessage?
private var messageToDelete: ChatMessage?
private var isMultiSelectMode = false
private var selectedMessageIds: Set<String> = []
// MARK: - Cached
private let currentPublicKey = SessionManager.shared.currentPublicKey
// MARK: - Message fingerprint (for diffing)
private var lastMessageFingerprint = ""
private var lastNewestMessageId: String?
private var lastScrollTargetId: String?
private var lastScrollTrigger: UInt = 0
// MARK: - Toolbar UI
private let headerBarHeight: CGFloat = 44
private var backButton: UIControl!
private var titlePill: UIControl!
private var avatarButton: UIControl!
// MARK: - Edge Effects
private let topEdgeEffectView = VariableBlurEdgeView(frame: .zero)
// MARK: - Init
init(route: ChatRoute) {
self.route = route
self.viewModel = ChatDetailViewModel(dialogKey: route.publicKey)
super.init(nibName: nil, bundle: nil)
hidesBottomBarWhenPushed = true
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
setupMessageListController()
setupNavigationChrome()
setupEdgeEffects()
wireCellActions()
wireViewModelSubscriptions()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Nav bar hidden BEFORE animation starts (no flash).
// Also handled by ChatListRootViewController willShow delegate.
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
setupFullWidthSwipeBack()
activateChat()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
deactivateChat()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutCustomHeader()
updateEdgeEffectFrames()
}
// MARK: - Full-Width Swipe Back
private var addedSwipeBackGesture = false
private func setupFullWidthSwipeBack() {
guard !addedSwipeBackGesture else { return }
addedSwipeBackGesture = true
guard let nav = navigationController,
let edgeGesture = nav.interactivePopGestureRecognizer,
let targets = edgeGesture.value(forKey: "targets") as? NSArray,
targets.count > 0 else { return }
edgeGesture.isEnabled = true
let fullWidthGesture = UIPanGestureRecognizer()
fullWidthGesture.setValue(targets, forKey: "targets")
fullWidthGesture.delegate = self
nav.view.addGestureRecognizer(fullWidthGesture)
}
// MARK: - Navigation Bar
func navigationController(
_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool
) {
let hide = viewController === self
navigationController.setNavigationBarHidden(hide, animated: animated)
}
// MARK: - Child VC Setup
private func setupMessageListController() {
let config = NativeMessageListController.Config(
maxBubbleWidth: calculateMaxBubbleWidth(),
currentPublicKey: currentPublicKey,
highlightedMessageId: nil,
isSavedMessages: route.isSavedMessages,
isSystemAccount: route.isSystemAccount,
isGroupChat: route.isGroup,
opponentPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username,
actions: cellActions,
firstUnreadMessageId: firstUnreadMessageId
)
let controller = NativeMessageListController(config: config)
controller.hasMoreMessages = viewModel.hasMoreMessages
// Setup composer
if !route.isSystemAccount {
controller.setupComposer()
}
// Wire callbacks
wireMessageListCallbacks(controller)
// Add as child VC
addChild(controller)
controller.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.topAnchor.constraint(equalTo: view.topAnchor),
controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
controller.didMove(toParent: self)
// Force view load
controller.loadViewIfNeeded()
messageListController = controller
}
// MARK: - Toolbar Setup (glass capsules as direct subviews)
private func setupNavigationChrome() {
// Back button
let back = ChatDetailBackButton()
back.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
back.layer.zPosition = 55
view.addSubview(back)
backButton = back
// Title pill
let title = ChatDetailTitlePill(route: route, viewModel: viewModel)
title.addTarget(self, action: #selector(titleTapped), for: .touchUpInside)
title.layer.zPosition = 55
view.addSubview(title)
titlePill = title
// Avatar
let avatar = ChatDetailAvatarButton(route: route)
avatar.addTarget(self, action: #selector(avatarTapped), for: .touchUpInside)
avatar.layer.zPosition = 55
view.addSubview(avatar)
avatarButton = avatar
}
private func layoutCustomHeader() {
let safeTop = view.safeAreaInsets.top
let centerY = safeTop + headerBarHeight * 0.5
// Back button (left)
let backSize = backButton.intrinsicContentSize
backButton.frame = CGRect(
x: 6,
y: centerY - backSize.height * 0.5,
width: backSize.width,
height: backSize.height
)
// Avatar (right) 44×44 glass circle with 38pt avatar inside
let avatarOuterSize: CGFloat = 44
let avatarRight: CGFloat = 16
avatarButton.frame = CGRect(
x: view.bounds.width - avatarRight - avatarOuterSize,
y: centerY - avatarOuterSize * 0.5,
width: avatarOuterSize,
height: avatarOuterSize
)
// Title pill (center, between back and avatar)
let titleLeft = backButton.frame.maxX + 8
let titleRight = avatarButton.frame.minX - 8
let titleWidth = titleRight - titleLeft
let titleHeight: CGFloat = 44
titlePill.frame = CGRect(
x: titleLeft,
y: centerY - titleHeight * 0.5,
width: titleWidth,
height: titleHeight
)
}
// MARK: - Edge Effects
private func setupEdgeEffects() {
topEdgeEffectView.isUserInteractionEnabled = false
topEdgeEffectView.layer.zPosition = 40
let isDark = traitCollection.userInterfaceStyle == .dark
topEdgeEffectView.setTintColor(isDark ? .black : .white)
view.addSubview(topEdgeEffectView)
}
private func updateEdgeEffectFrames() {
let edgeHeight = view.safeAreaInsets.top + headerBarHeight + 14
topEdgeEffectView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: edgeHeight)
topEdgeEffectView.update(size: topEdgeEffectView.bounds.size, edgeSize: 54, contentAlpha: 0.85)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
let isDark = traitCollection.userInterfaceStyle == .dark
topEdgeEffectView.setTintColor(isDark ? .black : .white)
}
}
// MARK: - Callback Wiring (NativeMessageListController)
private func wireMessageListCallbacks(_ controller: NativeMessageListController) {
controller.onScrollToBottomVisibilityChange = { [weak self] atBottom in
self?.isAtBottom = atBottom
}
controller.onPaginationTrigger = { [weak self] in
Task { await self?.viewModel.loadMore() }
}
controller.onBottomPaginationTrigger = { [weak self] in
Task { await self?.viewModel.loadNewer() }
}
controller.onJumpToBottom = { [weak self] in
self?.viewModel.jumpToBottom()
}
controller.onTapBackground = { [weak self] in
self?.view.endEditing(true)
}
controller.onComposerHeightChange = { [weak self] h in
self?.composerHeight = h
}
controller.onKeyboardDidHide = { [weak self] in
self?.isInputFocused = false
}
controller.onComposerSend = { [weak self] in
self?.sendCurrentMessage()
}
controller.onComposerAttach = { [weak self] in
self?.showAttachmentPanel()
}
controller.onComposerTextChange = { [weak self] text in
self?.messageText = text
}
controller.onComposerFocusChange = { [weak self] focused in
self?.isInputFocused = focused
}
controller.onComposerReplyCancel = { [weak self] in
self?.replyingToMessage = nil
}
controller.onComposerTyping = { [weak self] in
self?.handleComposerUserTyping()
}
}
// MARK: - Combine Subscriptions
private func wireViewModelSubscriptions() {
viewModel.$messages
.receive(on: DispatchQueue.main)
.sink { [weak self] messages in
self?.handleMessagesUpdate(messages)
}
.store(in: &cancellables)
}
private func handleMessagesUpdate(_ messages: [ChatMessage]) {
guard let controller = messageListController else { return }
controller.hasMoreMessages = viewModel.hasMoreMessages
controller.hasNewerMessages = viewModel.hasNewerMessages
let fingerprint = messageFingerprint(messages)
guard fingerprint != lastMessageFingerprint else { return }
let wasAtBottom = isAtBottom
let oldNewestId = lastNewestMessageId
let newNewestId = messages.last?.id
let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true
controller.update(messages: messages)
lastMessageFingerprint = fingerprint
lastNewestMessageId = newNewestId
if (wasAtBottom || lastIsOutgoing),
newNewestId != oldNewestId, newNewestId != nil {
DispatchQueue.main.async {
controller.scrollToBottom(animated: true)
}
}
}
private func messageFingerprint(_ messages: [ChatMessage]) -> String {
guard let first = messages.first, let last = messages.last else { return "empty" }
return "\(messages.count)|\(first.id)|\(last.id)|\(last.deliveryStatus.rawValue)|\(last.isRead)"
}
// MARK: - Cell Actions
private func wireCellActions() {
cellActions.onReply = { [weak self] msg in
self?.replyingToMessage = msg
self?.isInputFocused = true
self?.syncComposerReplyState()
}
cellActions.onForward = { [weak self] msg in
self?.forwardingMessage = msg
self?.showForwardPicker()
}
cellActions.onDelete = { [weak self] msg in
self?.messageToDelete = msg
self?.showDeleteAlert()
}
cellActions.onCopy = { text in
UIPasteboard.general.string = text
}
cellActions.onImageTap = { [weak self] attId, frame, sourceView in
self?.openImageViewer(attachmentId: attId, sourceFrame: frame, sourceView: sourceView)
}
ImageViewerPresenter.shared.onShowInChat = { [weak self] messageId in
self?.scrollToMessage(id: messageId)
}
cellActions.onScrollToMessage = { [weak self] msgId in
guard let self else { return }
Task { @MainActor in
guard await self.viewModel.ensureMessageLoaded(messageId: msgId) else { return }
self.scrollToMessage(id: msgId)
}
}
cellActions.onRetry = { [weak self] msg in self?.retryMessage(msg) }
cellActions.onRemove = { [weak self] msg in self?.removeMessage(msg) }
cellActions.onCall = { [weak self] peerKey in
guard let self else { return }
let peerTitle = self.dialog?.opponentTitle ?? self.route.title
let peerUsername = self.dialog?.opponentUsername ?? self.route.username
let result = CallManager.shared.startOutgoingCall(
toPublicKey: peerKey, title: peerTitle, username: peerUsername
)
if case .alreadyInCall = result {
self.showAlert(title: "Call Error", message: "You are already in another call.")
}
}
cellActions.onGroupInviteTap = { [weak self] invite in
guard let self else { return }
if let parsed = GroupRepository.shared.parseInviteString(invite) {
self.showGroupInviteAlert(invite: invite, title: parsed.title)
}
}
cellActions.onEnterSelection = { [weak self] msg in
self?.isMultiSelectMode = true
self?.selectedMessageIds = [msg.id]
self?.messageListController?.setSelectionMode(true, animated: true)
}
cellActions.onToggleSelection = { [weak self] msgId in
guard let self else { return }
if self.selectedMessageIds.contains(msgId) {
self.selectedMessageIds.remove(msgId)
} else {
self.selectedMessageIds.insert(msgId)
}
self.messageListController?.updateSelectedIds(self.selectedMessageIds)
}
cellActions.onMentionTap = { [weak self] username in
self?.handleMentionTap(username: username)
}
cellActions.onAvatarTap = { [weak self] senderKey in
self?.handleAvatarTap(senderKey: senderKey)
}
cellActions.onGroupInviteOpen = { dialogKey in
let title = DialogRepository.shared.dialogs[dialogKey]?.opponentTitle ?? "Group"
let route = ChatRoute(groupDialogKey: dialogKey, title: title)
NotificationCenter.default.post(name: .openChatFromNotification, object: route)
}
}
// MARK: - Chat Lifecycle
private func activateChat() {
isViewActive = true
// Capture first unread
if firstUnreadMessageId == nil {
firstUnreadMessageId = viewModel.messages.first(where: {
!$0.isRead && $0.fromPublicKey != currentPublicKey
})?.id
}
// Restore draft
let draft = DraftManager.shared.getDraft(for: route.publicKey)
if !draft.isEmpty {
messageText = draft
messageListController?.composerView?.setText(draft)
}
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
SessionManager.shared.resetIdleTimer()
updateReadEligibility()
// Deferred activation (300ms matches push animation)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
guard let self, self.isViewActive else { return }
self.activateDialog()
self.clearDeliveredNotifications(for: self.route.publicKey)
self.markDialogAsRead()
DialogRepository.shared.setMention(opponentKey: self.route.publicKey, hasMention: false)
self.requestUserInfoIfNeeded()
self.updateReadEligibility()
self.markDialogAsRead()
if !self.route.isSystemAccount {
SessionManager.shared.subscribeToOnlineStatus(publicKey: self.route.publicKey)
if !self.route.isSavedMessages {
SessionManager.shared.forceRefreshUserInfo(publicKey: self.route.publicKey)
}
}
}
}
private func deactivateChat() {
firstUnreadMessageId = nil
markDialogAsRead()
isViewActive = false
updateReadEligibility()
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
SessionManager.shared.stopIdleTimer()
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
}
// MARK: - Helpers (ported from ChatDetailView)
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private func calculateMaxBubbleWidth() -> CGFloat {
let screenWidth = UIScreen.main.bounds.width
let listInsets: CGFloat = 20
let bubbleMargins: CGFloat = 16
let available = max(40, screenWidth - listInsets - bubbleMargins)
let compactInset: CGFloat = 36
let freeFill: CGFloat = screenWidth > 680 ? 0.65 : 0.85
return max(40, min(available - compactInset, available * freeFill))
}
private func syncComposerReplyState() {
guard let composer = messageListController?.composerView else { return }
if let reply = replyingToMessage {
let senderName = senderDisplayName(for: reply.fromPublicKey)
let preview = replyPreviewText(for: reply)
composer.setReply(senderName: senderName, previewText: preview)
} else {
composer.setReply(senderName: nil, previewText: nil)
}
}
// MARK: - Navigation Actions
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func titleTapped() {
openProfile()
}
@objc private func avatarTapped() {
openProfile()
}
private func openProfile() {
view.endEditing(true)
if route.isGroup {
let groupInfo = GroupInfoView(groupDialogKey: route.publicKey)
let hosting = UIHostingController(rootView: groupInfo)
navigationController?.pushViewController(hosting, animated: true)
} else if !route.isSystemAccount {
let profile = OpponentProfileView(route: route)
let hosting = UIHostingController(rootView: profile)
navigationController?.pushViewController(hosting, animated: true)
}
}
private func handleMentionTap(username: String) {
guard username.lowercased() != "all" else { return }
let myUsername = AccountManager.shared.currentAccount?.username?.lowercased() ?? ""
if !myUsername.isEmpty && username.lowercased() == myUsername {
if let saved = DialogRepository.shared.sortedDialogs.first(where: { $0.isSavedMessages }) {
let vc = ChatDetailViewController(route: ChatRoute(dialog: saved))
navigationController?.pushViewController(vc, animated: true)
}
return
}
if let dialog = DialogRepository.shared.sortedDialogs.first(where: {
$0.opponentUsername.lowercased() == username.lowercased()
}) {
let vc = ChatDetailViewController(route: ChatRoute(dialog: dialog))
navigationController?.pushViewController(vc, animated: true)
}
}
private func handleAvatarTap(senderKey: String) {
let profileRoute: ChatRoute
if let dialog = DialogRepository.shared.sortedDialogs.first(where: { $0.opponentKey == senderKey }) {
profileRoute = ChatRoute(dialog: dialog)
} else {
profileRoute = ChatRoute(publicKey: senderKey, title: String(senderKey.prefix(8)), username: "", verified: 0)
}
let profile = OpponentProfileView(route: profileRoute)
let hosting = UIHostingController(rootView: profile)
navigationController?.pushViewController(hosting, animated: true)
}
// MARK: - Sheets & Alerts
private func showAttachmentPanel() {
let panel = AttachmentPanelView(
onSend: { [weak self] attachments, caption in
guard let self else { return }
let trimmed = caption.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty { self.messageText = trimmed }
self.handleAttachmentsSend(attachments)
},
onSendAvatar: { [weak self] in
self?.sendAvatarToChat()
},
hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil,
onSetAvatar: { [weak self] in
self?.showAlert(title: "No Avatar", message: "Set a profile photo in Settings to share it with contacts.")
}
)
let hosting = UIHostingController(rootView: panel)
if let sheet = hosting.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
}
present(hosting, animated: true)
}
private func showForwardPicker() {
let picker = ForwardChatPickerView { [weak self] targetRoutes in
guard let self else { return }
self.dismiss(animated: true)
guard let message = self.forwardingMessage else { return }
self.forwardingMessage = nil
for route in targetRoutes {
self.forwardMessage(message, to: route)
}
}
let hosting = UIHostingController(rootView: picker)
if let sheet = hosting.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
}
present(hosting, animated: true)
}
private func showDeleteAlert() {
let alert = UIAlertController(
title: "Delete Message",
message: "Are you sure you want to delete this message? This action cannot be undone.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
guard let self, let message = self.messageToDelete else { return }
self.removeMessage(message)
self.messageToDelete = nil
if self.isMultiSelectMode {
self.isMultiSelectMode = false
self.selectedMessageIds.removeAll()
self.messageListController?.setSelectionMode(false, animated: true)
}
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
self?.messageToDelete = nil
})
present(alert, animated: true)
}
private func showGroupInviteAlert(invite: String, title: String) {
let alert = UIAlertController(
title: "Join Group",
message: "Join \"\(title)\"?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Join", style: .default) { _ in
Task {
do {
let route = try await GroupService.shared.joinGroup(inviteString: invite)
NotificationCenter.default.post(
name: .openChatFromNotification,
object: ChatRoute(groupDialogKey: route.publicKey, title: route.title)
)
} catch {
// Error handling
}
}
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(alert, animated: true)
}
private func showAlert(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
// MARK: - Scroll
private func scrollToMessage(id: String) {
messageListController?.scrollToMessage(id: id, animated: true)
// Highlight animation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.035) { [weak self] in
self?.messageListController?.animateHighlight(messageId: id)
}
}
// MARK: - Message Actions (ported from ChatDetailView)
private func sendCurrentMessage() {
let trimmed = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
let attachments = pendingAttachments
let replyMessage = replyingToMessage
guard !trimmed.isEmpty || !attachments.isEmpty else { return }
messageText = ""
pendingAttachments = []
DraftManager.shared.deleteDraft(for: route.publicKey)
Task { @MainActor in
do {
if !attachments.isEmpty {
_ = try await SessionManager.shared.sendMessageWithAttachments(
text: trimmed,
attachments: attachments,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} else if let replyMsg = replyMessage {
let replyData = buildReplyData(from: replyMsg)
try await SessionManager.shared.sendMessageWithReply(
text: trimmed,
replyMessages: [replyData],
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} else {
try await SessionManager.shared.sendMessage(
text: trimmed,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
}
messageListController?.composerView?.setText("")
replyingToMessage = nil
syncComposerReplyState()
} catch {
showAlert(title: "Send Error", message: error.localizedDescription)
}
}
}
private func retryMessage(_ message: ChatMessage) {
let text = message.text
let toKey = message.toPublicKey
MessageRepository.shared.deleteMessage(id: message.id)
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: toKey)
Task {
try? await SessionManager.shared.sendMessage(text: text, toPublicKey: toKey)
}
}
private func removeMessage(_ message: ChatMessage) {
MessageRepository.shared.deleteMessage(id: message.id)
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey)
}
private func handleAttachmentsSend(_ attachments: [PendingAttachment]) {
pendingAttachments = attachments
sendCurrentMessage()
}
private func sendAvatarToChat() {
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 handleComposerUserTyping() {
guard !route.isSavedMessages, !route.isSystemAccount else { return }
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
}
// MARK: - Read Receipts & Dialog
private func markDialogAsRead() {
guard MessageRepository.shared.isDialogReadEligible(route.publicKey) else { return }
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
if !route.isSystemAccount {
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
}
}
private func updateReadEligibility() {
MessageRepository.shared.setDialogReadEligible(route.publicKey, isEligible: isViewActive && isAtBottom)
}
private func activateDialog() {
if DialogRepository.shared.dialogs[route.publicKey] != nil {
DialogRepository.shared.ensureDialog(
opponentKey: route.publicKey,
title: route.title,
username: route.username,
verified: route.verified,
myPublicKey: currentPublicKey
)
}
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
updateReadEligibility()
}
private func requestUserInfoIfNeeded() {
SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey)
}
private func clearDeliveredNotifications(for senderKey: String) {
UNUserNotificationCenter.current().getDeliveredNotifications { notifs in
let ids = notifs
.filter { $0.request.content.userInfo["sender_public_key"] as? String == senderKey }
.map(\.request.identifier)
if !ids.isEmpty {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
}
}
}
// MARK: - Image Viewer
private func openImageViewer(attachmentId: String, sourceFrame: CGRect, sourceView: UIView?) {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
var allImages: [ViewableImageInfo] = []
for message in viewModel.messages {
let senderName = senderDisplayName(for: message.fromPublicKey)
let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000)
for attachment in message.attachments where attachment.type == .image {
allImages.append(ViewableImageInfo(
attachmentId: attachment.id,
messageId: message.id,
senderName: senderName,
timestamp: timestamp,
caption: message.text
))
}
for attachment in message.attachments where attachment.type == .messages {
if let replyMessages = parseReplyBlob(attachment.blob) {
for reply in replyMessages {
let fwdSenderName = senderDisplayName(for: reply.publicKey)
let fwdTimestamp = Date(timeIntervalSince1970: Double(reply.timestamp) / 1000)
for att in reply.attachments where att.type == AttachmentType.image.rawValue {
allImages.append(ViewableImageInfo(
attachmentId: att.id,
messageId: message.id,
senderName: fwdSenderName,
timestamp: fwdTimestamp,
caption: reply.message
))
}
}
}
}
}
let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
let state = ImageViewerState(images: allImages, initialIndex: index, sourceFrame: sourceFrame)
ImageViewerPresenter.shared.present(state: state, sourceView: sourceView)
}
private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? {
guard !blob.isEmpty, let data = blob.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode([ReplyMessageData].self, from: data)
}
// MARK: - Forward
private func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
// Unwrap forwarded messages if the message itself is a forward (.messages attachment)
var forwardDataList: [ReplyMessageData]
if let msgAtt = message.attachments.first(where: { $0.type == .messages }),
let innerMessages = parseReplyBlob(msgAtt.blob), !innerMessages.isEmpty {
forwardDataList = innerMessages
} else {
forwardDataList = [buildReplyData(from: message)]
}
let targetKey = targetRoute.publicKey
let targetTitle = targetRoute.title
let targetUsername = targetRoute.username
Task { @MainActor in
do {
try await SessionManager.shared.sendMessageWithReply(
text: "",
replyMessages: forwardDataList,
toPublicKey: targetKey,
opponentTitle: targetTitle,
opponentUsername: targetUsername
)
} catch {
showAlert(title: "Forward Error", message: error.localizedDescription)
}
}
}
// MARK: - Helpers (ported)
private func senderDisplayName(for publicKey: String) -> String {
if publicKey == currentPublicKey {
return AccountManager.shared.currentAccount?.displayName
?? SessionManager.shared.displayName
}
return DialogRepository.shared.dialogs[publicKey]?.opponentTitle
?? String(publicKey.prefix(8))
}
private func replyPreviewText(for message: ChatMessage) -> String {
let attachmentLabel: String? = {
for att in message.attachments {
switch att.type {
case .image: return "Photo"
case .file:
let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return att.id.isEmpty ? "File" : att.id
case .avatar: return "Avatar"
case .messages: return "Forwarded message"
case .call: return "Call"
case .voice: return "Voice message"
@unknown default: return "Attachment"
}
}
return nil
}()
let visibleText: String = {
let stripped = message.text
.trimmingCharacters(in: .whitespacesAndNewlines)
.filter { !$0.isASCII || $0.asciiValue! >= 0x20 }
if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" }
return stripped
}()
let visibleTextDecoded = EmojiParser.replaceShortcodes(in: visibleText)
if attachmentLabel != nil, !visibleTextDecoded.isEmpty { return visibleTextDecoded }
if let label = attachmentLabel { return label }
if !visibleTextDecoded.isEmpty { return visibleTextDecoded }
return ""
}
private func buildReplyData(from message: ChatMessage) -> ReplyMessageData {
let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in
guard att.type != .messages else { return nil }
return ReplyAttachmentData(
id: att.id,
type: att.type.rawValue,
preview: att.preview,
blob: "",
transport: ReplyAttachmentTransport(
transport_tag: att.transportTag,
transport_server: att.transportServer
)
)
}
if replyAttachments.isEmpty,
let msgAtt = message.attachments.first(where: { $0.type == .messages }),
let innerMessages = parseReplyBlob(msgAtt.blob),
let firstInner = innerMessages.first {
return firstInner
}
let cleanText = MessageCellView.isGarbageText(message.text) ? "" : message.text
let hexKey: String
if let password = message.attachmentPassword, password.hasPrefix("rawkey:") {
hexKey = String(password.dropFirst("rawkey:".count))
} else {
hexKey = ""
}
return ReplyMessageData(
message_id: message.id,
publicKey: message.fromPublicKey,
message: cleanText,
timestamp: message.timestamp,
attachments: replyAttachments,
chacha_key_plain: hexKey
)
}
}
// MARK: - Toolbar Components
/// Back button 44×44 glass capsule with Telegram SVG chevron (filled, not stroked).
/// Uses exact `TelegramIconPath.backChevron` SVG path data via `SVGPathParser`.
private final class ChatDetailBackButton: UIControl {
private let glassView = TelegramGlassUIView(frame: .zero)
private let chevronLayer = CAShapeLayer()
private static let viewBox = CGSize(width: 10.7, height: 19.63)
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
private func setupUI() {
glassView.isUserInteractionEnabled = false
addSubview(glassView)
chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor
layer.addSublayer(chevronLayer)
}
private func updateChevronPath() {
guard bounds.width > 0 else { return }
let iconSize = CGSize(width: 11, height: 20)
let origin = CGPoint(
x: (bounds.width - iconSize.width) / 2,
y: (bounds.height - iconSize.height) / 2
)
// Parse exact Telegram SVG path
var parser = SVGPathParser(pathData: TelegramIconPath.backChevron)
let rawPath = parser.parse()
// Scale from viewBox to icon size, then translate to center
let vb = Self.viewBox
var transform = CGAffineTransform(translationX: origin.x, y: origin.y)
.scaledBy(x: iconSize.width / vb.width, y: iconSize.height / vb.height)
let scaledPath = rawPath.copy(using: &transform)
chevronLayer.path = scaledPath
chevronLayer.frame = bounds
}
override var intrinsicContentSize: CGSize {
CGSize(width: 44, height: 44)
}
override func layoutSubviews() {
super.layoutSubviews()
glassView.frame = bounds
glassView.fixedCornerRadius = bounds.height * 0.5
glassView.updateGlass()
updateChevronPath()
}
override var isHighlighted: Bool {
didSet { alpha = isHighlighted ? 0.6 : 1.0 }
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor
}
}
}
/// Title pill with glass capsule name + subtitle
private final class ChatDetailTitlePill: UIControl {
private let glassView = TelegramGlassUIView(frame: .zero)
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let route: ChatRoute
private weak var viewModel: ChatDetailViewModel?
private var cancellables = Set<AnyCancellable>()
init(route: ChatRoute, viewModel: ChatDetailViewModel) {
self.route = route
self.viewModel = viewModel
super.init(frame: .zero)
setupUI()
observeChanges()
updateContent()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
private func setupUI() {
glassView.isUserInteractionEnabled = false
addSubview(glassView)
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = .white
titleLabel.textAlignment = .center
titleLabel.lineBreakMode = .byTruncatingTail
addSubview(titleLabel)
subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
subtitleLabel.textAlignment = .center
subtitleLabel.lineBreakMode = .byTruncatingTail
addSubview(subtitleLabel)
}
private func observeChanges() {
// Observe typing state changes
viewModel?.$isTyping
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.updateContent() }
.store(in: &cancellables)
viewModel?.$typingSenderNames
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.updateContent() }
.store(in: &cancellables)
// Observe dialog changes for online status (@Observable pattern)
observeDialogChanges()
}
private func observeDialogChanges() {
withObservationTracking {
_ = DialogRepository.shared.dialogs
} onChange: { [weak self] in
Task { @MainActor [weak self] in
self?.updateContent()
self?.observeDialogChanges()
}
}
}
private func updateContent() {
let dialog = DialogRepository.shared.dialogs[route.publicKey]
// Title
var title = route.title
if route.isSavedMessages { title = "Saved Messages" }
else if route.isGroup {
if let meta = GroupRepository.shared.groupMetadata(
account: SessionManager.shared.currentPublicKey,
groupDialogKey: route.publicKey
), !meta.title.isEmpty { title = meta.title }
}
if title.isEmpty, let d = dialog, !d.opponentTitle.isEmpty { title = d.opponentTitle }
if title.isEmpty { title = String(route.publicKey.prefix(12)) }
titleLabel.text = title
// Subtitle
var subtitle = "offline"
var subtitleColor = UIColor.secondaryLabel
if route.isSavedMessages { subtitle = "" }
else if route.isSystemAccount { subtitle = "official account" }
else if route.isGroup {
let names = viewModel?.typingSenderNames ?? []
if !names.isEmpty {
subtitle = names.count == 1 ? "\(names[0]) typing" : "\(names[0]) and \(names.count - 1) typing"
subtitleColor = UIColor(RosettaColors.primaryBlue)
} else { subtitle = "group" }
} else if viewModel?.isTyping == true {
subtitle = "typing"
subtitleColor = UIColor(RosettaColors.primaryBlue)
} else if dialog?.isOnline == true {
subtitle = "online"
subtitleColor = UIColor(RosettaColors.online)
}
subtitleLabel.text = subtitle
subtitleLabel.textColor = subtitleColor
}
override func layoutSubviews() {
super.layoutSubviews()
glassView.frame = bounds
glassView.fixedCornerRadius = bounds.height * 0.5
glassView.updateGlass()
// VStack(spacing: 1) centered, 16pt H-padding
let hPad: CGFloat = 16
let contentW = bounds.width - hPad * 2
let hasSubtitle = !(subtitleLabel.text?.isEmpty ?? true)
if hasSubtitle {
// Title 18pt line + 1pt spacing + subtitle 15pt line = 34pt total
let titleH: CGFloat = 18
let subtitleH: CGFloat = 15
let spacing: CGFloat = 1
let totalH = titleH + spacing + subtitleH
let topY = (bounds.height - totalH) / 2
titleLabel.frame = CGRect(x: hPad, y: topY, width: contentW, height: titleH)
subtitleLabel.frame = CGRect(x: hPad, y: topY + titleH + spacing, width: contentW, height: subtitleH)
} else {
titleLabel.frame = CGRect(x: hPad, y: 0, width: contentW, height: bounds.height)
}
}
override var isHighlighted: Bool {
didSet { alpha = isHighlighted ? 0.6 : 1.0 }
}
}
/// Avatar button with glass circle
private final class ChatDetailAvatarButton: UIControl {
private let glassView = TelegramGlassUIView(frame: .zero)
private let avatarImageView = UIImageView()
private let initialsLabel = UILabel()
private let route: ChatRoute
init(route: ChatRoute) {
self.route = route
super.init(frame: .zero)
setupUI()
updateAvatar()
// Observe avatar changes
NotificationCenter.default.addObserver(
self, selector: #selector(avatarChanged),
name: .init("AvatarRepositoryDidChange"), object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
private func setupUI() {
glassView.isUserInteractionEnabled = false
addSubview(glassView)
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
addSubview(avatarImageView)
initialsLabel.font = .systemFont(ofSize: 16, weight: .medium)
initialsLabel.textColor = .white
initialsLabel.textAlignment = .center
addSubview(initialsLabel)
}
@objc private func avatarChanged() {
updateAvatar()
}
private func updateAvatar() {
let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
if let avatar {
avatarImageView.image = avatar
avatarImageView.isHidden = false
initialsLabel.isHidden = true
} else {
avatarImageView.isHidden = true
initialsLabel.isHidden = false
let title = route.title.isEmpty ? String(route.publicKey.prefix(8)) : route.title
initialsLabel.text = route.isSavedMessages ? "S"
: route.isGroup ? RosettaColors.groupInitial(name: title, publicKey: route.publicKey)
: RosettaColors.initials(name: title, publicKey: route.publicKey)
let colorIndex = RosettaColors.avatarColorIndex(for: title, publicKey: route.publicKey)
backgroundColor = RosettaColors.avatarColor(for: colorIndex)
}
}
override var intrinsicContentSize: CGSize {
CGSize(width: 44, height: 44)
}
override func layoutSubviews() {
super.layoutSubviews()
// Glass circle (full 44×44)
glassView.frame = bounds
glassView.fixedCornerRadius = bounds.height * 0.5
glassView.updateGlass()
// Avatar (38×38 centered inside 44×44)
let avatarDiam: CGFloat = 38
let pad = (bounds.width - avatarDiam) / 2
let avatarFrame = CGRect(x: pad, y: pad, width: avatarDiam, height: avatarDiam)
avatarImageView.frame = avatarFrame
avatarImageView.layer.cornerRadius = avatarDiam * 0.5
initialsLabel.frame = avatarFrame
layer.cornerRadius = bounds.height * 0.5
clipsToBounds = true
}
override var isHighlighted: Bool {
didSet { alpha = isHighlighted ? 0.6 : 1.0 }
}
}
// MARK: - UIGestureRecognizerDelegate (Full-Width Swipe Back)
extension ChatDetailViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
let velocity = pan.velocity(in: pan.view)
return velocity.x > 0 && abs(velocity.x) > abs(velocity.y)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
false
}
}