Фикс: empty state + service messages + навигация GroupInfoView + профиль из группы + Saved Messages
This commit is contained in:
15
Rosetta/Assets.xcassets/SelectionForward.imageset/Contents.json
vendored
Normal file
15
Rosetta/Assets.xcassets/SelectionForward.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "ic_forward.pdf"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Rosetta/Assets.xcassets/SelectionForward.imageset/ic_forward.pdf
vendored
Normal file
BIN
Rosetta/Assets.xcassets/SelectionForward.imageset/ic_forward.pdf
vendored
Normal file
Binary file not shown.
15
Rosetta/Assets.xcassets/SelectionShare.imageset/Contents.json
vendored
Normal file
15
Rosetta/Assets.xcassets/SelectionShare.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "ic_share.pdf"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Rosetta/Assets.xcassets/SelectionShare.imageset/ic_share.pdf
vendored
Normal file
BIN
Rosetta/Assets.xcassets/SelectionShare.imageset/ic_share.pdf
vendored
Normal file
Binary file not shown.
15
Rosetta/Assets.xcassets/SelectionTrash.imageset/Contents.json
vendored
Normal file
15
Rosetta/Assets.xcassets/SelectionTrash.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "ic_delete.pdf"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Rosetta/Assets.xcassets/SelectionTrash.imageset/ic_delete.pdf
vendored
Normal file
BIN
Rosetta/Assets.xcassets/SelectionTrash.imageset/ic_delete.pdf
vendored
Normal file
Binary file not shown.
@@ -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<String> = []
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
177
Rosetta/Features/Chats/ChatDetail/SelectionToolbarView.swift
Normal file
177
Rosetta/Features/Chats/ChatDetail/SelectionToolbarView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Content: View>: 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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user