Merge 6672f364b0bd6cb03c211195cce2483a35c4298d into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
krish rathi 2026-08-29 11:16:57 +00:00 committed by GitHub
commit 8cd30351f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 72805 additions and 71091 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

@ -65,6 +65,8 @@ final class PeerListModel: ObservableObject {
@Published private(set) var visibleGeohashPeerCount = 0
@Published private(set) var renderID = ""
var activeChannel: ChannelID { conversations.activeChannel }
private let chatViewModel: ChatViewModel
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,151 @@
//
// 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")
static let accessibilityHint = String(localized: "forward.accessibility.forward_hint", defaultValue: "forwards message to this conversation", comment: "Accessibility hint for selecting a target to forward a message to")
}
private var meshTargets: [MeshPeerRow] {
peerListModel.meshRows.filter { !$0.isMe && !$0.isBlocked }
}
private var filteredRecentChatRows: [RecentChatRow] {
peerListModel.recentChatRows.filter { row in
if case .location = peerListModel.activeChannel {
return true
} else {
return !row.peerID.isGeoDM
}
}
}
private var hasAnyTarget: Bool {
!meshTargets.isEmpty || !filteredRecentChatRows.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: filteredRecentChatRows,
accessibilityHint: Strings.accessibilityHint
) { 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)
.accessibilityHint(Strings.accessibilityHint)
}
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,17 @@ struct MessageListView: View {
conversationUIModel.resendFailedPrivateMessage(message)
}
}
if conversationUIModel.mediaAttachment(for: message) == nil {
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 +285,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)
}
}
}
}
}
}

View File

@ -18,6 +18,7 @@ struct RecentChatList: View {
@ThemedPalette private var palette
let chats: [RecentChatRow]
var accessibilityHint: String? = nil
let onTapChat: (PeerID) -> Void
private enum Strings {
@ -72,7 +73,7 @@ struct RecentChatList: View {
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: chat))
.accessibilityAddTraits(.isButton)
.accessibilityHint(Strings.openChatHint)
.accessibilityHint(accessibilityHint ?? Strings.openChatHint)
}
}
}