1291 lines
48 KiB
Swift
1291 lines
48 KiB
Swift
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
|
||
}
|
||
}
|