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

1475 lines
56 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)
private let bottomEdgeGradientView: UIView = {
let v = UIView()
v.isUserInteractionEnabled = false
return v
}()
private let bottomGradientLayer = CAGradientLayer()
// 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()
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
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
// Fix date pill sticky offset for floating header
messageListController.topStickyOffset = headerBarHeight
// Reparent pill overlay: above edge effect (z=40), below toolbar (z=55)
let overlay = messageListController.datePillOverlay
overlay.removeFromSuperview()
overlay.layer.zPosition = 45
overlay.frame = view.bounds
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(overlay)
}
// 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) 8pt from edge
let sideMargin: CGFloat = 8
let backSize = backButton.intrinsicContentSize
backButton.frame = CGRect(
x: sideMargin,
y: centerY - backSize.height * 0.5,
width: backSize.width,
height: backSize.height
)
// Avatar (right) same margin as back button
let avatarOuterSize: CGFloat = 44
let avatarRight: CGFloat = sideMargin
avatarButton.frame = CGRect(
x: view.bounds.width - avatarRight - avatarOuterSize,
y: centerY - avatarOuterSize * 0.5,
width: avatarOuterSize,
height: avatarOuterSize
)
// Title pill (content-sized, centered between back and avatar)
let titleHeight: CGFloat = 44
let hPad: CGFloat = 16 // horizontal padding inside pill
let titlePillRef = titlePill as! ChatDetailTitlePill
let contentWidth = titlePillRef.contentWidth()
let pillWidth = max(120, contentWidth + hPad * 2)
// Available zone between back and avatar
let zoneLeft = backButton.frame.maxX + 8
let zoneRight = avatarButton.frame.minX - 8
let zoneWidth = zoneRight - zoneLeft
// Clamp pill to available zone, center within it
let clampedWidth = min(pillWidth, zoneWidth)
let pillX = zoneLeft + (zoneWidth - clampedWidth) / 2
titlePill.frame = CGRect(
x: pillX,
y: centerY - titleHeight * 0.5,
width: clampedWidth,
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)
// Bottom gradient: simple tint fade (no blur) above composer area
bottomEdgeGradientView.layer.zPosition = 40
bottomGradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
bottomGradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
updateBottomGradientColors()
bottomEdgeGradientView.layer.addSublayer(bottomGradientLayer)
view.addSubview(bottomEdgeGradientView)
}
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)
// Bottom gradient: tint fade below composer, in bottom safe area
let bottomSafe = view.safeAreaInsets.bottom
if bottomSafe > 0 {
bottomEdgeGradientView.isHidden = false
bottomEdgeGradientView.frame = CGRect(
x: 0,
y: view.bounds.height - bottomSafe,
width: view.bounds.width,
height: bottomSafe
)
bottomGradientLayer.frame = bottomEdgeGradientView.bounds
} else {
bottomEdgeGradientView.isHidden = true
}
}
private func updateBottomGradientColors() {
let bg = traitCollection.userInterfaceStyle == .dark
? UIColor.black
: UIColor.white
bottomGradientLayer.colors = [
bg.withAlphaComponent(0).cgColor,
bg.withAlphaComponent(1).cgColor,
]
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
let isDark = traitCollection.userInterfaceStyle == .dark
topEdgeEffectView.setTintColor(isDark ? .black : .white)
updateBottomGradientColors()
}
}
// MARK: - Callback Wiring (NativeMessageListController)
private func wireMessageListCallbacks(_ controller: NativeMessageListController) {
controller.onScrollToBottomVisibilityChange = { [weak self] atBottom in
guard let self else { return }
self.isAtBottom = atBottom
SessionManager.shared.resetIdleTimer()
self.updateReadEligibility()
if atBottom {
self.markDialogAsRead()
}
}
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)
}
// Mark incoming auto-scrolled messages as read (SwiftUI onNewMessageAutoScroll parity)
if isViewActive && !lastIsOutgoing
&& !route.isSavedMessages && !route.isSystemAccount {
updateReadEligibility()
markDialogAsRead()
}
}
}
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)
// Show nav bar BEFORE push prevents jump from hiddenvisible during animation.
// Profile uses .toolbarBackground(.hidden) so it's visually invisible anyway.
navigationController?.setNavigationBarHidden(false, animated: false)
if route.isGroup {
let groupInfo = GroupInfoView(groupDialogKey: route.publicKey)
let hosting = UIHostingController(rootView: groupInfo)
hosting.navigationItem.hidesBackButton = true // prevent system "< Back" flash
navigationController?.pushViewController(hosting, animated: true)
} else if !route.isSystemAccount {
let profile = OpponentProfileView(route: route)
let hosting = UIHostingController(rootView: profile)
hosting.navigationItem.hidesBackButton = true // prevent system "< Back" flash
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)
}
navigationController?.setNavigationBarHidden(false, animated: false)
let profile = OpponentProfileView(route: profileRoute)
let hosting = UIHostingController(rootView: profile)
hosting.navigationItem.hidesBackButton = true
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)
}
@objc private func appDidBecomeActive() {
guard isViewActive else { return }
SessionManager.shared.resetIdleTimer()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
guard let self, self.isViewActive else { return }
self.updateReadEligibility()
self.markDialogAsRead()
}
}
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 typingDotsView = TypingDotsView()
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)
typingDotsView.isUserInteractionEnabled = false
typingDotsView.isHidden = true
addSubview(typingDotsView)
}
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
// Show/hide typing dots animation
let isTypingActive = (viewModel?.isTyping == true)
|| !(viewModel?.typingSenderNames ?? []).isEmpty
if isTypingActive {
typingDotsView.dotColor = subtitleColor
typingDotsView.isHidden = false
typingDotsView.startAnimating()
} else {
typingDotsView.isHidden = true
typingDotsView.stopAnimating()
}
setNeedsLayout()
}
/// Returns the natural content width (max of title/subtitle label widths).
func contentWidth() -> CGFloat {
let titleW = titleLabel.intrinsicContentSize.width
let subtitleW = subtitleLabel.intrinsicContentSize.width
let dotsExtra: CGFloat = typingDotsView.isHidden ? 0 : 24
return max(titleW, subtitleW + dotsExtra)
}
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)
let subtitleY = topY + titleH + spacing
if !typingDotsView.isHidden {
// Dots (24×14) + subtitle text, centered horizontally
let dotsW: CGFloat = 24
let dotsH: CGFloat = 14
let textW = subtitleLabel.intrinsicContentSize.width
let totalW = dotsW + textW
let startX = (bounds.width - totalW) / 2
typingDotsView.frame = CGRect(x: startX, y: subtitleY + (subtitleH - dotsH) / 2 + 1, width: dotsW, height: dotsH)
subtitleLabel.frame = CGRect(x: startX + dotsW, y: subtitleY, width: textW, height: subtitleH)
subtitleLabel.textAlignment = .left
} else {
typingDotsView.frame = .zero
subtitleLabel.frame = CGRect(x: hPad, y: subtitleY, width: contentW, height: subtitleH)
subtitleLabel.textAlignment = .center
}
} 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 avatarBackgroundView = UIView()
private let avatarImageView = UIImageView()
private let initialsLabel = UILabel()
private let route: ChatRoute
init(route: ChatRoute) {
self.route = route
super.init(frame: .zero)
setupUI()
updateAvatar()
NotificationCenter.default.addObserver(
self, selector: #selector(avatarChanged),
name: .init("AvatarRepositoryDidChange"), object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
private func setupUI() {
// All subviews must NOT intercept touches UIControl handles them
glassView.isUserInteractionEnabled = false
addSubview(glassView)
avatarBackgroundView.isUserInteractionEnabled = false
avatarBackgroundView.clipsToBounds = true
addSubview(avatarBackgroundView)
avatarImageView.isUserInteractionEnabled = false
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
addSubview(avatarImageView)
initialsLabel.isUserInteractionEnabled = false
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)
// Mantine "light" variant (ChatListCell parity)
let colorIndex = RosettaColors.avatarColorIndex(for: title, publicKey: route.publicKey)
let colorPair = RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count]
let isDark = traitCollection.userInterfaceStyle == .dark
let mantineDarkBody = UIColor(red: 0x1A / 255, green: 0x1B / 255, blue: 0x1E / 255, alpha: 1)
let baseColor = isDark ? mantineDarkBody : .white
let tintUIColor = UIColor(colorPair.tint)
let tintAlpha: CGFloat = isDark ? 0.15 : 0.10
avatarBackgroundView.backgroundColor = baseColor.chatDetailBlended(with: tintUIColor, alpha: tintAlpha)
// Font: bold rounded, 38 * 0.38 14.4pt
initialsLabel.font = UIFont.systemFont(ofSize: 38 * 0.38, weight: .bold).chatDetailRounded()
initialsLabel.textColor = isDark ? UIColor(colorPair.text) : tintUIColor
}
}
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)
avatarBackgroundView.frame = avatarFrame
avatarBackgroundView.layer.cornerRadius = avatarDiam * 0.5
avatarImageView.frame = avatarFrame
avatarImageView.layer.cornerRadius = avatarDiam * 0.5
initialsLabel.frame = avatarFrame
}
override var isHighlighted: Bool {
didSet { alpha = isHighlighted ? 0.6 : 1.0 }
}
}
// MARK: - UIFont/UIColor Helpers (ChatListCell parity)
private extension UIFont {
func chatDetailRounded() -> UIFont {
guard let descriptor = fontDescriptor.withDesign(.rounded) else { return self }
return UIFont(descriptor: descriptor, size: 0)
}
}
private extension UIColor {
func chatDetailBlended(with color: UIColor, alpha: CGFloat) -> UIColor {
var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
let inv = 1.0 - alpha
return UIColor(
red: r1 * inv + r2 * alpha,
green: g1 * inv + g2 * alpha,
blue: b1 * inv + b2 * alpha,
alpha: a1 * inv + a2 * alpha
)
}
}
// 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
}
}