eleven / messenger Public

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>
Paul Campbell fe84d5169aaf79abc108a34528e48e78a0f14141 parent 3d6d802
4 files changed, +40 −5
  • ios/LChat/Crypto.swift +11 −0
  • ios/LChat/Models.swift +2 −1
  • ios/LChat/ThreadStore.swift +18 −3
  • web/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