Save contact photos inline, and load cards so avatars show

Stalwart refuses a blobId in a card's media ("blobIds in media is not
supported"), so adding or changing a photo always failed. The editor now
saves the photo as a data: URI, which Stalwart accepts and returns
unchanged, and leaves the card's other media as it was. Checked live on
0.16.22; the mock now refuses a blobId the same way.

Avatars in the mail list come from the address book's cards, and nothing
loaded those at sign-in, so a photo showed only after Contacts had been
opened. The cards now load in the background at start, the avatar uses
whatever cards are held, and a shared card's photo is fetched from the
account it belongs to.

Fixes #376.
This commit is contained in:
2026-09-16 11:27:47 -07:00
parent aa9bf1b9b2
commit d38dee7eb9
10 changed files with 150 additions and 18 deletions
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { useSession } from "@/store/session";
import { useContacts } from "@/store/contacts";
/**
* #376: avatars in the mail list come from the address book's cards, and
* nothing loaded those at sign-in -- so a contact's photo showed once Contacts
* had been opened and was gone after the next reload.
*/
const session = {
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.contacts]: {} },
accounts: { own: { name: "[email protected]", isPersonal: true, accountCapabilities: { [CAP.contacts]: {} } } },
primaryAccounts: { [CAP.contacts]: "own" },
state: "s",
} as unknown as JmapSession;
beforeEach(() => {
client.session = session;
useSession.setState({ status: "authenticated", session, accountId: "own" });
useContacts.setState({ accountId: null, loaded: false, loading: false, cards: {}, cardState: null });
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = methodCalls.map(([name, , id]) => {
if (name === "ContactCard/query") return [name, { ids: ["c1"], total: 1, position: 0, queryState: "q" }, id];
if (name === "ContactCard/get") return [name, { state: "5", list: [{ id: "c1", emails: { e: { address: "[email protected]" } }, media: { p: { kind: "photo", uri: "data:image/jpeg;base64,AA" } } }], notFound: [] }, id];
return [name, { state: "1", list: [], notFound: [] }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("contacts at sign-in", () => {
it("loads the cards, so an avatar can be found without opening Contacts", async () => {
await useContacts.getState().init();
await vi.waitFor(() => expect(useContacts.getState().loaded).toBe(true));
const card = useContacts.getState().lookupByEmail("[email protected]");
expect(card?.id).toBe("c1");
});
});
+7
View File
@@ -277,6 +277,13 @@ export const useContacts = create<ContactsState>((set, get) => ({
if (!available) return;
await get().loadBooks();
void get().loadShared();
/*
* The cards too, in the background. The avatars in the mail list come from
* them, and nothing else loaded them until Contacts was opened or an
* address was typed -- so a photo appeared once somebody did either, and
* was gone again after the next reload (#376).
*/
if (!get().loaded) void get().loadAll();
},
/*