From 75008e4a20e6d1bc2d3434908a032944f63d55f8 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Thu, 16 Apr 2026 09:16:57 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20empty=20state=20+?= =?UTF-8?q?=20service=20messages=20+=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20GroupInfoView=20+=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=84=D0=B8=D0=BB=D1=8C=20=D0=B8=D0=B7=20=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D1=8B=20+=20Saved=20Messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SelectionForward.imageset/Contents.json | 15 ++ .../SelectionForward.imageset/ic_forward.pdf | Bin 0 -> 4293 bytes .../SelectionShare.imageset/Contents.json | 15 ++ .../SelectionShare.imageset/ic_share.pdf | Bin 0 -> 4321 bytes .../SelectionTrash.imageset/Contents.json | 15 ++ .../SelectionTrash.imageset/ic_delete.pdf | Bin 0 -> 4692 bytes .../ChatDetail/ChatDetailViewController.swift | 213 +++++++++++++++++- .../Chats/ChatDetail/ComposerView.swift | 17 +- .../ChatDetail/OpponentProfileView.swift | 32 ++- .../ChatDetail/PeerProfileHeaderView.swift | 18 +- .../ChatDetail/RecordingPreviewPanel.swift | 2 +- .../ChatDetail/SelectionToolbarView.swift | 177 +++++++++++++++ Rosetta/Features/Groups/GroupInfoView.swift | 7 +- Rosetta/Features/Settings/SafetyView.swift | 43 ++-- Rosetta/Features/Settings/UpdatesView.swift | 77 ++++--- 15 files changed, 548 insertions(+), 83 deletions(-) create mode 100644 Rosetta/Assets.xcassets/SelectionForward.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/SelectionForward.imageset/ic_forward.pdf create mode 100644 Rosetta/Assets.xcassets/SelectionShare.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/SelectionShare.imageset/ic_share.pdf create mode 100644 Rosetta/Assets.xcassets/SelectionTrash.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/SelectionTrash.imageset/ic_delete.pdf create mode 100644 Rosetta/Features/Chats/ChatDetail/SelectionToolbarView.swift 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 0000000000000000000000000000000000000000..b602a9cc891ad8066b0db428ce19ecd9d5e47828 GIT binary patch literal 4293 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 X=;29DjUPV*4wDoI^YN+bsDb|jVbvi= literal 0 HcmV?d00001 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() } + } +}