diff --git a/Rosetta/Assets.xcassets/SelectionForward.imageset/Contents.json b/Rosetta/Assets.xcassets/SelectionForward.imageset/Contents.json new file mode 100644 index 0000000..dfd9101 --- /dev/null +++ b/Rosetta/Assets.xcassets/SelectionForward.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_forward.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Rosetta/Assets.xcassets/SelectionForward.imageset/ic_forward.pdf b/Rosetta/Assets.xcassets/SelectionForward.imageset/ic_forward.pdf new file mode 100644 index 0000000..b602a9c Binary files /dev/null and b/Rosetta/Assets.xcassets/SelectionForward.imageset/ic_forward.pdf differ diff --git a/Rosetta/Assets.xcassets/SelectionShare.imageset/Contents.json b/Rosetta/Assets.xcassets/SelectionShare.imageset/Contents.json new file mode 100644 index 0000000..b1c306c --- /dev/null +++ b/Rosetta/Assets.xcassets/SelectionShare.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_share.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Rosetta/Assets.xcassets/SelectionShare.imageset/ic_share.pdf b/Rosetta/Assets.xcassets/SelectionShare.imageset/ic_share.pdf new file mode 100644 index 0000000..c372a21 Binary files /dev/null and b/Rosetta/Assets.xcassets/SelectionShare.imageset/ic_share.pdf differ diff --git a/Rosetta/Assets.xcassets/SelectionTrash.imageset/Contents.json b/Rosetta/Assets.xcassets/SelectionTrash.imageset/Contents.json new file mode 100644 index 0000000..8b366b5 --- /dev/null +++ b/Rosetta/Assets.xcassets/SelectionTrash.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_delete.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Rosetta/Assets.xcassets/SelectionTrash.imageset/ic_delete.pdf b/Rosetta/Assets.xcassets/SelectionTrash.imageset/ic_delete.pdf new file mode 100644 index 0000000..ece3e64 Binary files /dev/null and b/Rosetta/Assets.xcassets/SelectionTrash.imageset/ic_delete.pdf differ diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index 1160666..cf6b10a 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -34,10 +34,17 @@ final class ChatDetailViewController: UIViewController { private var scrollToBottomTrigger: UInt = 0 private var pendingAttachments: [PendingAttachment] = [] private var forwardingMessage: ChatMessage? + private var forwardingMessages: [ChatMessage] = [] private var messageToDelete: ChatMessage? private var isMultiSelectMode = false private var selectedMessageIds: Set = [] + // MARK: - Selection Toolbar + + private var selectionToolbar: SelectionToolbarView? + private var selectionHeaderOverlay: UIView? + private var selectionHeaderLabel: UILabel? + // MARK: - Cached private let currentPublicKey = SessionManager.shared.currentPublicKey @@ -579,9 +586,16 @@ final class ChatDetailViewController: UIViewController { } } cellActions.onEnterSelection = { [weak self] msg in - self?.isMultiSelectMode = true - self?.selectedMessageIds = [msg.id] - self?.messageListController?.setSelectionMode(true, animated: true) + guard let self else { return } + self.isMultiSelectMode = true + self.selectedMessageIds = [msg.id] + self.messageListController?.setSelectionMode(true, animated: true) + // Sync selected IDs AFTER setSelectionMode so the first message gets checkmarked + self.messageListController?.updateSelectedIds(self.selectedMessageIds) + self.showSelectionToolbar(animated: true) + self.showSelectionHeader(animated: true) + self.selectionToolbar?.updateState(selectedCount: 1, canDelete: true) + self.updateSelectionHeaderLabel() } cellActions.onToggleSelection = { [weak self] msgId in guard let self else { return } @@ -591,6 +605,8 @@ final class ChatDetailViewController: UIViewController { self.selectedMessageIds.insert(msgId) } self.messageListController?.updateSelectedIds(self.selectedMessageIds) + self.selectionToolbar?.updateState(selectedCount: self.selectedMessageIds.count, canDelete: true) + self.updateSelectionHeaderLabel() } cellActions.onMentionTap = { [weak self] username in self?.handleMentionTap(username: username) @@ -789,13 +805,25 @@ final class ChatDetailViewController: UIViewController { } private func showForwardPicker() { + // Support both single message (context menu) and multi-select forward + let messagesToForward: [ChatMessage] + if !forwardingMessages.isEmpty { + messagesToForward = forwardingMessages + forwardingMessages = [] + } else if let single = forwardingMessage { + messagesToForward = [single] + forwardingMessage = nil + } else { + return + } + 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) + for message in messagesToForward { + self.forwardMessage(message, to: route) + } } } let hosting = UIHostingController(rootView: picker) @@ -817,9 +845,7 @@ final class ChatDetailViewController: UIViewController { self.removeMessage(message) self.messageToDelete = nil if self.isMultiSelectMode { - self.isMultiSelectMode = false - self.selectedMessageIds.removeAll() - self.messageListController?.setSelectionMode(false, animated: true) + self.exitSelectionMode(animated: true) } }) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in @@ -857,6 +883,175 @@ final class ChatDetailViewController: UIViewController { present(alert, animated: true) } + // MARK: - Selection Toolbar & Header + + private func showSelectionToolbar(animated: Bool) { + guard selectionToolbar == nil else { return } + let toolbar = SelectionToolbarView() + let safeBottom = view.safeAreaInsets.bottom + let toolbarHeight: CGFloat = 40 + safeBottom + let finalY = view.bounds.height - toolbarHeight + + toolbar.frame = CGRect( + x: 0, + y: animated ? view.bounds.height : finalY, + width: view.bounds.width, + height: toolbarHeight + ) + toolbar.layer.zPosition = 50 + view.addSubview(toolbar) + selectionToolbar = toolbar + + // Wire callbacks + toolbar.onDelete = { [weak self] in self?.deleteSelectedMessages() } + toolbar.onForward = { [weak self] in self?.forwardSelectedMessages() } + + if animated { + UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0, options: []) { + toolbar.frame.origin.y = finalY + } + } + } + + private func hideSelectionToolbar(animated: Bool) { + guard let toolbar = selectionToolbar else { return } + if animated { + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) { + toolbar.frame.origin.y = self.view.bounds.height + toolbar.alpha = 0 + } completion: { _ in + toolbar.removeFromSuperview() + } + } else { + toolbar.removeFromSuperview() + } + selectionToolbar = nil + } + + private func showSelectionHeader(animated: Bool) { + guard selectionHeaderOverlay == nil else { return } + let safeTop = view.safeAreaInsets.top + + let overlay = UIView() + overlay.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: safeTop + headerBarHeight) + overlay.layer.zPosition = 60 + + // Simple dark background (Telegram style — no glass, just solid dark) + overlay.backgroundColor = UIColor { $0.userInterfaceStyle == .dark + ? UIColor(white: 0.11, alpha: 0.95) // Dark translucent + : UIColor(white: 0.97, alpha: 0.95) // Light translucent + } + + // Cancel button (left, blue text) + let cancelBtn = UIButton(type: .system) + cancelBtn.setTitle("Cancel", for: .normal) + cancelBtn.titleLabel?.font = .systemFont(ofSize: 17) + cancelBtn.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6 + cancelBtn.sizeToFit() + let centerY = safeTop + headerBarHeight * 0.5 + cancelBtn.frame = CGRect( + x: 16, + y: centerY - cancelBtn.bounds.height * 0.5, + width: cancelBtn.bounds.width, + height: cancelBtn.bounds.height + ) + cancelBtn.addTarget(self, action: #selector(selectionCancelTapped), for: .touchUpInside) + overlay.addSubview(cancelBtn) + + // "N Selected" label (centered) + let label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black } + label.textAlignment = .center + overlay.addSubview(label) + selectionHeaderLabel = label + label.frame = CGRect(x: 0, y: safeTop, width: view.bounds.width, height: headerBarHeight) + + overlay.alpha = animated ? 0 : 1 + view.addSubview(overlay) + selectionHeaderOverlay = overlay + + if animated { + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) { + overlay.alpha = 1 + } + } + } + + private func hideSelectionHeader(animated: Bool) { + guard let overlay = selectionHeaderOverlay else { return } + if animated { + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) { + overlay.alpha = 0 + } completion: { _ in + overlay.removeFromSuperview() + } + } else { + overlay.removeFromSuperview() + } + selectionHeaderOverlay = nil + selectionHeaderLabel = nil + } + + private func updateSelectionHeaderLabel() { + let count = selectedMessageIds.count + selectionHeaderLabel?.text = "\(count) Selected" + } + + private func exitSelectionMode(animated: Bool) { + isMultiSelectMode = false + selectedMessageIds.removeAll() + messageListController?.setSelectionMode(false, animated: animated) + hideSelectionToolbar(animated: animated) + hideSelectionHeader(animated: animated) + } + + @objc private func selectionCancelTapped() { + exitSelectionMode(animated: true) + } + + private func forwardSelectedMessages() { + let messages = viewModel.messages + .filter { selectedMessageIds.contains($0.id) } + .sorted { $0.timestamp < $1.timestamp } + guard !messages.isEmpty else { return } + forwardingMessages = messages + exitSelectionMode(animated: true) + showForwardPicker() + } + + private func deleteSelectedMessages() { + let count = selectedMessageIds.count + let alert = UIAlertController( + title: "Delete \(count) Message\(count == 1 ? "" : "s")", + message: "This action cannot be undone.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in + guard let self else { return } + let messages = self.viewModel.messages.filter { self.selectedMessageIds.contains($0.id) } + for msg in messages { + self.removeMessage(msg) + } + self.exitSelectionMode(animated: true) + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + present(alert, animated: true) + } + + private func shareSelectedMessages() { + let messages = viewModel.messages + .filter { selectedMessageIds.contains($0.id) } + .sorted { $0.timestamp < $1.timestamp } + let texts = messages.map { $0.text }.filter { !$0.isEmpty } + guard !texts.isEmpty else { return } + let activity = UIActivityViewController( + activityItems: [texts.joined(separator: "\n\n")], + applicationActivities: nil + ) + present(activity, animated: true) + } + // MARK: - Scroll private func scrollToMessage(id: String) { diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index 5efb31d..7d579ba 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -1391,6 +1391,12 @@ extension ComposerView: RecordingMicButtonDelegate { } private func finalizeVoiceSession(cleanup: VoiceSessionCleanupMode, dismissStyle: VoiceSessionDismissStyle) { + // Remove preview panel immediately BEFORE resetting state to avoid + // race condition where input is restored while panel is still animating. + recordingPreviewPanel?.stopPlayback() + recordingPreviewPanel?.removeFromSuperview() + recordingPreviewPanel = nil + resetVoiceSessionState(cleanup: cleanup) switch dismissStyle { @@ -1410,10 +1416,6 @@ extension ComposerView: RecordingMicButtonDelegate { recordingLockView?.dismiss() recordingLockView = nil - recordingPreviewPanel?.animateOut { [weak self] in - self?.recordingPreviewPanel = nil - } - restoreComposerChrome() // For cancel: play bin animation inside attach button, then restore icon @@ -1512,15 +1514,16 @@ extension ComposerView: RecordingMicButtonDelegate { private func resumeRecordingFromPreview() { let trimRange = recordingPreviewPanel?.selectedTrimRange + // Remove preview panel immediately before layout changes + recordingPreviewPanel?.stopPlayback() + recordingPreviewPanel?.removeFromSuperview() + recordingPreviewPanel = nil setPreviewRowReplacement(false) micButton.resetState() guard audioRecorder.resumeRecording(trimRange: trimRange) else { dismissOverlayAndRestore() return } - recordingPreviewPanel?.animateOut { [weak self] in - self?.recordingPreviewPanel = nil - } isRecording = true isRecordingLocked = true setRecordingFlowState(.recordingLocked) diff --git a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift index d997340..7255cbb 100644 --- a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift @@ -12,6 +12,10 @@ struct OpponentProfileView: View { @State private var topInset: CGFloat = 0 @State private var isMuted = false @State private var showMoreSheet = false + /// When true, shows "Message" button in action bar (group member profile context). + var showMessageButton = false + /// Navigation controller for pushing chat from profile. + @State private var navController: UINavigationController? @State private var selectedTab: PeerProfileTab = .media @Namespace private var tabNamespace @@ -95,8 +99,15 @@ struct OpponentProfileView: View { viewModel.loadSharedContent() viewModel.loadCommonGroups() } + .background { + NavigationControllerAccessor { nav in + self.navController = nav + } + } .confirmationDialog("", isPresented: $showMoreSheet, titleVisibility: .hidden) { - Button("Block User", role: .destructive) {} + if !route.isSavedMessages { + Button("Block User", role: .destructive) {} + } Button("Clear Chat History", role: .destructive) {} } } @@ -127,10 +138,14 @@ struct OpponentProfileView: View { avatarInitials: RosettaColors.initials(name: displayName, publicKey: route.publicKey), avatarColorIndex: RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey), isMuted: isMuted, + showCallButton: !route.isSavedMessages, + showMuteButton: !route.isSavedMessages, + showMessageButton: route.isSavedMessages || showMessageButton, onCall: handleCall, onMuteToggle: handleMuteToggle, onSearch: { dismiss() }, - onMore: { showMoreSheet = true } + onMore: { showMoreSheet = true }, + onMessage: handleMessage ) } } @@ -407,6 +422,19 @@ struct OpponentProfileView: View { DialogRepository.shared.toggleMute(opponentKey: route.publicKey) isMuted.toggle() } + + private func handleMessage() { + if route.isSavedMessages { + // Pop back to Saved Messages chat + dismiss() + } else { + // Push chat with this user + let chatView = ChatDetailView(route: route) + let vc = UIHostingController(rootView: chatView) + vc.navigationItem.hidesBackButton = true + navController?.pushViewController(vc, animated: true) + } + } } // MARK: - Media Tile diff --git a/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift b/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift index 15a5ff0..74ba77a 100644 --- a/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift +++ b/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift @@ -14,10 +14,13 @@ struct PeerProfileHeaderView: View { let avatarColorIndex: Int let isMuted: Bool var showCallButton: Bool = true + var showMuteButton: Bool = true + var showMessageButton: Bool = false let onCall: () -> Void let onMuteToggle: () -> Void let onSearch: () -> Void let onMore: () -> Void + var onMessage: (() -> Void)? @Environment(\.colorScheme) private var colorScheme @@ -166,14 +169,19 @@ struct PeerProfileHeaderView: View { private var actionButtons: some View { HStack(spacing: 6) { + if showMessageButton, let onMessage { + profileActionButton(icon: "bubble.left.fill", title: "Message", action: onMessage) + } if showCallButton { profileActionButton(icon: "phone.fill", title: "Call", action: onCall) } - profileActionButton( - icon: isMuted ? "bell.slash.fill" : "bell.fill", - title: isMuted ? "Unmute" : "Mute", - action: onMuteToggle - ) + if showMuteButton { + profileActionButton( + icon: isMuted ? "bell.slash.fill" : "bell.fill", + title: isMuted ? "Unmute" : "Mute", + action: onMuteToggle + ) + } profileActionButton(icon: "magnifyingglass", title: "Search", action: onSearch) profileActionButton(icon: "ellipsis", title: "More", action: onMore) } diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift b/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift index 854f69b..cb7b264 100644 --- a/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift +++ b/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift @@ -381,7 +381,7 @@ final class RecordingPreviewPanel: UIView, UIGestureRecognizerDelegate { stopDisplayLink() } - private func stopPlayback(resetToTrimStart: Bool = true) { + func stopPlayback(resetToTrimStart: Bool = true) { audioPlayer?.stop() if resetToTrimStart { audioPlayer?.currentTime = trimStart diff --git a/Rosetta/Features/Chats/ChatDetail/SelectionToolbarView.swift b/Rosetta/Features/Chats/ChatDetail/SelectionToolbarView.swift new file mode 100644 index 0000000..7a90b3f --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/SelectionToolbarView.swift @@ -0,0 +1,177 @@ +import UIKit + +/// Telegram-parity selection toolbar: Delete (glass circle) + "N Selected" (glass capsule) + Forward (blue circle). +/// Matches Telegram's bottom toolbar during multi-select mode. +final class SelectionToolbarView: UIView { + + // MARK: - Callbacks + + var onDelete: (() -> Void)? + var onForward: (() -> Void)? + + // MARK: - Constants + + private let buttonSize: CGFloat = 42 + private let pillHeight: CGFloat = 42 + private let horizontalPadding: CGFloat = 16 + + // MARK: - UI + + // Delete button (left — glass circle + trash icon) + private let deleteGlass = TelegramGlassUIView(frame: .zero) + private let deleteButton = UIButton(type: .system) + + // Center pill — "N Selected" label inside glass capsule + private let pillGlass = TelegramGlassUIView(frame: .zero) + private let pillLabel: UILabel = { + let l = UILabel() + l.font = .systemFont(ofSize: 15, weight: .medium) + l.textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black } + l.textAlignment = .center + return l + }() + + // Forward/done button (right — blue circle + white checkmark) + private let forwardCircle = UIView() + private let forwardButton = UIButton(type: .system) + private let checkmarkLayer = CAShapeLayer() + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + + private func setupUI() { + let iconColor = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(white: 0.0, alpha: 0.85) } + + // Delete — glass circle + deleteGlass.isCircle = true + deleteGlass.isUserInteractionEnabled = false + addSubview(deleteGlass) + + deleteButton.setImage(UIImage(named: "SelectionTrash")?.withRenderingMode(.alwaysTemplate), for: .normal) + deleteButton.tintColor = iconColor + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(buttonDown(_:)), for: .touchDown) + deleteButton.addTarget(self, action: #selector(buttonUp(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel]) + addSubview(deleteButton) + + // Center pill — glass capsule with label + pillGlass.isUserInteractionEnabled = false + addSubview(pillGlass) + addSubview(pillLabel) + + // Forward — blue circle with white checkmark + forwardCircle.backgroundColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6 + forwardCircle.layer.cornerRadius = buttonSize / 2 + forwardCircle.isUserInteractionEnabled = false + addSubview(forwardCircle) + + // Draw checkmark (Telegram CheckNode path) + let checkSize: CGFloat = buttonSize + let scale = (checkSize - 4) / 18.0 + let cx = checkSize / 2 + let cy = checkSize / 2 + let startX = cx - 4.333 * scale + let startY = cy + 0.5 * scale + let path = UIBezierPath() + path.move(to: CGPoint(x: startX, y: startY)) + path.addLine(to: CGPoint(x: startX + 2.5 * scale, y: startY + 3.0 * scale)) + path.addLine(to: CGPoint(x: startX + 2.5 * scale + 4.667 * scale, y: startY + 3.0 * scale - 6.0 * scale)) + + checkmarkLayer.path = path.cgPath + checkmarkLayer.strokeColor = UIColor.white.cgColor + checkmarkLayer.fillColor = UIColor.clear.cgColor + checkmarkLayer.lineWidth = 2.0 + checkmarkLayer.lineCap = .round + checkmarkLayer.lineJoin = .round + checkmarkLayer.frame = CGRect(x: 0, y: 0, width: checkSize, height: checkSize) + forwardCircle.layer.addSublayer(checkmarkLayer) + + forwardButton.addTarget(self, action: #selector(forwardTapped), for: .touchUpInside) + forwardButton.addTarget(self, action: #selector(buttonDown(_:)), for: .touchDown) + forwardButton.addTarget(self, action: #selector(buttonUp(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel]) + addSubview(forwardButton) + } + + // MARK: - State + + func updateState(selectedCount: Int, canDelete: Bool) { + let hasSelection = selectedCount > 0 + + // Update pill label + pillLabel.text = "\(selectedCount) Selected" + + // Delete enabled state + let deleteEnabled = hasSelection && canDelete + deleteButton.isEnabled = deleteEnabled + deleteGlass.alpha = deleteEnabled ? 1.0 : 0.5 + deleteButton.alpha = deleteEnabled ? 1.0 : 0.5 + + // Forward enabled state + forwardButton.isEnabled = hasSelection + forwardCircle.alpha = hasSelection ? 1.0 : 0.5 + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + let w = bounds.width + + // Same padding as composer (16pt each side) + let left = horizontalPadding + let right = horizontalPadding + + // Delete button (left) + let deleteFrame = CGRect(x: left, y: 0, width: buttonSize, height: buttonSize) + deleteGlass.frame = deleteFrame + deleteButton.frame = deleteFrame + + // Forward button (right) + let forwardFrame = CGRect(x: w - right - buttonSize, y: 0, width: buttonSize, height: buttonSize) + forwardCircle.frame = forwardFrame + forwardButton.frame = forwardFrame + + // Center pill (fills space between delete and forward with some spacing) + let pillSpacing: CGFloat = 10 + let pillX = deleteFrame.maxX + pillSpacing + let pillRight = forwardFrame.minX - pillSpacing + let pillWidth = pillRight - pillX + let pillFrame = CGRect(x: pillX, y: (buttonSize - pillHeight) / 2, width: pillWidth, height: pillHeight) + pillGlass.frame = pillFrame + pillLabel.frame = pillFrame + } + + // MARK: - Actions + + @objc private func deleteTapped() { onDelete?() } + @objc private func forwardTapped() { onForward?() } + + // MARK: - Highlight + + @objc private func buttonDown(_ sender: UIButton) { + guard sender.isEnabled else { return } + let target: UIView? = sender == deleteButton ? deleteGlass : forwardCircle + UIView.animate(withDuration: 0.05) { + sender.alpha = 0.6 + target?.alpha = 0.6 + } + } + + @objc private func buttonUp(_ sender: UIButton) { + guard sender.isEnabled else { return } + let target: UIView? = sender == deleteButton ? deleteGlass : forwardCircle + UIView.animate(withDuration: 0.2) { + sender.alpha = 1.0 + target?.alpha = 1.0 + } + } +} diff --git a/Rosetta/Features/Groups/GroupInfoView.swift b/Rosetta/Features/Groups/GroupInfoView.swift index e7783cf..9e64ffb 100644 --- a/Rosetta/Features/Groups/GroupInfoView.swift +++ b/Rosetta/Features/Groups/GroupInfoView.swift @@ -91,7 +91,8 @@ struct GroupInfoView: View { .onChange(of: showMemberChat) { show in guard show, let route = selectedMemberRoute else { return } showMemberChat = false - let profile = OpponentProfileView(route: route) + var profile = OpponentProfileView(route: route) + profile.showMessageButton = true // show "Message" button (group member context) let vc = UIHostingController(rootView: profile) vc.navigationItem.hidesBackButton = true navController?.pushViewController(vc, animated: true) @@ -808,7 +809,7 @@ private struct GroupIOS18ScrollTracker: View { // MARK: - UIKit Navigation Bridge /// Invisible UIView that captures the nearest UINavigationController via responder chain. -private struct NavigationControllerAccessor: UIViewRepresentable { +struct NavigationControllerAccessor: UIViewRepresentable { let callback: (UINavigationController?) -> Void func makeUIView(context: Context) -> UIView { @@ -837,7 +838,7 @@ private struct NavigationControllerAccessor: UIViewRepresentable { } } -private extension UIView { +extension UIView { var parentViewController: UIViewController? { var responder: UIResponder? = self.next while let r = responder { diff --git a/Rosetta/Features/Settings/SafetyView.swift b/Rosetta/Features/Settings/SafetyView.swift index 4ba8e44..061f771 100644 --- a/Rosetta/Features/Settings/SafetyView.swift +++ b/Rosetta/Features/Settings/SafetyView.swift @@ -19,21 +19,30 @@ struct SafetyView: View { } var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - keysSection - actionsSection + ZStack(alignment: .topLeading) { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + keysSection + actionsSection + } + .padding(.horizontal, 16) + .padding(.top, 60) + .padding(.bottom, 100) } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 100) + + // Inline back button — UIKit ComposeGlassBackButton (SVG chevron, identical to Appearance) + SettingsGlassBackButton { + dismiss() + } + .frame(width: 44, height: 44) + .padding(.leading, 8) + .padding(.top, 4) } .background(RosettaColors.Adaptive.background) .scrollContentBackground(.hidden) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .enableSwipeBack() - .toolbar { toolbarContent } .toolbarBackground(.hidden, for: .navigationBar) .alert("Delete Account", isPresented: $showDeleteConfirmation) { Button("Cancel", role: .cancel) {} @@ -61,24 +70,6 @@ struct SafetyView: View { } } - // MARK: - Toolbar - - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .navigationBarLeading) { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .glassCircle() - } - } - // MARK: - Keys Section private var keysSection: some View { diff --git a/Rosetta/Features/Settings/UpdatesView.swift b/Rosetta/Features/Settings/UpdatesView.swift index 8126819..9738517 100644 --- a/Rosetta/Features/Settings/UpdatesView.swift +++ b/Rosetta/Features/Settings/UpdatesView.swift @@ -6,46 +6,35 @@ struct UpdatesView: View { @Environment(\.dismiss) private var dismiss var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - statusCard - versionCard - helpText - checkButton + ZStack(alignment: .topLeading) { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + statusCard + versionCard + helpText + checkButton + } + .padding(.horizontal, 16) + .padding(.top, 60) + .padding(.bottom, 100) } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 100) + + // Inline back button — UIKit ComposeGlassBackButton (SVG chevron, identical to Appearance) + SettingsGlassBackButton { + dismiss() + } + .frame(width: 44, height: 44) + .padding(.leading, 8) + .padding(.top, 4) } .background(RosettaColors.Adaptive.background) .scrollContentBackground(.hidden) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .enableSwipeBack() - .toolbar { toolbarContent } .toolbarBackground(.hidden, for: .navigationBar) } - // MARK: - Toolbar - - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .navigationBarLeading) { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .glassCircle() - } - - // No title — per user request - } - // MARK: - Status Card private var statusCard: some View { @@ -181,3 +170,31 @@ struct UpdatesView: View { } } } + +// MARK: - Glass Back Button Bridge (UIKit → SwiftUI) + +/// Wraps `ComposeGlassBackButton` (UIKit) for use in SwiftUI settings screens. +/// Identical chevron icon to AppearanceViewController's back button. +struct SettingsGlassBackButton: UIViewRepresentable { + var action: () -> Void + + func makeUIView(context: Context) -> ComposeGlassBackButton { + let button = ComposeGlassBackButton() + button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside) + return button + } + + func updateUIView(_ uiView: ComposeGlassBackButton, context: Context) { + context.coordinator.action = action + } + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + final class Coordinator: NSObject { + var action: () -> Void + init(action: @escaping () -> Void) { self.action = action } + @objc func tapped() { action() } + } +}