Merge pull request #388 from Coffey-Labs/fix/contact-photos
Save contact photos inline, and load cards so avatars show
This commit is contained in:
@@ -48,6 +48,8 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
|||||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||||
|
|
||||||
|
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
|
||||||
|
|
||||||
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
||||||
|
|
||||||
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
|
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
|
||||||
|
|||||||
@@ -144,6 +144,21 @@ test("upstream caches let go of sessions that have aged out", async () => {
|
|||||||
assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 });
|
assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the mock refuses a contact photo given as a blob id, as Stalwart does", async () => {
|
||||||
|
const jmap = (methodCalls: unknown[]) => call("/api/jmap", { method: "POST", body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], methodCalls }) });
|
||||||
|
const card = (media: unknown) => ({ "@type": "Card", version: "1.0", kind: "individual", name: { full: "Probe" }, addressBookIds: { ab1: true }, media });
|
||||||
|
const res = await jmap([["ContactCard/set", { accountId: "a1", create: {
|
||||||
|
blob: card({ p: { "@type": "Media", kind: "photo", blobId: "b1", mediaType: "image/jpeg" } }),
|
||||||
|
inline: card({ p: { "@type": "Media", kind: "photo", uri: "data:image/jpeg;base64,AA", mediaType: "image/jpeg" } }),
|
||||||
|
} }, "s"]]);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const set = res.body.methodResponses[0][1];
|
||||||
|
assert.equal(set.notCreated.blob.description, "blobIds in media is not supported.");
|
||||||
|
assert.deepEqual(set.notCreated.blob.properties, ["media"]);
|
||||||
|
assert.ok(set.created.inline.id, "a data URI is accepted");
|
||||||
|
await jmap([["ContactCard/set", { accountId: "a1", destroy: [set.created.inline.id] }, "d"]]);
|
||||||
|
});
|
||||||
|
|
||||||
test("an app password needs a name", async () => {
|
test("an app password needs a name", async () => {
|
||||||
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
|
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
|
||||||
assert.equal(res.status, 400);
|
assert.equal(res.status, 400);
|
||||||
|
|||||||
@@ -434,7 +434,23 @@ export const handlers: Record<string, Handler> = {
|
|||||||
* state older than the log's window cannot be answered, as on a real server.
|
* state older than the log's window cannot be answered, as on a real server.
|
||||||
*/
|
*/
|
||||||
"ContactCard/set": (a) => {
|
"ContactCard/set": (a) => {
|
||||||
const r = genericSet(cards, "cc")(a);
|
/*
|
||||||
|
* Stalwart refuses a `blobId` inside `media` (0.16.22, checked live on
|
||||||
|
* 2026-09-16), and takes the whole call down for it. The mock took
|
||||||
|
* anything, which is how ihasmail shipped a photo upload that never
|
||||||
|
* worked against the real server (#376).
|
||||||
|
*/
|
||||||
|
const withBlobMedia = (o: unknown) => Object.values(((o as Obj)?.media as Record<string, Obj> | null) ?? {}).some((m) => m && "blobId" in m);
|
||||||
|
const refuse = { type: "invalidProperties", description: "blobIds in media is not supported.", properties: ["media"] };
|
||||||
|
const create = { ...((a.create as Obj) ?? {}) };
|
||||||
|
const update = { ...((a.update as Obj) ?? {}) };
|
||||||
|
const notCreated: Obj = {};
|
||||||
|
const notUpdated: Obj = {};
|
||||||
|
for (const [k, v] of Object.entries(create)) if (withBlobMedia(v)) { notCreated[k] = refuse; delete create[k]; }
|
||||||
|
for (const [k, v] of Object.entries(update)) if (withBlobMedia(v)) { notUpdated[k] = refuse; delete update[k]; }
|
||||||
|
const r = genericSet(cards, "cc")({ ...a, create, update });
|
||||||
|
if (Object.keys(notCreated).length) r.notCreated = { ...((r.notCreated as Obj) ?? {}), ...notCreated };
|
||||||
|
if (Object.keys(notUpdated).length) r.notUpdated = notUpdated;
|
||||||
nextState();
|
nextState();
|
||||||
recordCardChange({
|
recordCardChange({
|
||||||
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
|
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { contactFromAddress, nameParts } from "../contacts";
|
import { contactFromAddress, contactPhoto, nameParts, withPhoto } from "../contacts";
|
||||||
import type { ContactCard } from "@/jmap/types";
|
import type { ContactCard } from "@/jmap/types";
|
||||||
|
|
||||||
const parts = (name: string | null, email = "[email protected]") =>
|
const parts = (name: string | null, email = "[email protected]") =>
|
||||||
@@ -34,3 +34,35 @@ describe("contactFromAddress", () => {
|
|||||||
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
|
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
@@ -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";
|
import { withBase } from "@/lib/basePath";
|
||||||
|
|
||||||
/** Best display name for a card. */
|
/** 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 }));
|
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 {
|
export function contactPhoto(c: ContactCard, accountId: string): string | null {
|
||||||
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -277,6 +277,13 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
if (!available) return;
|
if (!available) return;
|
||||||
await get().loadBooks();
|
await get().loadBooks();
|
||||||
void get().loadShared();
|
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();
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
+5
-2
@@ -8,10 +8,13 @@ import { t } from "@/lib/i18n";
|
|||||||
export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) {
|
export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) {
|
||||||
const email = typeof who === "string" ? who : (who?.email ?? "");
|
const email = typeof who === "string" ? who : (who?.email ?? "");
|
||||||
const name = typeof who === "string" ? who : (who?.name ?? who?.email ?? "");
|
const name = typeof who === "string" ? who : (who?.name ?? who?.email ?? "");
|
||||||
|
// Whatever cards are held count, the reader's own or a shared book's; the
|
||||||
|
// photo is fetched from the account the card belongs to.
|
||||||
const photo = useContacts((s) => {
|
const photo = useContacts((s) => {
|
||||||
if (!email || !s.loaded) return null;
|
if (!email) return null;
|
||||||
const c = s.lookupByEmail(email);
|
const c = s.lookupByEmail(email);
|
||||||
return c && s.accountId ? contactPhoto(c, s.accountId) : null;
|
const account = c ? (s.accountOfCard(c.id) ?? s.accountId) : null;
|
||||||
|
return c && account ? contactPhoto(c, account) : null;
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<span className={`avatar ${size ?? ""} ${className ?? ""}`} style={{ background: photo ? "transparent" : avatarColor(email || name) }} aria-hidden="true">
|
<span className={`avatar ${size ?? ""} ${className ?? ""}`} style={{ background: photo ? "transparent" : avatarColor(email || name) }} aria-hidden="true">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { Plus, Trash2, Camera, X } from "lucide-react";
|
import { Plus, Trash2, Camera, X } from "lucide-react";
|
||||||
import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } from "@/jmap/types";
|
import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } from "@/jmap/types";
|
||||||
import { useContacts } from "@/store/contacts";
|
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 { Dialog } from "@/ui/dialog";
|
||||||
import { DateField } from "@/ui/datefield";
|
import { DateField } from "@/ui/datefield";
|
||||||
import { toast } from "@/ui/toast";
|
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.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.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;
|
obj.members = kind === "group" && memberUids.length ? Object.fromEntries(memberUids.map((u) => [u, true])) : null;
|
||||||
if (photo) {
|
// Inline, not uploaded: see `withPhoto`. The card's other media stays.
|
||||||
const m = /^data:([^;]+);base64,(.*)$/s.exec(photo.dataUrl);
|
if (photo) obj.media = withPhoto(card.media, photo);
|
||||||
if (m) {
|
else if (removePhoto) obj.media = withPhoto(card.media, null);
|
||||||
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;
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
|
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
|
||||||
toast.success(t("Contact created"));
|
toast.success(t("Contact created"));
|
||||||
|
|||||||
@@ -301,7 +301,8 @@ export function ContactsView({ id }: { id?: string }) {
|
|||||||
<div className="contact-letter">{g.letter}</div>
|
<div className="contact-letter">{g.letter}</div>
|
||||||
{g.items.map((c) => {
|
{g.items.map((c) => {
|
||||||
const email = contactEmails(c)[0]?.email;
|
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 (
|
return (
|
||||||
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""} ${picked[c.id] ? "picked" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
|
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""} ${picked[c.id] ? "picked" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
|
||||||
{!readOnly && (
|
{!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 }) {
|
function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: ContactCard; onBack: () => void; onEdit: () => void; narrow: boolean; onEmail: (addr: string) => void }) {
|
||||||
const contacts = useContacts();
|
const contacts = useContacts();
|
||||||
const [, navigate] = useLocation();
|
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 name = contactDisplayName(c);
|
||||||
const org = Object.values(c.organizations ?? {})[0];
|
const org = Object.values(c.organizations ?? {})[0];
|
||||||
const title = Object.values(c.titles ?? {})[0];
|
const title = Object.values(c.titles ?? {})[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user