Files
native/src/lib/data-uri.ts
T
Linus Rath f52ef7db7e fix: show and keep contact photos in the app on Stalwart 1.0
Stalwart 1.0 returns every photo, logo and sound in a contact card as a
blobId without a uri, including photos saved as data: URIs. The app read
only `uri`, so sender avatars, the contact list, the detail screen, the
form preview and vCard export showed nothing. Worse, the form rebuilt
`media` from the card on every save and sent it without the photo, or
as `null`, so editing a phone number deleted the photo on the server.

- `ContactMedia` carries `blobId`, `label`, `pref` and `contexts`, and
  `uri` is optional. The photo shown is the photo entry with the lowest
  `pref`, as a URI (0.16) or a blob (1.0).
- Blob photos load from the download URL with the Authorization
  header, like file thumbnails. At most half of the server's
  `maxConcurrentRequests` load at once, a failed one is tried once more
  and then left alone for two minutes, and one that loaded skips the
  queue from then on.
- The form leaves `media` out of the save unless the photo changed. A
  changed photo is built on the card as the server has it now, so a
  stale blobId can't make 1.0 refuse the update, and a new photo keeps
  the key, `pref`, `label` and `contexts` of the one it replaces.
- A new photo is uploaded into the user's own account and sent as a
  blobId where the blob capability lists ContactCard (1.0). Otherwise
  it goes as a data: URI, and a refusal like 0.16's "blobIds in media
  is not supported." retries once with the data: URI and is remembered
  for the account.
- vCard export fetches blob photos with batched Blob/get requests and
  writes them inline. It also writes photos whose data: URI names no
  media type, which it dropped before. Duplicate and moving a card to
  another account send the bytes instead of the source's blobIds.
- An update never clears `id`, `uid` or `addressBookIds`, and vCard
  export writes MEMBER values that are already URIs as they are instead
  of `urn:uuid:urn:uuid:…`.

The form's state and patch building moved to `src/lib/contact-form.ts`
so the save path can be tested.
2026-09-25 20:32:13 +02:00

47 lines
1.7 KiB
TypeScript

// base64 data: URIs, as contact photos carry them (webmail lib/data-uri.ts).
//
// Stalwart writes three shapes: `data:<type>;base64,…`, `data:;base64,…`
// (0.16.x) and `data:base64,…` (0.16.0, no parameter separator). The last two
// carry no media type, so it comes from the card's `mediaType` when known.
/** The media type (if any) and base64 payload of a base64 data: URI. */
export function parseBase64DataUri(uri: string): { type?: string; data: string } | null {
if (!uri.toLowerCase().startsWith('data:')) return null;
const comma = uri.indexOf(',');
if (comma < 0) return null;
const header = uri.slice(5, comma).split(';');
if (header[header.length - 1].trim().toLowerCase() !== 'base64') return null;
const type = header.length > 1 ? header[0].trim() : '';
return { type: type || undefined, data: uri.slice(comma + 1) };
}
export function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
}
return btoa(binary);
}
/**
* The bytes of a base64 data: URI with their media type; `fallbackType`
* applies when the URI names none. Null for anything else.
*/
export function dataUriToBytes(
uri: string,
fallbackType?: string,
): { bytes: Uint8Array; type: string } | null {
const parsed = parseBase64DataUri(uri);
if (!parsed) return null;
let binary: string;
try {
binary = atob(parsed.data);
} catch {
return null;
}
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return { bytes, type: parsed.type || fallbackType || 'application/octet-stream' };
}