mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:05:19 +00:00
* Make peer lists accessible and actionable; block by stable identity Who you can reach — the app's most important fact — was encoded in unlabeled 10pt icons with macOS-only tooltips, and the mesh list had no actions (block/favorite/verify were slash-command-only). - Both peer lists become real accessibility citizens: each row is one element announcing name, reachability, and favorite/unread/blocked state, with a button trait and custom actions for the gesture-only interactions. Neither file previously had a single accessibility modifier. Reachability icons gain tooltips reusing existing strings; teleported vs in-area pins are explained. - Mesh rows gain the context menu the geohash list already had: direct message, favorite, show fingerprint, block/unblock. The fingerprint double-tap, previously shadowed by the single tap, is reordered so it fires. - The DM header's offline state (previously EmptyView — absence of a glyph as the only signal) becomes a dimmed "offline" tag, and a geohash DM — always Nostr-routed — no longer mislabels itself "offline". - App Info gains a SYMBOLS legend defining every glyph the lists and headers use; nothing defined them before. - Mesh block/unblock now resolve by the peer's stable Noise identity instead of a `/block <displayName>` string, so the exact tapped row is affected and offline peers can be unblocked (with covering tests). New strings are added source-language (en) only. * Surface block/unblock feedback in the conversation where it was triggered setMeshPeerBlocked silently returned when the peer's identity could not be resolved (e.g. long-press-blocking an old public message from a sender who left and was never a favorite), where the /block command printed "cannot block X: not found or unable to verify identity" — post that same message from the guard branch. Both the failure and confirmation messages now route through addCommandOutput instead of addSystemMessage, so blocking from inside a private chat prints into that chat rather than invisibly into the public timeline (same routing #1363 applied to command output). The confirmation also reuses the /block wording ("blocked X. you will no longer receive messages from them") for parity with the command. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove dead accessibility label and unreachable /unblock fallback The favorite button's .accessibilityLabel in MeshPeerList is unreachable: the row-level .accessibilityElement(children: .ignore) swallows child elements, and the row's custom accessibility action already covers favoriting. ConversationUIModel.unblock is only called from the mesh peer list with a non-optional mesh peerID, so the "/unblock <name>" fallback branch could never run — take PeerID directly and drop the branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
208 lines
7.3 KiB
Swift
208 lines
7.3 KiB
Swift
import BitFoundation
|
|
import Combine
|
|
import SwiftUI
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
@MainActor
|
|
final class ConversationUIModel: ObservableObject {
|
|
@Published private(set) var showAutocomplete = false
|
|
@Published private(set) var autocompleteSuggestions: [String] = []
|
|
@Published private(set) var currentNickname: String
|
|
@Published private(set) var isBatchingPublic = false
|
|
@Published private(set) var canSendMediaInCurrentContext = true
|
|
|
|
private let chatViewModel: ChatViewModel
|
|
private let privateConversationModel: PrivateConversationModel
|
|
private let conversations: ConversationStore
|
|
private var activeChannel: ChannelID
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(
|
|
chatViewModel: ChatViewModel,
|
|
privateConversationModel: PrivateConversationModel,
|
|
conversations: ConversationStore
|
|
) {
|
|
self.chatViewModel = chatViewModel
|
|
self.privateConversationModel = privateConversationModel
|
|
self.conversations = conversations
|
|
self.activeChannel = conversations.activeChannel
|
|
self.currentNickname = chatViewModel.nickname
|
|
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
|
self.showAutocomplete = chatViewModel.showAutocomplete
|
|
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
|
|
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
|
|
|
|
bind()
|
|
}
|
|
|
|
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
|
|
chatViewModel.currentColorScheme = colorScheme
|
|
}
|
|
|
|
func setCurrentTheme(_ theme: AppTheme) {
|
|
chatViewModel.currentTheme = theme
|
|
}
|
|
|
|
func sendMessage(_ message: String) {
|
|
chatViewModel.sendMessage(message)
|
|
}
|
|
|
|
/// Resends a failed private message through the normal send path,
|
|
/// removing the failed original so the re-submission replaces it
|
|
/// instead of stacking a duplicate under the red bubble.
|
|
func resendFailedPrivateMessage(_ message: BitchatMessage) {
|
|
chatViewModel.removePrivateMessage(withID: message.id)
|
|
chatViewModel.sendMessage(message.content)
|
|
}
|
|
|
|
func clearCurrentConversation() {
|
|
chatViewModel.sendMessage("/clear")
|
|
}
|
|
|
|
func sendHug(to sender: String) {
|
|
chatViewModel.sendMessage("/hug @\(sender)")
|
|
}
|
|
|
|
func sendSlap(to sender: String) {
|
|
chatViewModel.sendMessage("/slap @\(sender)")
|
|
}
|
|
|
|
func block(peerID: PeerID?, displayName: String?) {
|
|
guard let displayName else { return }
|
|
|
|
if let peerID, peerID.isGeoChat,
|
|
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
|
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
|
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
|
|
// Mesh: block the peer's stable Noise identity resolved from the
|
|
// tapped peerID rather than re-resolving a display-name string.
|
|
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
|
|
} else {
|
|
chatViewModel.sendMessage("/block \(displayName)")
|
|
}
|
|
}
|
|
|
|
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
|
|
/// the tapped peer's stable identity so the exact row is unblocked — this
|
|
/// also works for offline peers, which the `/unblock <displayName>` command
|
|
/// cannot resolve.
|
|
func unblock(peerID: PeerID, displayName: String) {
|
|
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
|
|
}
|
|
|
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
|
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
|
}
|
|
|
|
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
|
chatViewModel.completeNickname(nickname, in: &text)
|
|
}
|
|
|
|
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
|
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
|
|
}
|
|
|
|
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
|
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
|
|
}
|
|
|
|
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
|
|
message.mediaAttachment(for: currentNickname)
|
|
}
|
|
|
|
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
|
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
|
|
}
|
|
|
|
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
|
|
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
|
|
}
|
|
|
|
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
|
|
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
|
|
}
|
|
|
|
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
|
|
if peerID.isGeoDM || peerID.isGeoChat {
|
|
return chatViewModel.geohashDisplayName(for: peerID)
|
|
}
|
|
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
|
|
return nickname
|
|
}
|
|
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
|
}
|
|
|
|
#if os(iOS)
|
|
func processSelectedImage(_ image: UIImage?) {
|
|
chatViewModel.processThenSendImage(image)
|
|
}
|
|
#endif
|
|
|
|
func processSelectedImage(from url: URL?) {
|
|
#if os(macOS)
|
|
chatViewModel.processThenSendImage(from: url)
|
|
#endif
|
|
}
|
|
|
|
func sendVoiceNote(at url: URL) {
|
|
chatViewModel.sendVoiceNote(at: url)
|
|
}
|
|
|
|
func cancelMediaSend(messageID: String) {
|
|
chatViewModel.cancelMediaSend(messageID: messageID)
|
|
}
|
|
|
|
func deleteMediaMessage(messageID: String) {
|
|
chatViewModel.deleteMediaMessage(messageID: messageID)
|
|
}
|
|
|
|
private func bind() {
|
|
chatViewModel.$nickname
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$currentNickname)
|
|
|
|
chatViewModel.$showAutocomplete
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$showAutocomplete)
|
|
|
|
chatViewModel.$autocompleteSuggestions
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$autocompleteSuggestions)
|
|
|
|
chatViewModel.$isBatchingPublic
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$isBatchingPublic)
|
|
|
|
conversations.$activeChannel
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] channel in
|
|
self?.activeChannel = channel
|
|
self?.refreshComputedState()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
privateConversationModel.$selectedPeerID
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refreshComputedState()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func refreshComputedState() {
|
|
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
|
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
|
return
|
|
}
|
|
|
|
switch activeChannel {
|
|
case .mesh:
|
|
canSendMediaInCurrentContext = true
|
|
case .location:
|
|
canSendMediaInCurrentContext = false
|
|
}
|
|
}
|
|
}
|