From 72c21fb183749e481307b2db0686875669a0600f Mon Sep 17 00:00:00 2001 From: krishrathi1 Date: Sun, 16 Aug 2026 13:34:21 +0530 Subject: [PATCH] Add a Forward action to resend a message into another conversation Long-press a message -> Forward opens a picker of direct-conversation targets and resends the message's content as a new outgoing DM to whichever one is chosen. 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. ForwardMessageSheet sources targets the same way the existing people sheet does -- currently-known mesh peers (PeerListModel.meshRows, connected or not, excluding self and blocked peers) plus people whose direct conversation fell out of that roster (PeerListModel.recentChatRows, reusing the existing RecentChatList component verbatim rather than reinventing that section). ConversationUIModel.forwardMessage(_:to:) is a one-line delegate to the same ChatViewModel.sendPrivateMessage(_:to:) every normal DM already goes through -- not a new wire concept, just a new outgoing message that starts from someone else's words, sent to an arbitrary peer regardless of which conversation (if any) is currently open. New strings (content.actions.forward, forward.title, forward.no_recipients, forward.peers_header) carry their English copy inline via defaultValue, following this file area's own documented convention for new keys ("New keys carry their English copy inline (defaultValue) until the i18n pass lands them in the catalog" -- AppInfoView.swift's Strings enum) -- Localizable.xcstrings itself is untouched. No Xcode/Swift toolchain available here, so this is unusually unverified for new SwiftUI surface area, and I want to be upfront about that rather than overstate confidence: - Traced every new type/call against its actual declaration rather than assuming: RecentChatList's exact init signature and existing call convention (matched against its current use in ContentSheetViews.swift), MeshPeerRow/RecentChatRow's real fields, ChatViewModel+PrivateChat.swift's sendPrivateMessage(_:to:) confirmed to route to an arbitrary peerID (not gated on "currently selected" state), SheetCloseButton and themedSheetBackground()/themedSurface() matched against MeshTopologyView's existing minimal-sheet pattern (same macOS/iOS split), PeopleSectionHeader's real init. - Verified brace/paren balance across all three changed files programmatically rather than by eye alone. - No dedicated test: ConversationUIModel has no existing test file, and its sibling one-line wrapper methods (sendHug, sendSlap, resendFailedPrivateMessage) are equally untested -- forwardMessage follows that same established (untested) convention rather than introducing new test infrastructure alone for a single delegate call. This is the least-verified PR I've opened against this repo so far given the amount of new SwiftUI code; a build/run check from someone with the toolchain before merge would be genuinely valuable here, more so than on a narrow logic fix. --- bitchat/App/ConversationUIModel.swift | 9 ++ bitchat/Views/ForwardMessageSheet.swift | 136 ++++++++++++++++++++++++ bitchat/Views/MessageListView.swift | 20 ++++ 3 files changed, 165 insertions(+) create mode 100644 bitchat/Views/ForwardMessageSheet.swift 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) + } + } + } } } }