Files
flamenet-messenger-ios/Sources/Views/BuddyProfileView.swift
T
Paul HittandClaude Opus 5.5 a2177e1a97 Spanish (es) draft: string catalogs, every UI string made localizable
DRAFT, needs native review. Nothing in the es column ships until the owner
or a Localization Lab volunteer has read it.

- project.yml: developmentLanguage en, SWIFT_EMIT_LOC_STRINGS and
  LOCALIZATION_PREFERS_STRING_CATALOGS on; xcodegen adds es to knownRegions
  from the catalogs.
- Resources/Localizable.xcstrings: 1,366 keys from the compiler, 1,356 with
  Spanish (the rest are punctuation and placeholders, marked not
  translatable), 23 with plural variations. Every es value is
  "needs_review".
- Resources/InfoPlist.xcstrings: the four permission prompts in Spanish.
- String(localized:) on 729 changed lines across 115 files, so status, error and
  warning text held in a String reaches the catalog. Sentences built with +
  are one literal now. English output is unchanged.
- Integer identifiers (user ids, HTTP and CallKit codes, signals) are
  interpolated as String, because String(localized:) groups digits: a
  CallKit code 9999 read "9,999".
- SpanishCatalogTests: every key has Spanish, identical placeholders, the
  draft marker is present; short labels over 1.6x are listed, not failed.
- Scripts/check-spanish-catalog.py: every key the compiler extracts is in
  the catalog (xcodebuild does not add new keys by itself).
- Source-reading tests updated to the String(localized:) form.
- docs/i18n/glossary-es.md: the terms, matched to the /es/ site pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:11:29 -04:00

601 lines
24 KiB
Swift

import SwiftUI
/// Detail page for one buddy, pulls `/api/flamenet/v1/messenger/users` and
/// renders avatar, display name, slug, and the description field if account
/// returns one.
struct BuddyProfileView: View {
@EnvironmentObject private var environment: AppEnvironment
@Environment(\.dismiss) private var dismiss
let buddy: Buddy
/// Optional, and the rail hides the button when it is nil.
var onCall: (() -> Void)? = nil
@State private var isPinned = false
/// Bumped when a post-quantum pin lands, so the number is read again.
@State private var numberChecked = 0
@State private var isMuted = false
@State private var isBlocked = false
/// ios#14, set when the toggle is flipped ON, so the method can be asked
/// for before the record is written.
@State private var verifying = false
/// Why a block or unblock did not take.
@State private var blockFailure: String?
@State private var showingBlockConfirm = false
@State private var navigateToChat = false
@State private var isStartingChat = false
var body: some View {
ScrollView {
VStack(spacing: 22) {
// The kit's contact detail: a cover, a name plate, and a rail
// of actions floating at the seam between them.
coverAndRail
section(String(localized: "Account")) {
row(String(localized: "Username"), buddy.screenName ?? ", ")
row(String(localized: "User ID"), "\(buddy.id)")
row(String(localized: "Status"), buddy.presence.label)
}
encryptionSection
// The way back.
if let inbox = environment.e2eInbox {
section(String(localized: "Stories")) {
Toggle(
"Show \(buddy.displayName)'s stories",
isOn: Binding(
get: { !inbox.areStoriesMuted(for: buddy.id) },
set: { inbox.setStoriesMuted(!$0, for: buddy.id) }
)
)
.accessibilityIdentifier("profile.stories.toggle")
// Says what muting is NOT.
Text(
"Turning this off hides their stories on this phone. They aren't told, and their messages still arrive as usual."
)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
}
}
Button(role: .destructive) {
if isBlocked { unblock() } else { showingBlockConfirm = true }
} label: {
Label(
isBlocked ? "Unblock \(buddy.displayName)" : "Block \(buddy.displayName)",
systemImage: isBlocked ? "checkmark.shield" : "nosign"
)
.frame(maxWidth: .infinity, minHeight: 48)
}
.buttonStyle(.portal)
.tint(.red)
.confirmationDialog(
"Block \(buddy.displayName)?",
isPresented: $showingBlockConfirm,
titleVisibility: .visible
) {
Button("Block", role: .destructive) { block() }
Button("Cancel", role: .cancel) {}
}
// ios#14, the method is ASKED for, not assumed.
.confirmationDialog(
"How did you compare the number?",
isPresented: $verifying, titleVisibility: .visible
) {
ForEach(VerificationRecord.Method.allCases, id: \.rawValue) { method in
Button(method.label.prefix(1).uppercased() + method.label.dropFirst()) {
if let inbox = environment.e2eInbox {
inbox.setVerified(true, for: buddy.id, method: method)
}
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text(
"Both of you should be reading the same number. Comparing it in person or by scanning is stronger than reading it over a call, which could itself be intercepted.")
}
.confirmationDialog(
"Block \(buddy.displayName)?",
isPresented: $showingBlockConfirm,
titleVisibility: .visible
) {
Button("Block", role: .destructive) { block() }
Button("Cancel", role: .cancel) {}
} message: {
Text("They won't be able to send you messages while blocked.")
}
// Right under the button that failed, not somewhere else on the
// screen: this contradicts what the button appears to say.
if let failure = blockFailure {
Label(failure, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(Theme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 4)
.accessibilityIdentifier("profile.blockFailed")
}
Spacer(minLength: 30)
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
.background(Theme.background)
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(isPresented: $navigateToChat) {
ChatScreen(buddy: buddy)
}
.toolbar {
PortalToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
PortalToolbarItem(placement: .topBarTrailing) {
if let url = shareURL {
ShareLink(item: url) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
.onAppear {
isPinned = BuddyPrefs.isPinned(buddyID: buddy.id)
isMuted = NotificationManager.shared.isMuted(buddyID: buddy.id)
}
.task { await refreshBlocked() }
}
private func refreshBlocked() async {
// This device's list first (section 8.9d), then the relay's, which
// may still hold a block made on a device that predates 0.32.
if environment.e2eInbox?.isBlocked(buddy.id) == true {
isBlocked = true
return
}
guard let api = environment.api else { return }
if let list = try? await api.fetchBlocked() {
isBlocked = list.contains { $0.id == buddy.id }
}
}
private func block() {
guard environment.api != nil else { return }
Task {
do {
try await environment.setBlocked(true, userID: buddy.id)
blockFailure = nil
isBlocked = true
} catch {
// Do NOT flip the toggle.
blockFailure = String(
localized: "Couldn't block \(buddy.displayName). They have NOT been blocked, try again.")
}
}
}
private func unblock() {
guard environment.api != nil else { return }
Task {
do {
try await environment.setBlocked(false, userID: buddy.id)
blockFailure = nil
isBlocked = false
} catch {
blockFailure = String(
localized: "Couldn't unblock \(buddy.displayName). They are still blocked, try again.")
}
}
}
// MARK: - Sections
/// Cover, name plate and the action rail, as one unit.
private var coverAndRail: some View {
ZStack(alignment: .topTrailing) {
VStack(spacing: 0) {
// The cover.
ZStack {
Rectangle()
.fill(Theme.primary.opacity(0.12))
Text(initials)
.font(.system(size: 64, weight: .bold))
.foregroundStyle(Theme.primary)
}
.frame(height: 220)
// Full bleed.
.padding(.horizontal, -16)
// Name plate: 60 tall, radius 16, on the kit's neutral fill,
// riding the join between cover and sheet.
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(buddy.displayName)
.font(.system(size: 20, weight: .bold))
.foregroundStyle(Theme.ink)
.lineLimit(1)
// Presence goes on THIS line, not on the trailing edge.
HStack(spacing: 6) {
if buddy.presence != .offline {
PresenceDot(presence: buddy.presence, size: 8)
}
Text(handleAndPresence)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
}
}
// Reserve the rail's width so a long name does not run
// under it.
Spacer(minLength: 60 + Theme.Space.md)
}
.padding(.horizontal, Theme.Kit.margin)
.frame(height: 60)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: Theme.Radius.medium, style: .continuous)
.fill(Theme.pillFill)
)
}
actionRail
.padding(.trailing, Theme.Kit.margin)
.padding(.top, 140)
}
}
/// Four actions in one vertical pill, divided by white hairlines, the kit's
/// 60x240 rail at radius 30.
private var actionRail: some View {
VStack(spacing: 0) {
railButton("bubble.left.fill", String(localized: "Message"), id: "profile.message") {
startChat()
}
if let onCall {
railDivider
railButton("phone.fill", String(localized: "Call"), id: "profile.call", action: onCall)
}
railDivider
railButton(
isMuted ? "bell.slash.fill" : "bell.fill",
isMuted ? String(localized: "Unmute") : String(localized: "Mute"), id: "profile.mute"
) {
isMuted.toggle()
NotificationManager.shared.setMuted(isMuted, buddyID: buddy.id)
}
}
.frame(width: 60)
.background(
RoundedRectangle(cornerRadius: Theme.Radius.small, style: .continuous)
.fill(isBlocked ? Theme.secondaryText : Theme.primary)
)
.opacity(isBlocked ? 0.5 : 1)
.allowsHitTesting(!isBlocked)
}
private var railDivider: some View {
Rectangle().fill(Color.white.opacity(0.5)).frame(height: 1)
}
private func railButton(
_ symbol: String, _ label: String, id: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.white)
.frame(width: 60, height: 60)
}
.buttonStyle(.plain)
.accessibilityLabel(label)
.accessibilityIdentifier(id)
}
private var handleAndPresence: String {
let handle = (buddy.screenName?.isEmpty == false) ? "@\(buddy.screenName!)" : nil
return [handle, buddy.presence.label].compactMap { $0 }.joined(separator: " · ")
}
private var header: some View {
VStack(spacing: 8) {
ZStack(alignment: .bottomTrailing) {
RoundedRectangle(cornerRadius: Theme.Radius.small)
.fill(Theme.avatarFill)
.frame(width: 96, height: 96)
.overlay(
Text(initials)
.font(.title.bold())
.foregroundStyle(Theme.primary)
)
if buddy.presence != .offline {
PresenceDot(
presence: buddy.presence, size: 22,
ring: Color(.systemBackground), ringWidth: 3
)
.offset(x: 2, y: 2)
}
}
Text(buddy.displayName)
.font(.title3.bold())
if let slug = buddy.screenName, !slug.isEmpty {
Text("@\(slug)").font(.subheadline).foregroundStyle(.secondary)
}
}
}
/// Primary action: open the conversation.
private var sendMessageButton: some View {
Button {
startChat()
} label: {
HStack(spacing: 8) {
if isStartingChat {
ProgressView().tint(.white)
} else {
Image(systemName: "paperplane.fill")
}
Text("Send message")
.font(.body.bold())
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity, minHeight: 48)
.background(
RoundedRectangle(cornerRadius: Theme.Radius.small, style: .continuous)
.fill(Theme.primary)
)
}
.buttonStyle(.plain)
.disabled(isStartingChat || isBlocked)
.opacity(isBlocked ? 0.5 : 1)
}
private func startChat() {
guard !isStartingChat else { return }
navigateToChat = true
}
private var quickActions: some View {
HStack(spacing: 12) {
actionButton(
title: isPinned ? String(localized: "Pinned") : String(localized: "Pin"),
systemImage: isPinned ? "pin.fill" : "pin",
active: isPinned
) {
isPinned.toggle()
BuddyPrefs.setPinned(isPinned, buddyID: buddy.id)
}
actionButton(
title: isMuted ? String(localized: "Muted") : String(localized: "Mute"),
systemImage: isMuted ? "bell.slash.fill" : "bell",
active: isMuted
) {
isMuted.toggle()
NotificationManager.shared.setMuted(isMuted, buddyID: buddy.id)
}
}
}
private func actionButton(
title: String, systemImage: String, active: Bool, action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 4) {
Image(systemName: systemImage)
.font(.title3)
Text(title).font(.caption)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.foregroundStyle(active ? .white : Theme.primary)
.background(
RoundedRectangle(cornerRadius: Theme.Radius.small, style: .continuous)
.fill(active ? Theme.primary : Theme.incomingBubble)
)
}
.buttonStyle(.plain)
}
/// Safety number + verification state.
@ViewBuilder
private var encryptionSection: some View {
if let inbox = environment.e2eInbox {
let state = inbox.trustState(for: buddy.id)
section(String(localized: "Encryption")) {
switch state {
case .unknown:
Text("No encrypted session yet. Send a message and their keys will be pinned.")
.font(.footnote)
.foregroundStyle(.secondary)
case .changed:
Label("Keys changed, not verified", systemImage: "exclamationmark.shield.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
Text(
"Compare the number below in person or over a call you trust. If it matches, accept the new keys. If it doesn't, stop messaging them here."
)
.font(.footnote)
.foregroundStyle(.secondary)
// And the button, here, rather than a sentence sending
// somebody somewhere else to find it.
//
// This screen is where a person investigating a key change
// ends up: it is the screen with the safety number on it,
// which is the thing they were told to compare. It then
// told them to go and accept "in the chat", and the only
// accept in the app was a banner in the conversation.
// Reading the warning and being unable to act on it is how
// somebody decides the app is broken, or worse, ignores it.
Button("Accept new keys") {
Task { await inbox.acceptIdentityChange(for: buddy.id) }
}
.buttonStyle(.portalProminent)
.accessibilityIdentifier("profile.accept-keys")
// ios#35, an addition, not a substitution.
case .deviceAdded:
Label("They added a device", systemImage: "iphone.badge.plus")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
Text(
"""
Their existing keys are unchanged, so this is what buying a \
second phone looks like. It is also what somebody adding a \
device to their account looks like, nothing breaks either \
way. Ask them whether they did, and compare the number \
below if you want to be sure.
"""
)
.font(.footnote)
.foregroundStyle(.secondary)
// A phone signed out. Said plainly, and not in orange: nothing
// is wrong and there is nothing to do about it.
case .deviceRemoved:
Label("They signed a device out", systemImage: "iphone.slash")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Text(
"""
One of their phones is no longer on this account. The keys \
that are left are the same ones you already had, so \
nothing about your conversation changes and there is \
nothing for you to accept.
"""
)
.font(.footnote)
.foregroundStyle(.secondary)
case .pinned, .verified:
Label(
state == .verified ? "Verified" : "Not verified",
systemImage: state == .verified ? "checkmark.shield.fill" : "shield"
)
.font(.subheadline.weight(.semibold))
.foregroundStyle(state == .verified ? .green : .secondary)
}
if let number = inbox.safetyNumber(for: buddy.id), numberChecked >= 0 {
VStack(alignment: .leading, spacing: 4) {
ForEach(SafetyNumber.displayRows(number), id: \.self) { line in
Text(line)
.font(.system(.body, design: .monospaced))
}
}
.padding(.vertical, 6)
.accessibilityLabel("Safety number \(number)")
// SPEC §7: read their bundle once if this device cannot
// cover their post-quantum identity yet, so the digits
// match the ones on their screen.
.task(id: buddy.id) {
if await inbox.pinPeerPQIdentityIfNeeded(buddy.id) { numberChecked += 1 }
}
Text(
"Read this aloud with \(buddy.displayName). Both devices must show the same 60 digits."
)
.font(.footnote)
.foregroundStyle(.secondary)
// Which guarantee these digits actually cover (audit H3).
if inbox.safetyNumberIsPostQuantum(for: buddy.id) {
Label(
"Covers their post-quantum identity too",
systemImage: "checkmark.shield"
)
.font(.caption)
.foregroundStyle(.green)
} else {
Label(
"Covers their classical key only, one of you has no post-quantum identity yet",
systemImage: "shield.lefthalf.filled"
)
.font(.caption)
.foregroundStyle(.secondary)
}
// ios#14, what was checked, when and how.
if let record = inbox.verification(for: buddy.id) {
Text(record.summary())
.font(.footnote)
.foregroundStyle(record.isCurrent ? Color.secondary : Color.orange)
.accessibilityIdentifier("profile.verificationRecord")
}
if state != .changed {
Toggle(
"Mark as verified",
isOn: Binding(
get: { state == .verified },
set: { verifying = $0 }
)
)
.font(.subheadline)
}
}
}
}
}
private func section<Content: View>(_ title: String, @ViewBuilder content: () -> Content)
-> some View
{
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.caption.bold())
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(
RoundedRectangle(cornerRadius: Theme.Radius.small, style: .continuous)
.fill(Theme.incomingBubble)
)
}
}
private func row(_ label: String, _ value: String) -> some View {
HStack {
Text(label).foregroundStyle(.secondary)
Spacer()
Text(value).foregroundStyle(.primary)
}
.font(.subheadline)
}
// MARK: - Fetch
private var initials: String {
let parts = buddy.displayName.split(separator: " ")
return String(parts.prefix(2).compactMap(\.first)).uppercased()
}
/// `<site>/author/<slug>`, the canonical account author page.
private var shareURL: URL? {
guard
let baseURL = environment.client?.baseURL,
let slug = buddy.screenName
else { return nil }
return
baseURL
.appendingPathComponent("author")
.appendingPathComponent(slug)
}
// There is deliberately no profile fetch here.
}
/// One member, as the relay's username lookup returns them.
struct BuddyProfile: Decodable {
let id: Int
let name: String
let slug: String
}
#if DEBUG
#Preview {
NavigationStack {
BuddyProfileView(buddy: Buddy.sample[0])
.environmentObject(AppEnvironment())
}
}
#endif