From 6062c1b5037b4413fca3b1d2280db392e915a12f Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Thu, 21 May 2026 15:33:18 +0300 Subject: [PATCH] Add iOS message action controls --- apps/ios/TeleTuiIOS/Package.swift | 6 +- .../TeleTuiIOSCore/SessionBridgeFactory.swift | 2 +- .../TeleTuiIOSCore/UniFfiSessionBridge.swift | 6 +- .../Sources/TeleTuiIOSCore/ViewModels.swift | 4 + .../Sources/TeleTuiIOSCore/Views.swift | 239 +++++++++++++++--- scripts/typecheck-ios-uniffi-app-bridge.sh | 1 + 6 files changed, 221 insertions(+), 37 deletions(-) diff --git a/apps/ios/TeleTuiIOS/Package.swift b/apps/ios/TeleTuiIOS/Package.swift index 70fdaaf..41354ad 100644 --- a/apps/ios/TeleTuiIOS/Package.swift +++ b/apps/ios/TeleTuiIOS/Package.swift @@ -23,6 +23,9 @@ let localFfiTargets: [Target] = useLocalFfi ? [ let coreDependencies: [Target.Dependency] = useLocalFfi ? [ "tele_ios_ffi", ] : [] +let coreSwiftSettings: [SwiftSetting] = useLocalFfi ? [ + .define("TELE_IOS_USE_LOCAL_FFI"), +] : [] let package = Package( name: "TeleTuiIOS", @@ -38,7 +41,8 @@ let package = Package( targets: [ .target( name: "TeleTuiIOSCore", - dependencies: coreDependencies + dependencies: coreDependencies, + swiftSettings: coreSwiftSettings ), .executableTarget( name: "TeleTuiIOSApp", diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/SessionBridgeFactory.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/SessionBridgeFactory.swift index 136dbe0..2e7157b 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/SessionBridgeFactory.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/SessionBridgeFactory.swift @@ -5,7 +5,7 @@ public enum SessionBridgeFactory { account: Account, useFakeTdlib: Bool = true ) -> SessionBridge { - #if canImport(tele_ios_ffi) || canImport(tele_ios_ffiFFI) + #if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI do { return try UniFfiSessionBridge(account: account, useFakeTdlib: useFakeTdlib) } catch { diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/UniFfiSessionBridge.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/UniFfiSessionBridge.swift index 9bd5e42..de233fd 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/UniFfiSessionBridge.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/UniFfiSessionBridge.swift @@ -1,12 +1,12 @@ import Foundation -#if canImport(tele_ios_ffi) +#if TELE_IOS_USE_LOCAL_FFI import tele_ios_ffi -#elseif canImport(tele_ios_ffiFFI) +#elseif TELE_IOS_TYPECHECK_UNIFFI import tele_ios_ffiFFI #endif -#if canImport(tele_ios_ffi) || canImport(tele_ios_ffiFFI) +#if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI public actor UniFfiSessionBridge: SessionBridge { private let handle: SessionHandle private let defaultLimit: Int32 diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift index 63c8825..e7a6e58 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift @@ -195,6 +195,10 @@ public final class ChatViewModel: ObservableObject { replyTo = message } + public func cancelReply() { + replyTo = nil + } + public func edit(message: Message, text: String) async { do { let edited = try await bridge.editMessage(chatId: chat.id, messageId: message.id, text: text) diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift index 4124479..8149d3b 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift @@ -217,36 +217,93 @@ public struct ChatRow: View { public struct ChatDetailView: View { @StateObject public var viewModel: ChatViewModel public let bridge: SessionBridge + public let clipboard: ClipboardWriting @StateObject private var profileViewModel: ProfileViewModel @State private var showsProfile = false + @State private var editingMessage: Message? + @State private var editedText = "" + @State private var deleteCandidate: Message? + @State private var forwardCandidate: Message? + @State private var forwardChatIdText = "" - public init(viewModel: ChatViewModel, bridge: SessionBridge) { + public init( + viewModel: ChatViewModel, + bridge: SessionBridge, + clipboard: ClipboardWriting = SystemClipboardWriter() + ) { _viewModel = StateObject(wrappedValue: viewModel) self.bridge = bridge + self.clipboard = clipboard _profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge)) } public var body: some View { VStack(spacing: 0) { - List(viewModel.messages) { message in - MessageRow(message: message) - .contextMenu { - Button("Reply") { - viewModel.beginReply(to: message) - } - Button("React") { - Task { await viewModel.react(message: message, reaction: "👍") } - } - Button("Copy") { - Task { await viewModel.copyPayload(for: message) } - } - Button("Delete", role: .destructive) { - Task { await viewModel.delete(message: message) } + List { + if !viewModel.searchText.isEmpty { + Section("Search") { + if viewModel.searchResults.isEmpty { + Text("No results") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.searchResults) { message in + MessageRow(message: message) + .listRowSeparator(.hidden) + } } } - .listRowSeparator(.hidden) + } + Section { + ForEach(viewModel.messages) { message in + MessageRow(message: message) + .contextMenu { + Button { + viewModel.beginReply(to: message) + } label: { + Label("Reply", systemImage: "arrowshape.turn.up.left") + } + Button { + editingMessage = message + editedText = message.text + } label: { + Label("Edit", systemImage: "pencil") + } + Button { + forwardCandidate = message + forwardChatIdText = "" + } label: { + Label("Forward", systemImage: "arrowshape.turn.up.forward") + } + Button { + Task { await viewModel.react(message: message, reaction: "👍") } + } label: { + Label("React", systemImage: "face.smiling") + } + Button { + Task { + await viewModel.copyPayload(for: message) + if let payload = viewModel.copiedPayload { + await clipboard.write(text: payload) + } + } + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + Button(role: .destructive) { + deleteCandidate = message + } label: { + Label("Delete", systemImage: "trash") + } + } + .listRowSeparator(.hidden) + } + } } - ComposeBar(text: $viewModel.composeText) { + ComposeBar( + text: $viewModel.composeText, + replyTo: viewModel.replyTo, + cancelReply: { viewModel.cancelReply() } + ) { Task { await viewModel.send() } } } @@ -264,10 +321,78 @@ public struct ChatDetailView: View { .sheet(isPresented: $showsProfile) { ProfileView(viewModel: profileViewModel) } + .alert("Edit Message", isPresented: editAlertBinding) { + TextField("Message", text: $editedText) + Button("Save") { + if let editingMessage { + Task { await viewModel.edit(message: editingMessage, text: editedText) } + } + editingMessage = nil + } + Button("Cancel", role: .cancel) { + editingMessage = nil + } + } + .alert("Delete Message", isPresented: deleteAlertBinding) { + Button("Delete", role: .destructive) { + if let deleteCandidate { + Task { await viewModel.delete(message: deleteCandidate) } + } + deleteCandidate = nil + } + Button("Cancel", role: .cancel) { + deleteCandidate = nil + } + } + .alert("Forward Message", isPresented: forwardAlertBinding) { + TextField("Chat ID", text: $forwardChatIdText) + Button("Forward") { + if let forwardCandidate, let chatId = Int64(forwardChatIdText) { + Task { await viewModel.forward(message: forwardCandidate, to: chatId) } + } + forwardCandidate = nil + } + Button("Cancel", role: .cancel) { + forwardCandidate = nil + } + } .task { await viewModel.load() } } + + private var editAlertBinding: Binding { + Binding( + get: { editingMessage != nil }, + set: { isPresented in + if !isPresented { + editingMessage = nil + } + } + ) + } + + private var deleteAlertBinding: Binding { + Binding( + get: { deleteCandidate != nil }, + set: { isPresented in + if !isPresented { + deleteCandidate = nil + } + } + ) + } + + private var forwardAlertBinding: Binding { + Binding( + get: { forwardCandidate != nil }, + set: { isPresented in + if !isPresented { + forwardCandidate = nil + } + } + ) + } } public struct MessageRow: View { @@ -294,12 +419,29 @@ public struct MessageRow: View { .foregroundStyle(.secondary) .padding(.leading, 6) } + if let forwardSenderName = message.forwardSenderName { + Label(forwardSenderName, systemImage: "arrowshape.turn.up.forward") + .font(.caption) + .foregroundStyle(.secondary) + } Text(message.text) .textSelection(.enabled) if !message.reactions.isEmpty { Text(message.reactions.map(\.emoji).joined(separator: " ")) .font(.caption) } + if message.editDate != nil || message.isOutgoing { + HStack(spacing: 6) { + if message.editDate != nil { + Text("edited") + } + if message.isOutgoing { + Image(systemName: message.isRead ? "checkmark.circle.fill" : "checkmark.circle") + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } } .padding(10) .background(message.isOutgoing ? Color.blue.opacity(0.16) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) @@ -312,31 +454,64 @@ public struct MessageRow: View { public struct ComposeBar: View { @Binding public var text: String + public var replyTo: Message? + public var cancelReply: () -> Void public var send: () -> Void - public init(text: Binding, send: @escaping () -> Void) { + public init( + text: Binding, + replyTo: Message? = nil, + cancelReply: @escaping () -> Void = {}, + send: @escaping () -> Void + ) { _text = text + self.replyTo = replyTo + self.cancelReply = cancelReply self.send = send } public var body: some View { - HStack(spacing: 10) { - if !text.isEmpty { - Button { - text = "" - } label: { - Image(systemName: "xmark.circle.fill") + VStack(spacing: 8) { + if let replyTo { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(replyTo.senderName) + .font(.caption) + .foregroundStyle(.secondary) + Text(replyTo.text) + .font(.footnote) + .lineLimit(1) + } + Spacer() + Button(action: cancelReply) { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) } - .buttonStyle(.plain) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) } - TextField("Message", text: $text, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(1...5) - Button(action: send) { - Image(systemName: "paperplane.fill") + HStack(spacing: 10) { + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + } + TextField("Message", text: $text, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...5) + Button { + send() + } label: { + Image(systemName: "paperplane.fill") + } + .buttonStyle(.borderedProminent) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .buttonStyle(.borderedProminent) - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding() .background(.bar) diff --git a/scripts/typecheck-ios-uniffi-app-bridge.sh b/scripts/typecheck-ios-uniffi-app-bridge.sh index 9411f69..a6833cd 100755 --- a/scripts/typecheck-ios-uniffi-app-bridge.sh +++ b/scripts/typecheck-ios-uniffi-app-bridge.sh @@ -16,6 +16,7 @@ fi swiftc \ -typecheck \ -parse-as-library \ + -D TELE_IOS_TYPECHECK_UNIFFI \ -module-name TeleTuiIOSCore \ -module-cache-path "${module_cache}" \ -I "${ffi_dir}/Headers" \