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
+4 -11
View File
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import { Plus, Trash2, Camera, X } from "lucide-react";
import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } from "@/jmap/types";
import { useContacts } from "@/store/contacts";
import { buildName, contactDisplayName, nameParts, newKey } from "@/lib/contacts";
import { buildName, contactDisplayName, nameParts, newKey, withPhoto } from "@/lib/contacts";
import { Dialog } from "@/ui/dialog";
import { DateField } from "@/ui/datefield";
import { toast } from "@/ui/toast";
@@ -105,16 +105,9 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
obj.links = website ? { [newKey("l")]: { "@type": "Link", uri: /^https?:/i.test(website) ? website : `https://${website}` } } : null;
obj.notes = note.trim() ? { [newKey("x")]: { "@type": "Note", note: note.trim() } } : null;
obj.members = kind === "group" && memberUids.length ? Object.fromEntries(memberUids.map((u) => [u, true])) : null;
if (photo) {
const m = /^data:([^;]+);base64,(.*)$/s.exec(photo.dataUrl);
if (m) {
const bin = atob(m[2]!);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const up = await client.upload(contacts.accountId!, new Blob([bytes], { type: m[1]! }), { type: m[1]! });
obj.media = { [newKey("p")]: { "@type": "Media", kind: "photo", blobId: up.blobId, mediaType: m[1]! } };
}
} else if (removePhoto) obj.media = null;
// Inline, not uploaded: see `withPhoto`. The card's other media stays.
if (photo) obj.media = withPhoto(card.media, photo);
else if (removePhoto) obj.media = withPhoto(card.media, null);
if (isNew) {
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
toast.success(t("Contact created"));
+4 -2
View File
@@ -301,7 +301,8 @@ export function ContactsView({ id }: { id?: string }) {
<div className="contact-letter">{g.letter}</div>
{g.items.map((c) => {
const email = contactEmails(c)[0]?.email;
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
const photoAccount = contacts.accountOfCard(c.id) ?? contacts.accountId;
const photo = photoAccount ? contactPhoto(c, photoAccount) : null;
return (
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""} ${picked[c.id] ? "picked" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
{!readOnly && (
@@ -343,7 +344,8 @@ export function ContactsView({ id }: { id?: string }) {
function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: ContactCard; onBack: () => void; onEdit: () => void; narrow: boolean; onEmail: (addr: string) => void }) {
const contacts = useContacts();
const [, navigate] = useLocation();
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
const photoAccount = contacts.accountOfCard(c.id) ?? contacts.accountId;
const photo = photoAccount ? contactPhoto(c, photoAccount) : null;
const name = contactDisplayName(c);
const org = Object.values(c.organizations ?? {})[0];
const title = Object.values(c.titles ?? {})[0];