ios: decrypt encrypted screen names; fix feed hidden behind composer (#120)
Screen names: the server stores names as label_blob (encrypted under the instance key) and blanks the plaintext label once set that way, so iOS — which only read the plaintext label — showed people and DM titles as empty. Decrypt label_blob into the label on roster load (GroupKey.decryptString, .fragmentsAllowed for the bare JSON string), mirroring decryptRosterLabels in chat.js. Bots/not-yet-migrated keep theirs. Composer overlap: WebKit drops a scroll container's trailing padding at the scroll end, so the feed's padding-bottom let the last message slide under the floating composer once a chat scrolled. Reserve the composer's height with a real in-flow spacer (.native-embed #feed::after) instead of padding-bottom.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 files changed,
+40
−5
ios/LChat/Crypto.swift+11 −0ios/LChat/Models.swift+2 −1ios/LChat/ThreadStore.swift+18 −3web/static/style.css+9 −1
diff --git a/ios/LChat/Crypto.swift b/ios/LChat/Crypto.swift| index 920c598..76b8718 100644 |
| --- a/ios/LChat/Crypto.swift |
| +++ b/ios/LChat/Crypto.swift |
| @@ -122,6 +122,17 @@ struct GroupKey { |
| return try encrypt(data).base64urlEncodedString() |
| } |
| + /// Decrypts a base64url blob whose plaintext is a bare JSON string — how the |
| + /// web encrypts screen names (`encrypt(instanceKey, name)` → JSON.stringify). |
| + /// Uses .fragmentsAllowed so a top-level `"Name"` decodes reliably. |
| + func decryptString(blob: String) throws -> String { |
| + let data = try decrypt(base64urlBlob: blob) |
| + guard let s = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? String else { |
| + throw CryptoError.badBlob |
| + } |
| + return s |
| + } |
| + |
| // MARK: Binary payloads (attachments) |
| /// Decrypts raw `iv||ct||tag` bytes (attachment downloads are octet-stream, |
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift| index 6cb3746..9f8b11a 100644 |
| --- a/ios/LChat/Models.swift |
| +++ b/ios/LChat/Models.swift |
| @@ -49,7 +49,8 @@ struct ServerThread: Codable { |
| struct RosterEntry: Codable, Equatable { |
| let user_id: String |
| - let label: String |
| + var label: String // display name; decrypted from label_blob when present |
| + var label_blob: String? // name encrypted under the instance key (server blanks `label` once set this way) |
| let public_key: String |
| var avatar: String? |
| var is_agent: Bool? // an agent/bot helper, not a person (BOT badge in pickers) |
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift| index 5447f21..edfbe18 100644 |
| --- a/ios/LChat/ThreadStore.swift |
| +++ b/ios/LChat/ThreadStore.swift |
| @@ -41,18 +41,33 @@ final class ThreadStore: ObservableObject { |
| await api.ensurePubkey(instance.userPub) |
| - let people = await api.roster() |
| - roster = people |
| - rosterLabels = Dictionary(people.map { ($0.user_id, $0.label) }, uniquingKeysWith: { a, _ in a }) |
| + var people = await api.roster() |
| if myUserId.isEmpty, let me = people.first(where: { $0.public_key == instance.userPub }) { |
| myUserId = me.user_id |
| } |
| + var instanceGroupKey: GroupKey? |
| if let wrapped = await api.instanceKeyWrapped(), |
| let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv) { |
| instanceKeyB64 = raw.base64urlEncodedString() |
| + instanceGroupKey = try? GroupKey(rawKey: raw) |
| } |
| + // Screen names arrive encrypted under the instance key (label_blob); the |
| + // server blanks the plaintext `label` once a name is set that way, so |
| + // without this the sidebar shows people (and DM titles) as empty. Decrypt |
| + // into `.label` — bots / not-yet-migrated people keep their plaintext one. |
| + // Mirrors decryptRosterLabels in web/static/chat.js. |
| + if let ik = instanceGroupKey { |
| + for i in people.indices { |
| + guard let blob = people[i].label_blob, !blob.isEmpty, |
| + let name = try? ik.decryptString(blob: blob), !name.isEmpty else { continue } |
| + people[i].label = name |
| + } |
| + } |
| + roster = people |
| + rosterLabels = Dictionary(people.map { ($0.user_id, $0.label) }, uniquingKeysWith: { a, _ in a }) |
| + |
| instanceName = await api.groupName() |
| var rows: [Thread] = [] |
diff --git a/web/static/style.css b/web/static/style.css| index b9c0d64..64a3d40 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1412,7 +1412,15 @@ html.native .thread-head { |
| vars default to 0, so a shell that doesn't inject them gets the plain layout. */ |
| .native-embed #feed { |
| padding-top: calc(18px + var(--native-top-inset, 0px)); |
| - padding-bottom: calc(18px + var(--native-bottom-inset, 0px)); |
| + /* Bottom clearance is a real in-flow spacer (::after), NOT padding: WebKit drops |
| + a scroll container's trailing padding at the scroll end, so padding-bottom here |
| + let the last message slip under the floating composer once a chat scrolled. */ |
| + padding-bottom: 0; |
| +} |
| +.native-embed #feed::after { |
| + content: ""; |
| + flex: none; |
| + height: calc(18px + var(--native-bottom-inset, 0px)); |
| } |
| /* Keep the attachment preview visible: it's how a staged photo shows up just above |
| the native composer (drag-drop or the native + button stage into it). The composer |