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>
601 lines
24 KiB
Swift
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
|