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.
This commit is contained in:
krishrathi1 2026-08-16 13:34:21 +05:30
parent 9b84b36122
commit 72c21fb183
3 changed files with 165 additions and 0 deletions

View File

@ -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)")
}

View File

@ -0,0 +1,136 @@
//
// ForwardMessageSheet.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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()
}
}

View File

@ -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)
}
}
}
}
}
}