Фикс: 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 scrollToBottomTrigger: UInt = 0
|
||||||
private var pendingAttachments: [PendingAttachment] = []
|
private var pendingAttachments: [PendingAttachment] = []
|
||||||
private var forwardingMessage: ChatMessage?
|
private var forwardingMessage: ChatMessage?
|
||||||
|
private var forwardingMessages: [ChatMessage] = []
|
||||||
private var messageToDelete: ChatMessage?
|
private var messageToDelete: ChatMessage?
|
||||||
private var isMultiSelectMode = false
|
private var isMultiSelectMode = false
|
||||||
private var selectedMessageIds: Set<String> = []
|
private var selectedMessageIds: Set<String> = []
|
||||||
|
|
||||||
|
// MARK: - Selection Toolbar
|
||||||
|
|
||||||
|
private var selectionToolbar: SelectionToolbarView?
|
||||||
|
private var selectionHeaderOverlay: UIView?
|
||||||
|
private var selectionHeaderLabel: UILabel?
|
||||||
|
|
||||||
// MARK: - Cached
|
// MARK: - Cached
|
||||||
|
|
||||||
private let currentPublicKey = SessionManager.shared.currentPublicKey
|
private let currentPublicKey = SessionManager.shared.currentPublicKey
|
||||||
@@ -579,9 +586,16 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cellActions.onEnterSelection = { [weak self] msg in
|
cellActions.onEnterSelection = { [weak self] msg in
|
||||||
self?.isMultiSelectMode = true
|
guard let self else { return }
|
||||||
self?.selectedMessageIds = [msg.id]
|
self.isMultiSelectMode = true
|
||||||
self?.messageListController?.setSelectionMode(true, animated: 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
|
cellActions.onToggleSelection = { [weak self] msgId in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -591,6 +605,8 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
self.selectedMessageIds.insert(msgId)
|
self.selectedMessageIds.insert(msgId)
|
||||||
}
|
}
|
||||||
self.messageListController?.updateSelectedIds(self.selectedMessageIds)
|
self.messageListController?.updateSelectedIds(self.selectedMessageIds)
|
||||||
|
self.selectionToolbar?.updateState(selectedCount: self.selectedMessageIds.count, canDelete: true)
|
||||||
|
self.updateSelectionHeaderLabel()
|
||||||
}
|
}
|
||||||
cellActions.onMentionTap = { [weak self] username in
|
cellActions.onMentionTap = { [weak self] username in
|
||||||
self?.handleMentionTap(username: username)
|
self?.handleMentionTap(username: username)
|
||||||
@@ -789,13 +805,25 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showForwardPicker() {
|
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
|
let picker = ForwardChatPickerView { [weak self] targetRoutes in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.dismiss(animated: true)
|
self.dismiss(animated: true)
|
||||||
guard let message = self.forwardingMessage else { return }
|
|
||||||
self.forwardingMessage = nil
|
|
||||||
for route in targetRoutes {
|
for route in targetRoutes {
|
||||||
self.forwardMessage(message, to: route)
|
for message in messagesToForward {
|
||||||
|
self.forwardMessage(message, to: route)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let hosting = UIHostingController(rootView: picker)
|
let hosting = UIHostingController(rootView: picker)
|
||||||
@@ -817,9 +845,7 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
self.removeMessage(message)
|
self.removeMessage(message)
|
||||||
self.messageToDelete = nil
|
self.messageToDelete = nil
|
||||||
if self.isMultiSelectMode {
|
if self.isMultiSelectMode {
|
||||||
self.isMultiSelectMode = false
|
self.exitSelectionMode(animated: true)
|
||||||
self.selectedMessageIds.removeAll()
|
|
||||||
self.messageListController?.setSelectionMode(false, animated: true)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
||||||
@@ -857,6 +883,175 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
present(alert, animated: true)
|
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
|
// MARK: - Scroll
|
||||||
|
|
||||||
private func scrollToMessage(id: String) {
|
private func scrollToMessage(id: String) {
|
||||||
|
|||||||
@@ -1391,6 +1391,12 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func finalizeVoiceSession(cleanup: VoiceSessionCleanupMode, dismissStyle: VoiceSessionDismissStyle) {
|
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)
|
resetVoiceSessionState(cleanup: cleanup)
|
||||||
|
|
||||||
switch dismissStyle {
|
switch dismissStyle {
|
||||||
@@ -1410,10 +1416,6 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
recordingLockView?.dismiss()
|
recordingLockView?.dismiss()
|
||||||
recordingLockView = nil
|
recordingLockView = nil
|
||||||
|
|
||||||
recordingPreviewPanel?.animateOut { [weak self] in
|
|
||||||
self?.recordingPreviewPanel = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreComposerChrome()
|
restoreComposerChrome()
|
||||||
|
|
||||||
// For cancel: play bin animation inside attach button, then restore icon
|
// For cancel: play bin animation inside attach button, then restore icon
|
||||||
@@ -1512,15 +1514,16 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
|
|
||||||
private func resumeRecordingFromPreview() {
|
private func resumeRecordingFromPreview() {
|
||||||
let trimRange = recordingPreviewPanel?.selectedTrimRange
|
let trimRange = recordingPreviewPanel?.selectedTrimRange
|
||||||
|
// Remove preview panel immediately before layout changes
|
||||||
|
recordingPreviewPanel?.stopPlayback()
|
||||||
|
recordingPreviewPanel?.removeFromSuperview()
|
||||||
|
recordingPreviewPanel = nil
|
||||||
setPreviewRowReplacement(false)
|
setPreviewRowReplacement(false)
|
||||||
micButton.resetState()
|
micButton.resetState()
|
||||||
guard audioRecorder.resumeRecording(trimRange: trimRange) else {
|
guard audioRecorder.resumeRecording(trimRange: trimRange) else {
|
||||||
dismissOverlayAndRestore()
|
dismissOverlayAndRestore()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordingPreviewPanel?.animateOut { [weak self] in
|
|
||||||
self?.recordingPreviewPanel = nil
|
|
||||||
}
|
|
||||||
isRecording = true
|
isRecording = true
|
||||||
isRecordingLocked = true
|
isRecordingLocked = true
|
||||||
setRecordingFlowState(.recordingLocked)
|
setRecordingFlowState(.recordingLocked)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ struct OpponentProfileView: View {
|
|||||||
@State private var topInset: CGFloat = 0
|
@State private var topInset: CGFloat = 0
|
||||||
@State private var isMuted = false
|
@State private var isMuted = false
|
||||||
@State private var showMoreSheet = 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
|
@State private var selectedTab: PeerProfileTab = .media
|
||||||
@Namespace private var tabNamespace
|
@Namespace private var tabNamespace
|
||||||
|
|
||||||
@@ -95,8 +99,15 @@ struct OpponentProfileView: View {
|
|||||||
viewModel.loadSharedContent()
|
viewModel.loadSharedContent()
|
||||||
viewModel.loadCommonGroups()
|
viewModel.loadCommonGroups()
|
||||||
}
|
}
|
||||||
|
.background {
|
||||||
|
NavigationControllerAccessor { nav in
|
||||||
|
self.navController = nav
|
||||||
|
}
|
||||||
|
}
|
||||||
.confirmationDialog("", isPresented: $showMoreSheet, titleVisibility: .hidden) {
|
.confirmationDialog("", isPresented: $showMoreSheet, titleVisibility: .hidden) {
|
||||||
Button("Block User", role: .destructive) {}
|
if !route.isSavedMessages {
|
||||||
|
Button("Block User", role: .destructive) {}
|
||||||
|
}
|
||||||
Button("Clear Chat History", role: .destructive) {}
|
Button("Clear Chat History", role: .destructive) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,10 +138,14 @@ struct OpponentProfileView: View {
|
|||||||
avatarInitials: RosettaColors.initials(name: displayName, publicKey: route.publicKey),
|
avatarInitials: RosettaColors.initials(name: displayName, publicKey: route.publicKey),
|
||||||
avatarColorIndex: RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey),
|
avatarColorIndex: RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey),
|
||||||
isMuted: isMuted,
|
isMuted: isMuted,
|
||||||
|
showCallButton: !route.isSavedMessages,
|
||||||
|
showMuteButton: !route.isSavedMessages,
|
||||||
|
showMessageButton: route.isSavedMessages || showMessageButton,
|
||||||
onCall: handleCall,
|
onCall: handleCall,
|
||||||
onMuteToggle: handleMuteToggle,
|
onMuteToggle: handleMuteToggle,
|
||||||
onSearch: { dismiss() },
|
onSearch: { dismiss() },
|
||||||
onMore: { showMoreSheet = true }
|
onMore: { showMoreSheet = true },
|
||||||
|
onMessage: handleMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,6 +422,19 @@ struct OpponentProfileView: View {
|
|||||||
DialogRepository.shared.toggleMute(opponentKey: route.publicKey)
|
DialogRepository.shared.toggleMute(opponentKey: route.publicKey)
|
||||||
isMuted.toggle()
|
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
|
// MARK: - Media Tile
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ struct PeerProfileHeaderView: View {
|
|||||||
let avatarColorIndex: Int
|
let avatarColorIndex: Int
|
||||||
let isMuted: Bool
|
let isMuted: Bool
|
||||||
var showCallButton: Bool = true
|
var showCallButton: Bool = true
|
||||||
|
var showMuteButton: Bool = true
|
||||||
|
var showMessageButton: Bool = false
|
||||||
let onCall: () -> Void
|
let onCall: () -> Void
|
||||||
let onMuteToggle: () -> Void
|
let onMuteToggle: () -> Void
|
||||||
let onSearch: () -> Void
|
let onSearch: () -> Void
|
||||||
let onMore: () -> Void
|
let onMore: () -> Void
|
||||||
|
var onMessage: (() -> Void)?
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
@@ -166,14 +169,19 @@ struct PeerProfileHeaderView: View {
|
|||||||
|
|
||||||
private var actionButtons: some View {
|
private var actionButtons: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
|
if showMessageButton, let onMessage {
|
||||||
|
profileActionButton(icon: "bubble.left.fill", title: "Message", action: onMessage)
|
||||||
|
}
|
||||||
if showCallButton {
|
if showCallButton {
|
||||||
profileActionButton(icon: "phone.fill", title: "Call", action: onCall)
|
profileActionButton(icon: "phone.fill", title: "Call", action: onCall)
|
||||||
}
|
}
|
||||||
profileActionButton(
|
if showMuteButton {
|
||||||
icon: isMuted ? "bell.slash.fill" : "bell.fill",
|
profileActionButton(
|
||||||
title: isMuted ? "Unmute" : "Mute",
|
icon: isMuted ? "bell.slash.fill" : "bell.fill",
|
||||||
action: onMuteToggle
|
title: isMuted ? "Unmute" : "Mute",
|
||||||
)
|
action: onMuteToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
profileActionButton(icon: "magnifyingglass", title: "Search", action: onSearch)
|
profileActionButton(icon: "magnifyingglass", title: "Search", action: onSearch)
|
||||||
profileActionButton(icon: "ellipsis", title: "More", action: onMore)
|
profileActionButton(icon: "ellipsis", title: "More", action: onMore)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ final class RecordingPreviewPanel: UIView, UIGestureRecognizerDelegate {
|
|||||||
stopDisplayLink()
|
stopDisplayLink()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopPlayback(resetToTrimStart: Bool = true) {
|
func stopPlayback(resetToTrimStart: Bool = true) {
|
||||||
audioPlayer?.stop()
|
audioPlayer?.stop()
|
||||||
if resetToTrimStart {
|
if resetToTrimStart {
|
||||||
audioPlayer?.currentTime = trimStart
|
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
|
.onChange(of: showMemberChat) { show in
|
||||||
guard show, let route = selectedMemberRoute else { return }
|
guard show, let route = selectedMemberRoute else { return }
|
||||||
showMemberChat = false
|
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)
|
let vc = UIHostingController(rootView: profile)
|
||||||
vc.navigationItem.hidesBackButton = true
|
vc.navigationItem.hidesBackButton = true
|
||||||
navController?.pushViewController(vc, animated: true)
|
navController?.pushViewController(vc, animated: true)
|
||||||
@@ -808,7 +809,7 @@ private struct GroupIOS18ScrollTracker<Content: View>: View {
|
|||||||
// MARK: - UIKit Navigation Bridge
|
// MARK: - UIKit Navigation Bridge
|
||||||
|
|
||||||
/// Invisible UIView that captures the nearest UINavigationController via responder chain.
|
/// Invisible UIView that captures the nearest UINavigationController via responder chain.
|
||||||
private struct NavigationControllerAccessor: UIViewRepresentable {
|
struct NavigationControllerAccessor: UIViewRepresentable {
|
||||||
let callback: (UINavigationController?) -> Void
|
let callback: (UINavigationController?) -> Void
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
func makeUIView(context: Context) -> UIView {
|
||||||
@@ -837,7 +838,7 @@ private struct NavigationControllerAccessor: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension UIView {
|
extension UIView {
|
||||||
var parentViewController: UIViewController? {
|
var parentViewController: UIViewController? {
|
||||||
var responder: UIResponder? = self.next
|
var responder: UIResponder? = self.next
|
||||||
while let r = responder {
|
while let r = responder {
|
||||||
|
|||||||
@@ -19,21 +19,30 @@ struct SafetyView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ZStack(alignment: .topLeading) {
|
||||||
VStack(spacing: 0) {
|
ScrollView(showsIndicators: false) {
|
||||||
keysSection
|
VStack(spacing: 0) {
|
||||||
actionsSection
|
keysSection
|
||||||
|
actionsSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 60)
|
||||||
|
.padding(.bottom, 100)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 16)
|
// Inline back button — UIKit ComposeGlassBackButton (SVG chevron, identical to Appearance)
|
||||||
.padding(.bottom, 100)
|
SettingsGlassBackButton {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.padding(.leading, 8)
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
.background(RosettaColors.Adaptive.background)
|
.background(RosettaColors.Adaptive.background)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.enableSwipeBack()
|
.enableSwipeBack()
|
||||||
.toolbar { toolbarContent }
|
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
.alert("Delete Account", isPresented: $showDeleteConfirmation) {
|
.alert("Delete Account", isPresented: $showDeleteConfirmation) {
|
||||||
Button("Cancel", role: .cancel) {}
|
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
|
// MARK: - Keys Section
|
||||||
|
|
||||||
private var keysSection: some View {
|
private var keysSection: some View {
|
||||||
|
|||||||
@@ -6,46 +6,35 @@ struct UpdatesView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ZStack(alignment: .topLeading) {
|
||||||
VStack(spacing: 0) {
|
ScrollView(showsIndicators: false) {
|
||||||
statusCard
|
VStack(spacing: 0) {
|
||||||
versionCard
|
statusCard
|
||||||
helpText
|
versionCard
|
||||||
checkButton
|
helpText
|
||||||
|
checkButton
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 60)
|
||||||
|
.padding(.bottom, 100)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 16)
|
// Inline back button — UIKit ComposeGlassBackButton (SVG chevron, identical to Appearance)
|
||||||
.padding(.bottom, 100)
|
SettingsGlassBackButton {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.padding(.leading, 8)
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
.background(RosettaColors.Adaptive.background)
|
.background(RosettaColors.Adaptive.background)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.enableSwipeBack()
|
.enableSwipeBack()
|
||||||
.toolbar { toolbarContent }
|
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.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
|
// MARK: - Status Card
|
||||||
|
|
||||||
private var statusCard: some View {
|
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