Favorites consent, visible mutuality, and honest copy

The star read like a private bookmark but behaved like a friend
request plus an identity handshake: tapping it immediately notified
the peer ("alice favorited you") and transmitted the durable Nostr
public key — while the tapper saw nothing. And the load-bearing state,
mutuality (one-sided favorites do NOT enable offline delivery), was
invisible at the point of decision.

- One-time consent: the first favorite ever asks, naming the person
  and disclosing the notification + key sharing (both star surfaces:
  peer list and DM header). Acknowledged once; reset by panic wipe.
  /fav (an explicit typed command) proceeds without a dialog.
- Mutuality visible where the decision happens: half star until
  reciprocated, filled star when mutual, with tooltips and VoiceOver
  state labels for both. The DM header state now carries
  isMutualFavorite.
- FEATURES copy described favorites as notifications; it now leads
  with the actual mechanism (offline messaging via nostr when mutual).
- Geohash block copy no longer implies a global block: identities are
  derived per-geohash, so the same person appears as someone new in
  other channels — the message says so now (both the context-menu and
  /block paths).

7 new strings + 3 updated values, all 30 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-11 09:41:50 +02:00
parent a05c67252f
commit d4d78e85eb
9 changed files with 1542 additions and 97 deletions

View File

@ -105,6 +105,9 @@ struct PrivateConversationHeaderState: Equatable {
let displayName: String
let availability: PrivateConversationAvailability
let isFavorite: Bool
/// Whether the favorite is reciprocated the load-bearing state:
/// one-sided favorites do NOT enable offline delivery.
let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus?
var supportsFavoriteToggle: Bool {
@ -258,6 +261,7 @@ final class PrivateConversationModel: ObservableObject {
displayName: displayName,
availability: .meshReachable,
isFavorite: false,
isMutualFavorite: false,
encryptionStatus: nil
)
}
@ -282,6 +286,7 @@ final class PrivateConversationModel: ObservableObject {
displayName: displayName,
availability: availability,
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
isMutualFavorite: peer?.isMutualFavorite ?? false,
encryptionStatus: encryptionStatus
)
}

File diff suppressed because it is too large Load Diff

View File

@ -368,7 +368,7 @@ final class CommandProcessor {
return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname))
}
identityManager.setNostrBlocked(pub, isBlocked: true)
return .success(message: String(format: String(localized: "command.block.done_geo", defaultValue: "blocked %@ in geohash chats", comment: "Confirmation after blocking a geohash participant"), locale: .current, nickname))
return .success(message: String(format: String(localized: "command.block.done_geo", defaultValue: "blocked %@ in this geohash channel — in other geohash channels they appear as someone new, so block them there too if needed", comment: "Confirmation after blocking a geohash participant; says the block is per-channel because identities differ per geohash"), locale: .current, nickname))
}
return .error(message: String(format: String(localized: "command.block.failed", defaultValue: "cannot block %@: not found or unable to verify identity", comment: "Error when /block can't resolve or verify the target"), locale: .current, nickname))

View File

@ -0,0 +1,38 @@
//
// FavoriteConsent.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// One-time acknowledgement before the first favorite.
///
/// The star reads like a private bookmark but behaves like a friend request
/// plus an identity handshake: it notifies the other person immediately
/// ("alice favorited you") and transmits your durable Nostr public key so
/// mutual favorites can message over the internet. Disclosing a durable
/// identifier must not happen from a tap that looks local the first star
/// asks once, then never again.
enum FavoriteConsent {
private static let acknowledgedKey = "favorites.consentAcknowledged"
static var isAcknowledged: Bool {
isAcknowledged(in: .standard)
}
static func isAcknowledged(in defaults: UserDefaults) -> Bool {
defaults.bool(forKey: acknowledgedKey)
}
static func acknowledge(in defaults: UserDefaults = .standard) {
defaults.set(true, forKey: acknowledgedKey)
}
/// Panic-wipe hook: a wiped device asks again.
static func reset(in defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: acknowledgedKey)
}
}

View File

@ -227,7 +227,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
String(
format: String(
localized: "system.geohash.blocked",
comment: "System message shown when a user is blocked in geohash chats"
defaultValue: "blocked %@ in this geohash channel — in other geohash channels they appear as someone new, so block them there too if needed",
comment: "System message after blocking a geohash participant; says the block is per-channel because identities differ per geohash"
),
locale: .current,
displayName

View File

@ -1647,6 +1647,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
MeshEchoSettings.reset()
NotificationPrivacySettings.reset()
ReadReceiptSettings.reset()
FavoriteConsent.reset()
// A hand-added relay names an operator someone chose to route through,
// which is the kind of trace a wipe should not leave behind.
NostrRelaySettings.reset()

View File

@ -463,6 +463,9 @@ private extension ContentPeopleListView {
private struct ContentPrivateChatSheetView: View {
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
/// First-ever favorite waiting on the one-time consent dialog.
@State private var showFavoriteConsent = false
@Binding var showSidebar: Bool
@Binding var messageText: String
@Binding var selectedMessageSender: String?
@ -517,9 +520,19 @@ private struct ContentPrivateChatSheetView: View {
if headerState.supportsFavoriteToggle {
Button(action: {
privateConversationModel.toggleFavoriteForSelectedConversation()
// The first favorite ever asks once: the star
// notifies the peer and shares the nostr key.
if !headerState.isFavorite, !FavoriteConsent.isAcknowledged {
showFavoriteConsent = true
} else {
privateConversationModel.toggleFavoriteForSelectedConversation()
}
}) {
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
// Half star until reciprocated: one-sided
// favorites don't enable offline delivery.
Image(systemName: headerState.isFavorite
? (headerState.isMutualFavorite ? "star.fill" : "star.leadinghalf.filled")
: "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
// Same visual box + 44pt hit target as SheetCloseButton.
@ -532,6 +545,25 @@ private struct ContentPrivateChatSheetView: View {
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
)
.confirmationDialog(
Text(String(localized: "favorites.consent.title", defaultValue: "add favorite?", comment: "Title of the one-time confirmation before the first favorite")),
isPresented: $showFavoriteConsent,
titleVisibility: .visible
) {
Button(String(localized: "favorites.consent.confirm", defaultValue: "add favorite", comment: "Confirm button of the one-time favorite consent dialog")) {
FavoriteConsent.acknowledge()
privateConversationModel.toggleFavoriteForSelectedConversation()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text(
String(
format: String(localized: "favorites.consent.message", defaultValue: "this tells %@ right away and shares your nostr key with them. if they favorite you back, you can message each other over the internet when out of mesh range.", comment: "Body of the one-time favorite consent dialog; placeholder is the person's name"),
locale: .current,
headerState.displayName
)
)
}
}
}
.frame(maxWidth: .infinity)

View File

@ -13,6 +13,8 @@ struct MeshPeerList: View {
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
/// First-ever favorite waiting on the one-time consent dialog.
@State private var pendingFavorite: MeshPeerRow?
private enum Strings {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
@ -26,6 +28,19 @@ struct MeshPeerList: View {
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified")
static let favoriteMutual = String(localized: "mesh_peers.state.favorite_mutual", defaultValue: "mutual favorite", comment: "State label for a reciprocated favorite")
static let favoritePending = String(localized: "mesh_peers.state.favorite_pending", defaultValue: "favorited, not mutual yet", comment: "State label for a favorite the peer has not reciprocated")
static let favoriteMutualTooltip = String(localized: "mesh_peers.tooltip.favorite_mutual", defaultValue: "mutual favorite — offline messages via nostr work both ways", comment: "Tooltip for the filled star on a reciprocated favorite")
static let favoritePendingTooltip = String(localized: "mesh_peers.tooltip.favorite_pending", defaultValue: "favorited — offline messaging starts when they favorite you back", comment: "Tooltip for the half star on an unreciprocated favorite")
static let consentTitle = String(localized: "favorites.consent.title", defaultValue: "add favorite?", comment: "Title of the one-time confirmation before the first favorite")
static let consentConfirm = String(localized: "favorites.consent.confirm", defaultValue: "add favorite", comment: "Confirm button of the one-time favorite consent dialog")
static func consentMessage(_ name: String) -> String {
String(
format: String(localized: "favorites.consent.message", defaultValue: "this tells %@ right away and shares your nostr key with them. if they favorite you back, you can message each other over the internet when out of mesh range.", comment: "Body of the one-time favorite consent dialog; placeholder is the person's name"),
locale: .current,
name
)
}
static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer")
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
@ -43,6 +58,7 @@ struct MeshPeerList: View {
peerListModel.meshRows.first(where: { $0.id == id })
}
Group {
if peerListModel.meshRows.isEmpty {
// Match the section's row rhythm (same size, indent, and vertical
// padding as a peer row) so the empty state reads as the list's
@ -164,8 +180,22 @@ struct MeshPeerList: View {
}
if !isMe {
Button(action: { onToggleFavorite(peer.peerID) }) {
Image(systemName: peer.isFavorite ? "star.fill" : "star")
Button(action: {
// The first favorite ever asks once: the star
// notifies the peer and shares the nostr key.
if !peer.isFavorite, !FavoriteConsent.isAcknowledged {
pendingFavorite = peer
} else {
onToggleFavorite(peer.peerID)
}
}) {
// Mutuality is the load-bearing state (one-sided
// favorites don't enable offline delivery), so it
// shows at the point of decision: half star until
// reciprocated.
Image(systemName: peer.isFavorite
? (peer.isMutualFavorite ? "star.fill" : "star.leadinghalf.filled")
: "star")
.font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
// Widen the tap target beyond the bare glyph;
@ -175,6 +205,7 @@ struct MeshPeerList: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(peer.isMutualFavorite ? Strings.favoriteMutualTooltip : Strings.favoritePendingTooltip)
}
}
.padding(.horizontal)
@ -240,6 +271,24 @@ struct MeshPeerList: View {
if newOrder != orderedIDs { orderedIDs = newOrder }
}
}
}
.confirmationDialog(
Text(verbatim: Strings.consentTitle),
isPresented: Binding(
get: { pendingFavorite != nil },
set: { if !$0 { pendingFavorite = nil } }
),
titleVisibility: .visible,
presenting: pendingFavorite
) { peer in
Button(Strings.consentConfirm) {
FavoriteConsent.acknowledge()
onToggleFavorite(peer.peerID)
}
Button("common.cancel", role: .cancel) {}
} message: { peer in
Text(verbatim: Strings.consentMessage(peer.displayName))
}
}
/// One spoken sentence per row: name, how they're reachable, and any
@ -258,7 +307,9 @@ struct MeshPeerList: View {
}
}
if peer.showsVouchedBadge { parts.append(Strings.vouched) }
if peer.isFavorite { parts.append(Strings.favorite) }
if peer.isFavorite {
parts.append(peer.isMutualFavorite ? Strings.favoriteMutual : Strings.favoritePending)
}
if peer.hasUnread { parts.append(Strings.unread) }
if peer.isBlocked { parts.append(Strings.blocked) }
return parts.joined(separator: ", ")

View File

@ -468,6 +468,21 @@ struct ChatViewModelServiceLifecycleTests {
#expect(viewModel.sentReadReceipts.contains("read-2") || viewModel.privateChatManager.sentReadReceipts.contains("read-2"))
}
@Test @MainActor
func favoriteConsentDefaultsToUnacknowledgedAndResets() {
let suite = "FavoriteConsentTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
// The first star must ask: it notifies the peer and shares the
// nostr key, and a wiped device must ask again.
#expect(!FavoriteConsent.isAcknowledged(in: defaults))
FavoriteConsent.acknowledge(in: defaults)
#expect(FavoriteConsent.isAcknowledged(in: defaults))
FavoriteConsent.reset(in: defaults)
#expect(!FavoriteConsent.isAcknowledged(in: defaults))
}
@Test @MainActor
func readReceiptSettingDefaultsToOnAndResets() {
let suite = "ReadReceiptSettingsTests.\(UUID().uuidString)"