Add iOS message action controls
This commit is contained in:
@@ -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<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 {
|
||||
@@ -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<String>, send: @escaping () -> Void) {
|
||||
public init(
|
||||
text: Binding<String>,
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user