diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift new file mode 100644 index 0000000..f53de12 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -0,0 +1,1290 @@ +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() + + // 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 = [] + + // 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() + + 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 + } +} diff --git a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift index 72c5342..464db14 100644 --- a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift +++ b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift @@ -82,7 +82,7 @@ final class RequestChatsController: UIViewController { [weak self] cell, indexPath, dialog in guard let self else { return } cell.configure(with: dialog, isSyncing: self.isSyncing) - cell.setSeparatorHidden(indexPath.item == 0) + cell.setSeparatorHidden(false) } } diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift index a09c52a..9ee0971 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift @@ -51,6 +51,8 @@ final class ChatListCollectionController: UIViewController { private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! + /// Overscroll background — fills rubber-band area with pinnedItemBackgroundColor (Telegram parity) + private let overscrollBackgroundView = UIView() private var cellRegistration: UICollectionView.CellRegistration! private var requestsCellRegistration: UICollectionView.CellRegistration! private let floatingTabBarTotalHeight: CGFloat = 72 @@ -94,6 +96,11 @@ final class ChatListCollectionController: UIViewController { applyInsets() view.addSubview(collectionView) + // Overscroll background — behind cells, shows pinnedItemBackgroundColor on pull-down bounce + overscrollBackgroundView.isUserInteractionEnabled = false + overscrollBackgroundView.isHidden = true + collectionView.insertSubview(overscrollBackgroundView, at: 0) + NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: view.topAnchor), collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -155,6 +162,34 @@ final class ChatListCollectionController: UIViewController { reportPinnedHeaderFraction() } + private func updateOverscrollBackground() { + let hasPin = !pinnedDialogs.isEmpty + overscrollBackgroundView.isHidden = !hasPin + guard hasPin, collectionView != nil else { return } + + let isDark = traitCollection.userInterfaceStyle == .dark + overscrollBackgroundView.backgroundColor = isDark + ? UIColor(red: 0x1C / 255, green: 0x1C / 255, blue: 0x1D / 255, alpha: 1) + : UIColor(red: 0xF7 / 255, green: 0xF7 / 255, blue: 0xF7 / 255, alpha: 1) + + let offset = collectionView.contentOffset.y + let insetTop = collectionView.contentInset.top + let overscrollAmount = -(offset + insetTop) + let width = collectionView.bounds.width + + if overscrollAmount > 0 { + overscrollBackgroundView.frame = CGRect( + x: 0, y: offset, + width: width, height: insetTop + overscrollAmount + ) + } else { + overscrollBackgroundView.frame = CGRect( + x: 0, y: -insetTop, + width: width, height: insetTop + ) + } + } + private func schedulePinnedHeaderFractionReport(force: Bool = false) { if isPinnedFractionReportScheduled { return } isPinnedFractionReportScheduled = true @@ -171,23 +206,33 @@ final class ChatListCollectionController: UIViewController { collectionView.window != nil, !pinnedDialogs.isEmpty else { return 0.0 } - var maxPinnedOffset: CGFloat = 0.0 + // Telegram: itemNode.frame is in VISUAL space (changes with scroll). + // UICollectionView cell.frame is in CONTENT space (static). + // Convert: visibleMaxY = cell.frame.maxY - contentOffset.y + let offset = collectionView.contentOffset.y + var maxPinnedVisibleOffset: CGFloat = 0.0 + var foundAny = false + for cell in collectionView.visibleCells { guard let indexPath = collectionView.indexPath(for: cell), sectionForIndexPath(indexPath) == .pinned else { continue } - maxPinnedOffset = max(maxPinnedOffset, cell.frame.maxY) + let visibleMaxY = cell.frame.maxY - offset + maxPinnedVisibleOffset = max(maxPinnedVisibleOffset, visibleMaxY) + foundAny = true } + if !foundAny { return 0.0 } + let viewportInsetTop = collectionView.contentInset.top guard viewportInsetTop > 0 else { return 0.0 } - if maxPinnedOffset >= viewportInsetTop { + if maxPinnedVisibleOffset >= viewportInsetTop { return 1.0 } - return max(0.0, min(1.0, maxPinnedOffset / viewportInsetTop)) + return max(0.0, min(1.0, maxPinnedVisibleOffset / viewportInsetTop)) } private func reportPinnedHeaderFraction(force: Bool = false) { @@ -216,14 +261,18 @@ final class ChatListCollectionController: UIViewController { let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment) section.interGroupSpacing = 0 - // Add pinned section background decoration + // Add pinned section background decoration to pinned AND requests sections + // (requests sit above pinned, so they share the same gray background) if let self, sectionIndex < self.dataSource?.snapshot().sectionIdentifiers.count ?? 0, - self.dataSource?.snapshot().sectionIdentifiers[sectionIndex] == .pinned { - let bgItem = NSCollectionLayoutDecorationItem.background( - elementKind: PinnedSectionBackgroundView.elementKind - ) - section.decorationItems = [bgItem] + !self.pinnedDialogs.isEmpty { + let sectionId = self.dataSource?.snapshot().sectionIdentifiers[sectionIndex] + if sectionId == .pinned || sectionId == .requests { + let bgItem = NSCollectionLayoutDecorationItem.background( + elementKind: PinnedSectionBackgroundView.elementKind + ) + section.decorationItems = [bgItem] + } } return section @@ -262,9 +311,8 @@ final class ChatListCollectionController: UIViewController { requestsCellRegistration = UICollectionView.CellRegistration { [weak self] cell, _, count in - let hasPinned = !(self?.pinnedDialogs.isEmpty ?? true) - // Requests row separator should be hidden when pinned section exists. - cell.configure(count: count, showBottomSeparator: !hasPinned) + // Always show separator under requests row + cell.configure(count: count, showBottomSeparator: true) } } @@ -353,6 +401,7 @@ final class ChatListCollectionController: UIViewController { // Notify host immediately so top chrome reacts in the same frame. onPinnedStateChange?(!pinned.isEmpty) + updateOverscrollBackground() } /// Directly reconfigure only visible cells — no snapshot rebuild, no animation. @@ -366,7 +415,7 @@ final class ChatListCollectionController: UIViewController { let typingUsers = typingDialogs[dialog.opponentKey] chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers) } else if let reqCell = cell as? ChatListRequestsCell { - reqCell.configure(count: requestsCount, showBottomSeparator: pinnedDialogs.isEmpty) + reqCell.configure(count: requestsCount, showBottomSeparator: true) } } } @@ -474,6 +523,7 @@ extension ChatListCollectionController: UICollectionViewDelegate { onScrollOffsetChange?(expansion) } reportPinnedHeaderFraction() + updateOverscrollBackground() } func scrollViewWillEndDragging( diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift index 41efc0b..0773192 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift @@ -66,10 +66,8 @@ private final class ChatListUIKitCoordinator { } func setDetailPresented(_ presented: Bool) { - DispatchQueue.main.async { - if self.isDetailPresented.wrappedValue != presented { - self.isDetailPresented.wrappedValue = presented - } + if self.isDetailPresented.wrappedValue != presented { + self.isDetailPresented.wrappedValue = presented } } } @@ -197,9 +195,10 @@ final class ChatListRootViewController: UIViewController, UINavigationController } private func updateNavigationBlurHeight() { - let headerBottom = headerTotalHeight - let expandedSearchHeight = searchChromeHeight * lastSearchExpansion - navigationBlurHeightConstraint?.constant = max(0, headerBottom + expandedSearchHeight + 14.0) + // Telegram: edgeEffectHeight = componentHeight + 14 - searchBarExpansion + // Result: edge effect covers toolbar area + 14pt ONLY (not search bar). + // Search bar has its own background, doesn't need blur coverage. + navigationBlurHeightConstraint?.constant = max(0, headerTotalHeight + 14.0) } override func viewWillDisappear(_ animated: Bool) { @@ -604,16 +603,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController } private func openChat(route: ChatRoute) { - let detail = ChatDetailView( - route: route, - onPresentedChange: { [weak self] presented in - self?.onDetailPresentedChanged?(presented) - } - ) - - let hosting = UIHostingController(rootView: detail.id(route.publicKey)) - hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) - navigationController?.pushViewController(hosting, animated: true) + onDetailPresentedChanged?(true) // hide tab bar BEFORE push animation + let detail = ChatDetailViewController(route: route) + navigationController?.pushViewController(detail, animated: true) } private func openRequests() { @@ -705,9 +697,13 @@ final class ChatListRootViewController: UIViewController, UINavigationController willShow viewController: UIViewController, animated: Bool ) { - // Show standard nav bar for pushed screens, hide on chat list - let isChatList = viewController === self - navigationController.setNavigationBarHidden(isChatList, animated: animated) + let hideNavBar = viewController === self + || viewController is ChatDetailViewController + || viewController is RequestChatsUIKitShellController + navigationController.setNavigationBarHidden(hideNavBar, animated: animated) + + let isPresented = navigationController.viewControllers.count > 1 + onDetailPresentedChanged?(isPresented) } func navigationController( @@ -729,6 +725,14 @@ final class RequestChatsUIKitShellController: UIViewController { private let requestsController = RequestChatsController() private var observationTask: Task? + // Custom header elements (direct subviews — glass needs this) + private let headerBarHeight: CGFloat = 44 + private let backButton = UIControl() + private let backGlassBackground = ChatListToolbarGlassCapsuleView() + private let chevronLayer = CAShapeLayer() + private let titleLabel = UILabel() + private var addedFullWidthGesture = false + init(viewModel: ChatListViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -741,13 +745,14 @@ final class RequestChatsUIKitShellController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(RosettaColors.Adaptive.background) - title = "Request Chats" + + setupCustomHeader() addChild(requestsController) view.addSubview(requestsController.view) requestsController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - requestsController.view.topAnchor.constraint(equalTo: view.topAnchor), + requestsController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: headerBarHeight), requestsController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), requestsController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), requestsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -765,10 +770,117 @@ final class RequestChatsUIKitShellController: UIViewController { render() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + setupFullWidthSwipeBack() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutCustomHeader() + } + deinit { observationTask?.cancel() } + // MARK: - Custom Header + + private func setupCustomHeader() { + // Back button — glass capsule + SVG chevron (TelegramIconPath.backChevron) + backGlassBackground.translatesAutoresizingMaskIntoConstraints = false + backButton.addSubview(backGlassBackground) + NSLayoutConstraint.activate([ + backGlassBackground.topAnchor.constraint(equalTo: backButton.topAnchor), + backGlassBackground.leadingAnchor.constraint(equalTo: backButton.leadingAnchor), + backGlassBackground.trailingAnchor.constraint(equalTo: backButton.trailingAnchor), + backGlassBackground.bottomAnchor.constraint(equalTo: backButton.bottomAnchor), + ]) + + // Chevron from SVG path (viewBox 11×20, same as ChatDetailView) + let viewBox = CGSize(width: 11, height: 20) + let iconSize = CGSize(width: 11, height: 20) + var parser = SVGPathParser(pathData: TelegramIconPath.backChevron) + let rawPath = parser.parse() + let buttonSize: CGFloat = 44 + var transform = CGAffineTransform( + scaleX: iconSize.width / viewBox.width, + y: iconSize.height / viewBox.height + ).translatedBy( + x: (buttonSize - iconSize.width) / 2 * (viewBox.width / iconSize.width), + y: (buttonSize - iconSize.height) / 2 * (viewBox.height / iconSize.height) + ) + chevronLayer.path = rawPath.copy(using: &transform) + chevronLayer.fillColor = UIColor(RosettaColors.Adaptive.text).cgColor + chevronLayer.frame = CGRect(x: 0, y: 0, width: buttonSize, height: buttonSize) + backButton.layer.addSublayer(chevronLayer) + + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.layer.zPosition = 55 + view.addSubview(backButton) + + // Title + titleLabel.text = "Request Chats" + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = UIColor(RosettaColors.Adaptive.text) + titleLabel.textAlignment = .center + titleLabel.layer.zPosition = 55 + view.addSubview(titleLabel) + } + + private func layoutCustomHeader() { + let statusBarHeight = view.safeAreaInsets.top + let centerY = statusBarHeight + headerBarHeight * 0.5 + let sideInset: CGFloat = 16 + let buttonSize: CGFloat = 44 + + backButton.frame = CGRect( + x: sideInset, + y: centerY - buttonSize * 0.5, + width: buttonSize, + height: buttonSize + ) + + let titleSize = titleLabel.intrinsicContentSize + titleLabel.frame = CGRect( + x: (view.bounds.width - titleSize.width) * 0.5, + y: centerY - titleSize.height * 0.5, + width: titleSize.width, + height: titleSize.height + ) + } + + @objc private func backTapped() { + navigationController?.popViewController(animated: true) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + chevronLayer.fillColor = UIColor(RosettaColors.Adaptive.text).cgColor + } + + // MARK: - Full-Width Swipe Back + + private func setupFullWidthSwipeBack() { + guard !addedFullWidthGesture else { return } + addedFullWidthGesture = 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: - Observation + private func startObservationLoop() { observationTask?.cancel() observationTask = Task { @MainActor [weak self] in @@ -797,6 +909,23 @@ final class RequestChatsUIKitShellController: UIViewController { } } +// MARK: - UIGestureRecognizerDelegate (full-width swipe back) + +extension RequestChatsUIKitShellController: 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 + } +} + private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { var onQueryChanged: ((String) -> Void)? @@ -1039,12 +1168,12 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { private final class ChatListHeaderBlurView: UIView { - // Tint overlay — shows pinned section background color via gradient mask + // Telegram EdgeEffectView port: + // 1. CABackdropLayer — variable blur of content behind (radius 1.0) + // 2. Tint overlay — color changes with pinnedFraction, alpha 0.85, gradient-masked private let tintView = UIView() private let tintMaskView = UIImageView() - // CABackdropLayer — captures content behind and applies subtle blur private var backdropLayer: CALayer? - private let fadeMaskLayer = CAGradientLayer() private var plainBackgroundColor: UIColor = .black private var pinnedBackgroundColor: UIColor = .black private var currentProgress: CGFloat = 0.0 @@ -1055,7 +1184,7 @@ private final class ChatListHeaderBlurView: UIView { super.init(frame: frame) isUserInteractionEnabled = false - // Backdrop blur layer — very subtle (radius 1.0), no colorMatrix + // Backdrop blur layer (Telegram: VariableBlurView maxRadius 1.0) if let backdrop = BackdropLayerHelper.createBackdropLayer() { backdrop.delegate = BackdropLayerDelegate.shared BackdropLayerHelper.setScale(backdrop, scale: 0.5) @@ -1067,14 +1196,11 @@ private final class ChatListHeaderBlurView: UIView { self.backdropLayer = backdrop } - // Tint view with gradient mask (for pinned section color) + // Tint overlay with gradient mask (Telegram: contentView at alpha 0.85) tintView.mask = tintMaskView tintView.alpha = 0.85 addSubview(tintView) - // Gradient fade mask on the whole view - layer.mask = fadeMaskLayer - applyAdaptiveColors() } @@ -1093,7 +1219,6 @@ private final class ChatListHeaderBlurView: UIView { backdropLayer?.frame = bounds tintView.frame = bounds tintMaskView.frame = bounds - updateFadeMask() updateTintMask() } @@ -1104,6 +1229,7 @@ private final class ChatListHeaderBlurView: UIView { updateChromeOpacity() } + // Telegram: plainBackgroundColor.mixedWith(pinnedItemBackgroundColor, alpha: pinnedFraction) private func updateEdgeEffectColor() { let effectivePinnedFraction = isSearchCurrentlyActive ? 0.0 : currentPinnedFraction let resolved = plainBackgroundColor.mixedWith(pinnedBackgroundColor, alpha: effectivePinnedFraction) @@ -1112,8 +1238,7 @@ private final class ChatListHeaderBlurView: UIView { private func updateChromeOpacity() { let clamped = max(0.0, min(1.0, currentProgress)) - // Backdrop blur is always present — its visibility depends on content behind. - // Tint overlay fades in with scroll progress. + // Telegram: content alpha is always 0.85; we modulate by scroll progress tintView.alpha = 0.85 * clamped } @@ -1123,17 +1248,6 @@ private final class ChatListHeaderBlurView: UIView { tintMaskView.image = VariableBlurEdgeView.generateEdgeGradient(baseHeight: edgeSize) } - private func updateFadeMask() { - let height = max(1, bounds.height) - let fadeHeight = min(54.0, height) - let fadeStart = max(0.0, (height - fadeHeight) / height) - fadeMaskLayer.frame = bounds - fadeMaskLayer.startPoint = CGPoint(x: 0.5, y: 0) - fadeMaskLayer.endPoint = CGPoint(x: 0.5, y: 1) - fadeMaskLayer.colors = [UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor] - fadeMaskLayer.locations = [0, NSNumber(value: Float(fadeStart)), 1] - } - func setProgress(_ progress: CGFloat, pinnedFraction: CGFloat, isSearchActive: Bool) { currentProgress = max(0.0, min(1.0, progress)) currentPinnedFraction = max(0.0, min(1.0, pinnedFraction))