diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 4883a04c..e343658e 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -67,6 +67,15 @@ final class ConversationUIModel: ObservableObject { chatViewModel.sendMessage("/clear") } + /// Resends a message's content as a fresh outgoing private message to + /// `peerID`, regardless of which conversation (if any) is currently open. + /// Goes straight through the same send path a normal DM uses -- this is + /// not a distinct wire concept, just a new outgoing message that happens + /// to start from someone else's words. + func forwardMessage(_ content: String, to peerID: PeerID) { + chatViewModel.sendPrivateMessage(content, to: peerID) + } + func sendHug(to sender: String) { chatViewModel.sendMessage("/hug @\(sender)") } diff --git a/bitchat/Views/ForwardMessageSheet.swift b/bitchat/Views/ForwardMessageSheet.swift new file mode 100644 index 00000000..5c1b7278 --- /dev/null +++ b/bitchat/Views/ForwardMessageSheet.swift @@ -0,0 +1,136 @@ +// +// ForwardMessageSheet.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import SwiftUI + +/// Picker presented from a message's context menu: choose a direct +/// conversation to resend this message's content into. Deliberately DMs +/// only for v1 -- forwarding into a public mesh/geohash channel is a +/// materially different action (broadcasting someone's content rather than +/// resending it privately) and stays out of scope here. +/// +/// Sources targets the same way the people sheet does: currently-known mesh +/// peers first (PeerListModel.meshRows, connected or not), then people whose +/// direct conversation fell out of that roster (PeerListModel.recentChatRows, +/// reusing RecentChatList verbatim -- see its own doc comment for why that +/// section exists). "Me" and blocked peers are never valid forward targets. +struct ForwardMessageSheet: View { + @EnvironmentObject private var peerListModel: PeerListModel + @Environment(\.dismiss) private var dismiss + @ThemedPalette private var palette + + /// Called with the chosen recipient; the sheet dismisses itself right + /// after, so this only needs to hand off the target. + let onForward: (PeerID) -> Void + + private enum Strings { + static let title = String(localized: "forward.title", defaultValue: "Forward to…", comment: "Title of the sheet for picking who to forward a message to") + static let noRecipients = String(localized: "forward.no_recipients", defaultValue: "No one to forward to yet — no known peers or recent conversations.", comment: "Empty state shown in the forward-message sheet when there is nobody to pick") + static let peersHeader = String(localized: "forward.peers_header", defaultValue: "people", comment: "Section header above known peers in the forward-message sheet") + } + + private var meshTargets: [MeshPeerRow] { + peerListModel.meshRows.filter { !$0.isMe && !$0.isBlocked } + } + + private var hasAnyTarget: Bool { + !meshTargets.isEmpty || !peerListModel.recentChatRows.isEmpty + } + + var body: some View { + #if os(macOS) + VStack(spacing: 0) { + HStack { + Text(Strings.title) + .bitchatFont(size: 16, weight: .bold) + .foregroundColor(palette.primary) + Spacer() + SheetCloseButton { dismiss() } + .foregroundColor(palette.primary) + } + .padding() + .themedSurface(opacity: 0.95) + + content + } + .frame(width: 380, height: 420) + .themedSheetBackground() + #else + NavigationView { + content + .themedSheetBackground() + .navigationTitle(Text(Strings.title)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + SheetCloseButton { dismiss() } + .foregroundColor(palette.primary) + } + } + } + #endif + } + + @ViewBuilder + private var content: some View { + if !hasAnyTarget { + VStack { + Spacer() + Text(Strings.noRecipients) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + Spacer() + } + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if !meshTargets.isEmpty { + PeopleSectionHeader( + icon: "antenna.radiowaves.left.and.right", + iconColor: palette.accentBlue, + title: Strings.peersHeader + ) + ForEach(meshTargets) { peer in + forwardRow(displayName: peer.displayName, peerID: peer.peerID) + } + } + + RecentChatList(chats: peerListModel.recentChatRows) { peerID in + forward(to: peerID) + } + } + .padding(.vertical, 8) + } + } + } + + private func forwardRow(displayName: String, peerID: PeerID) -> some View { + Button { + forward(to: peerID) + } label: { + Text(verbatim: displayName) + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func forward(to peerID: PeerID) { + onForward(peerID) + dismiss() + } +} diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 1894a347..fb804b51 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -40,6 +40,7 @@ struct MessageListView: View { @State private var showMessageActions = false @State private var showClearConfirmation = false + @State private var messageToForward: BitchatMessage? @State private var lastScrollTime: Date = .distantPast @State private var scrollThrottleTimer: Timer? @State private var unseenCount = 0 @@ -157,6 +158,15 @@ struct MessageListView: View { conversationUIModel.resendFailedPrivateMessage(message) } } + Button( + String( + localized: "content.actions.forward", + defaultValue: "Forward", + comment: "Context menu action that opens a picker to forward this message to another conversation" + ) + ) { + messageToForward = message + } if showsUserActions { Button("content.actions.block", role: .destructive) { conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender) @@ -273,6 +283,16 @@ struct MessageListView: View { .onDisappear { scrollThrottleTimer?.invalidate() } + .sheet(isPresented: Binding( + get: { messageToForward != nil }, + set: { isPresented in if !isPresented { messageToForward = nil } } + )) { + if let messageToForward { + ForwardMessageSheet { peerID in + conversationUIModel.forwardMessage(messageToForward.content, to: peerID) + } + } + } } } }