Files
bitchat/bitchat/Views/FingerprintView.swift
T
d285c6ad53 ux-fixes: lock alignment, caption band, wrapping, tap targets, VoiceOver, theme sweep (#1366)
* Fix lock glyph alignment, privacy-caption band, and empty-state wrapping

- Message-row locks: align to first text baseline instead of a hardcoded
  top padding that left the lock ~4pt below the line's visual center
- Header/caption locks: 1pt optical lift (lock.fill ink is bottom-heavy;
  geometric centering reads low); seal badge stays untouched
- DM privacy caption: sit on the themed surface like the rest of the
  bottom chrome instead of painting its own orange band
- Empty-state lines: non-breaking spaces so the closing * can't orphan
  and 'bitchat/ for help' can't break right after the slash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Unify sheet close buttons, widen tiny tap targets, handle long nicknames

- New SheetCloseButton component: one glyph size/weight (13 semibold),
  32pt visual box, 44pt hit target; adopted by all 7 sheets (sizes had
  drifted across 12/13/14pt, two had no frame at all)
- Favorite star buttons get real tap targets (peer list + DM header)
- DM header nickname: single line with middle truncation instead of
  wrapping into the fixed-height header; peer-list names truncate tail
- Geohash people rows: leading glyph 12 -> 10 to match mesh rows
- Sidebar lock glyphs get the same optical lift as the DM header

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make channel switching, voice notes, and header actions work under VoiceOver

- Channel rows in the location sheet are now single activatable buttons
  (label + selected trait + switch hint) with the bookmark toggle
  mirrored as a named accessibility action; bookmark buttons labeled
- Voice-note mic: press-and-hold drag gestures can't be activated by
  VoiceOver, so the default accessibility action now toggles
  start/stop-and-send; announces 'recording' state; localized labels
- Attachment button: camera (long-press) path exposed as a named
  action; labels localized instead of hardcoded English
- People-count button announces connected vs no-one-reachable (was
  color-only); verification QR button gains a spoken name (.help is
  only a hint on iOS); bitchat/ logo exposes its tap-for-app-info as a
  button (panic triple-tap stays undiscoverable on purpose)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Theme-correctness sweep: palette colors everywhere, AX-size header growth

- Fingerprint/verification sheet cards: palette-tinted boxes instead of
  fixed gray bands that ignored matrix green and occluded glass
- Voice-note card: palette background (translucent) instead of opaque
  white/black; waveform + payment chips + image placeholder follow suit
- .secondary/.primary/Color.blue swapped for palette.secondary/primary/
  accentBlue across location sheets, people sheets, message captions,
  and the header count (system gray read wrong under matrix green)
- Autocomplete/command rows: dropped the uniform gray wash that dulled
  the themed overlay panel
- 'tap to reveal' caption follows the theme font instead of hardcoding
  monospaced
- Headers use minHeight so two-line accessibility text sizes grow the
  bar instead of clipping inside it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix main header expanding to fill the screen

The header bar's fixed height was load-bearing: its children fill the
bar with .frame(maxHeight: .infinity) tap targets, so switching to an
open-ended minHeight let the header expand to swallow all available
vertical space, centering the title mid-screen and crushing the
timeline into the composer. Restore the fixed height — headerHeight is
a @ScaledMetric, so it already grows with Dynamic Type. Reproduced and
verified both layouts with an offscreen render harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* DM header: floating glass panel instead of muddy orange wash under glass

Orange at 14% over the backdrop gradient reads as a gray-beige band,
not a privacy signature. Under liquid glass the DM header now uses the
same floating chrome panel as the main header; the private signature is
already carried by the orange lock, caption, and composer accents.
Matrix keeps its orange wash over the opaque themed surface, unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:22:38 +02:00

236 lines
10 KiB
Swift

//
// FingerprintView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
import BitFoundation
struct FingerprintView: View {
@EnvironmentObject private var verificationModel: VerificationModel
let peerID: PeerID
@Environment(\.dismiss) var dismiss
@ThemedPalette private var palette
private var textColor: Color { palette.primary }
private var backgroundColor: Color { palette.background }
private enum Strings {
static let title: LocalizedStringKey = "fingerprint.title"
static let theirFingerprint: LocalizedStringKey = "fingerprint.their_label"
static let handshakePending: LocalizedStringKey = "fingerprint.handshake_pending"
static let yourFingerprint: LocalizedStringKey = "fingerprint.your_label"
static let copy: LocalizedStringKey = "common.copy"
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
static func verifyHint(_ nickname: String) -> String {
String(
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
locale: .current,
nickname
)
}
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
static func unknownPeer() -> String {
String(localized: "common.unknown", comment: "Label for an unknown peer")
}
}
var body: some View {
let fingerprintState = verificationModel.fingerprintPresentation(for: peerID)
VStack(spacing: 20) {
// Header
HStack {
Text(Strings.title)
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(textColor)
Spacer()
SheetCloseButton { dismiss() }
.foregroundColor(textColor)
}
.padding()
VStack(alignment: .leading, spacing: 16) {
HStack {
if let icon = fingerprintState.encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 20))
.foregroundColor(fingerprintState.encryptionStatus == .noiseVerified ? Color.green : textColor)
}
VStack(alignment: .leading, spacing: 4) {
Text(fingerprintState.peerNickname)
.bitchatFont(size: 18, weight: .semibold)
.foregroundColor(textColor)
Text(fingerprintState.encryptionStatus.description)
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
}
Spacer()
}
.padding()
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
// Their fingerprint
VStack(alignment: .leading, spacing: 8) {
Text(Strings.theirFingerprint)
.bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7))
if let fingerprint = fingerprintState.theirFingerprint {
Text(formatFingerprint(fingerprint))
.bitchatFont(size: 14)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(maxWidth: .infinity)
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
.contextMenu {
Button(Strings.copy) {
#if os(iOS)
UIPasteboard.general.string = fingerprint
#else
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(fingerprint, forType: .string)
#endif
}
}
} else {
Text(Strings.handshakePending)
.bitchatFont(size: 14)
.foregroundColor(Color.orange)
.padding()
}
}
// My fingerprint
VStack(alignment: .leading, spacing: 8) {
Text(Strings.yourFingerprint)
.bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7))
Text(formatFingerprint(fingerprintState.myFingerprint))
.bitchatFont(size: 14)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(maxWidth: .infinity)
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
.contextMenu {
Button(Strings.copy) {
#if os(iOS)
UIPasteboard.general.string = fingerprintState.myFingerprint
#else
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(fingerprintState.myFingerprint, forType: .string)
#endif
}
}
}
// Verification status
if fingerprintState.canToggleVerification {
VStack(spacing: 12) {
Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange)
.frame(maxWidth: .infinity)
Group {
if fingerprintState.isVerified {
Text(Strings.verifiedMessage)
} else {
Text(Strings.verifyHint(fingerprintState.peerNickname))
}
}
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
if !fingerprintState.isVerified {
Button(action: {
verificationModel.verifyFingerprint(for: peerID)
dismiss()
}) {
Text(Strings.markVerified)
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.green)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
} else {
Button(action: {
verificationModel.unverifyFingerprint(for: peerID)
dismiss()
}) {
Text(Strings.removeVerification)
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.red)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.top)
.frame(maxWidth: .infinity)
}
}
.padding()
.frame(maxWidth: 500) // Constrain max width for better readability
Spacer()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSheetBackground()
}
private func formatFingerprint(_ fingerprint: String) -> String {
// Convert to uppercase and format into 4 lines (4 groups of 4 on each line)
let uppercased = fingerprint.uppercased()
var formatted = ""
for (index, char) in uppercased.enumerated() {
// Add space every 4 characters (but not at the start)
if index > 0 && index % 4 == 0 {
// Add newline after every 16 characters (4 groups of 4)
if index % 16 == 0 {
formatted += "\n"
} else {
formatted += " "
}
}
formatted += String(char)
}
return formatted
}
}