Merge main into fix/push-subscriptions

# Conflicts:
#	KNOWN-ISSUES.md
This commit is contained in:
2026-09-16 11:39:35 -07:00
10 changed files with 150 additions and 18 deletions
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { contactFromAddress, nameParts } from "../contacts";
import { contactFromAddress, contactPhoto, nameParts, withPhoto } from "../contacts";
import type { ContactCard } from "@/jmap/types";
const parts = (name: string | null, email = "[email protected]") =>
@@ -34,3 +34,35 @@ describe("contactFromAddress", () => {
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
});
});
/**
* #376: a photo saved as a `blobId` was refused by Stalwart, which only takes
* the `uri` form. Saving one must also leave a card's other media alone.
*/
describe("withPhoto", () => {
const photo = { dataUrl: "data:image/jpeg;base64,AAAA", type: "image/jpeg" };
it("puts the photo in as a data URI, never a blob id", () => {
const media = withPhoto(undefined, photo)!;
const [m] = Object.values(media);
expect(m).toEqual({ "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: "image/jpeg" });
expect(m).not.toHaveProperty("blobId");
});
it("replaces an existing photo and keeps a logo", () => {
const media = withPhoto({ old: { kind: "photo", blobId: "b1" }, l: { kind: "logo", uri: "data:image/png;base64,BB" } }, photo)!;
expect(Object.values(media).filter((m) => m.kind === "photo")).toHaveLength(1);
expect(media.old).toBeUndefined();
expect(media.l).toEqual({ kind: "logo", uri: "data:image/png;base64,BB" });
});
it("removes only the photo, and clears media when nothing is left", () => {
expect(withPhoto({ p: { kind: "photo", uri: "data:x" }, s: { kind: "sound", uri: "data:y" } }, null)).toEqual({ s: { kind: "sound", uri: "data:y" } });
expect(withPhoto({ p: { kind: "photo", uri: "data:x" } }, null)).toBeNull();
});
it("is read back by contactPhoto", () => {
const card = { id: "c1", media: withPhoto(undefined, photo) } as unknown as ContactCard;
expect(contactPhoto(card, "a1")).toBe(photo.dataUrl);
});
});
+17 -1
View File
@@ -1,4 +1,4 @@
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
import type { ContactCard, EmailAddress, JSContactMedia, JSContactName } from "@/jmap/types";
import { withBase } from "@/lib/basePath";
/** Best display name for a card. */
@@ -57,6 +57,22 @@ export function contactEmails(c: ContactCard): EmailAddress[] {
return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address }));
}
/**
* A card's `media` with its photo replaced by `photo`, or removed when that is
* null, and everything else in it -- a logo, a sound -- left as it was.
*
* The photo goes in as a `data:` URI. Stalwart (0.16.22, checked live on
* 2026-09-16) refuses a `blobId` in `media` outright -- "blobIds in media is
* not supported" -- which is RFC 9610's JMAP extension to JSContact, and
* accepts the plain RFC 9553 `uri` form, returning it unchanged (#376). The
* editor's photo is a 256px JPEG, tens of kilobytes; 134 KB was accepted.
*/
export function withPhoto(media: Record<string, JSContactMedia> | undefined | null, photo: { dataUrl: string; type: string } | null): Record<string, JSContactMedia> | null {
const rest: Record<string, JSContactMedia> = Object.fromEntries(Object.entries(media ?? {}).filter(([, m]) => m.kind !== "photo"));
if (photo) rest[newKey("p")] = { "@type": "Media", kind: "photo", uri: photo.dataUrl, mediaType: photo.type };
return Object.keys(rest).length ? rest : null;
}
export function contactPhoto(c: ContactCard, accountId: string): string | null {
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
if (!m) return null;