Add iOS message action controls
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
Reference in New Issue
Block a user