Add iOS message action controls

This commit is contained in:
Mikhail Kilin
2026-05-21 15:33:18 +03:00
parent 217328505c
commit 6062c1b503
6 changed files with 221 additions and 37 deletions

View File

@@ -23,6 +23,9 @@ let localFfiTargets: [Target] = useLocalFfi ? [
let coreDependencies: [Target.Dependency] = useLocalFfi ? [ let coreDependencies: [Target.Dependency] = useLocalFfi ? [
"tele_ios_ffi", "tele_ios_ffi",
] : [] ] : []
let coreSwiftSettings: [SwiftSetting] = useLocalFfi ? [
.define("TELE_IOS_USE_LOCAL_FFI"),
] : []
let package = Package( let package = Package(
name: "TeleTuiIOS", name: "TeleTuiIOS",
@@ -38,7 +41,8 @@ let package = Package(
targets: [ targets: [
.target( .target(
name: "TeleTuiIOSCore", name: "TeleTuiIOSCore",
dependencies: coreDependencies dependencies: coreDependencies,
swiftSettings: coreSwiftSettings
), ),
.executableTarget( .executableTarget(
name: "TeleTuiIOSApp", name: "TeleTuiIOSApp",

View File

@@ -5,7 +5,7 @@ public enum SessionBridgeFactory {
account: Account, account: Account,
useFakeTdlib: Bool = true useFakeTdlib: Bool = true
) -> SessionBridge { ) -> SessionBridge {
#if canImport(tele_ios_ffi) || canImport(tele_ios_ffiFFI) #if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI
do { do {
return try UniFfiSessionBridge(account: account, useFakeTdlib: useFakeTdlib) return try UniFfiSessionBridge(account: account, useFakeTdlib: useFakeTdlib)
} catch { } catch {

View File

@@ -1,12 +1,12 @@
import Foundation import Foundation
#if canImport(tele_ios_ffi) #if TELE_IOS_USE_LOCAL_FFI
import tele_ios_ffi import tele_ios_ffi
#elseif canImport(tele_ios_ffiFFI) #elseif TELE_IOS_TYPECHECK_UNIFFI
import tele_ios_ffiFFI import tele_ios_ffiFFI
#endif #endif
#if canImport(tele_ios_ffi) || canImport(tele_ios_ffiFFI) #if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI
public actor UniFfiSessionBridge: SessionBridge { public actor UniFfiSessionBridge: SessionBridge {
private let handle: SessionHandle private let handle: SessionHandle
private let defaultLimit: Int32 private let defaultLimit: Int32

View File

@@ -195,6 +195,10 @@ public final class ChatViewModel: ObservableObject {
replyTo = message replyTo = message
} }
public func cancelReply() {
replyTo = nil
}
public func edit(message: Message, text: String) async { public func edit(message: Message, text: String) async {
do { do {
let edited = try await bridge.editMessage(chatId: chat.id, messageId: message.id, text: text) let edited = try await bridge.editMessage(chatId: chat.id, messageId: message.id, text: text)

View File

@@ -217,36 +217,93 @@ public struct ChatRow: View {
public struct ChatDetailView: View { public struct ChatDetailView: View {
@StateObject public var viewModel: ChatViewModel @StateObject public var viewModel: ChatViewModel
public let bridge: SessionBridge public let bridge: SessionBridge
public let clipboard: ClipboardWriting
@StateObject private var profileViewModel: ProfileViewModel @StateObject private var profileViewModel: ProfileViewModel
@State private var showsProfile = false @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) _viewModel = StateObject(wrappedValue: viewModel)
self.bridge = bridge self.bridge = bridge
self.clipboard = clipboard
_profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge)) _profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge))
} }
public var body: some View { public var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
List(viewModel.messages) { message in List {
MessageRow(message: message) if !viewModel.searchText.isEmpty {
.contextMenu { Section("Search") {
Button("Reply") { if viewModel.searchResults.isEmpty {
viewModel.beginReply(to: message) Text("No results")
} .foregroundStyle(.secondary)
Button("React") { } else {
Task { await viewModel.react(message: message, reaction: "👍") } ForEach(viewModel.searchResults) { message in
} MessageRow(message: message)
Button("Copy") { .listRowSeparator(.hidden)
Task { await viewModel.copyPayload(for: message) } }
}
Button("Delete", role: .destructive) {
Task { await viewModel.delete(message: message) }
} }
} }
.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() } Task { await viewModel.send() }
} }
} }
@@ -264,10 +321,78 @@ public struct ChatDetailView: View {
.sheet(isPresented: $showsProfile) { .sheet(isPresented: $showsProfile) {
ProfileView(viewModel: profileViewModel) 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 { .task {
await viewModel.load() await viewModel.load()
} }
} }
private var editAlertBinding: Binding<Bool> {
Binding(
get: { editingMessage != nil },
set: { isPresented in
if !isPresented {
editingMessage = nil
}
}
)
}
private var deleteAlertBinding: Binding<Bool> {
Binding(
get: { deleteCandidate != nil },
set: { isPresented in
if !isPresented {
deleteCandidate = nil
}
}
)
}
private var forwardAlertBinding: Binding<Bool> {
Binding(
get: { forwardCandidate != nil },
set: { isPresented in
if !isPresented {
forwardCandidate = nil
}
}
)
}
} }
public struct MessageRow: View { public struct MessageRow: View {
@@ -294,12 +419,29 @@ public struct MessageRow: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.leading, 6) .padding(.leading, 6)
} }
if let forwardSenderName = message.forwardSenderName {
Label(forwardSenderName, systemImage: "arrowshape.turn.up.forward")
.font(.caption)
.foregroundStyle(.secondary)
}
Text(message.text) Text(message.text)
.textSelection(.enabled) .textSelection(.enabled)
if !message.reactions.isEmpty { if !message.reactions.isEmpty {
Text(message.reactions.map(\.emoji).joined(separator: " ")) Text(message.reactions.map(\.emoji).joined(separator: " "))
.font(.caption) .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) .padding(10)
.background(message.isOutgoing ? Color.blue.opacity(0.16) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) .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 { public struct ComposeBar: View {
@Binding public var text: String @Binding public var text: String
public var replyTo: Message?
public var cancelReply: () -> Void
public var send: () -> Void public var send: () -> Void
public init(text: Binding<String>, send: @escaping () -> Void) { public init(
text: Binding<String>,
replyTo: Message? = nil,
cancelReply: @escaping () -> Void = {},
send: @escaping () -> Void
) {
_text = text _text = text
self.replyTo = replyTo
self.cancelReply = cancelReply
self.send = send self.send = send
} }
public var body: some View { public var body: some View {
HStack(spacing: 10) { VStack(spacing: 8) {
if !text.isEmpty { if let replyTo {
Button { HStack {
text = "" VStack(alignment: .leading, spacing: 2) {
} label: { Text(replyTo.senderName)
Image(systemName: "xmark.circle.fill") .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) HStack(spacing: 10) {
.textFieldStyle(.roundedBorder) if !text.isEmpty {
.lineLimit(1...5) Button {
Button(action: send) { text = ""
Image(systemName: "paperplane.fill") } 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() .padding()
.background(.bar) .background(.bar)

View File

@@ -16,6 +16,7 @@ fi
swiftc \ swiftc \
-typecheck \ -typecheck \
-parse-as-library \ -parse-as-library \
-D TELE_IOS_TYPECHECK_UNIFFI \
-module-name TeleTuiIOSCore \ -module-name TeleTuiIOSCore \
-module-cache-path "${module_cache}" \ -module-cache-path "${module_cache}" \
-I "${ffi_dir}/Headers" \ -I "${ffi_dir}/Headers" \