mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
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:
parent
a05c67252f
commit
d4d78e85eb
@ -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
@ -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))
|
||||
|
||||
38
bitchat/Services/FavoriteConsent.swift
Normal file
38
bitchat/Services/FavoriteConsent.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: ", ")
|
||||
|
||||
@ -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)"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user