Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Zero-Clause BSD
===============
BSD Zero Clause License

Copyright (c) 2026 QuickMythril and Qortium Help contributors

Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
Expand Down
4 changes: 3 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
publishPreparedFeedbackBundle,
type PreparedFeedbackAttachment,
} from './attachmentUpload';
import { Avatar } from './Avatar';
import { Avatar, AvatarActionsProvider } from './avatar';
import {
APP_FILTER_ALL,
buildAppFilterOptions,
Expand Down Expand Up @@ -2067,6 +2067,7 @@ export default function App() {
const showSidebar = view !== 'compose' && view !== 'reference';

return (
<AvatarActionsProvider actions={bridgeState.actions}>
<main className="app-shell">
<header className="topbar">
<div className="brand">
Expand Down Expand Up @@ -2709,5 +2710,6 @@ export default function App() {
/>
) : null}
</main>
</AvatarActionsProvider>
);
}
42 changes: 1 addition & 41 deletions src/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1 @@
import { useEffect, useState } from 'react';
import { getAvatarFallbackCharacter, getCachedAvatar, resolveAvatar } from './avatar';

// A small circular author avatar. Shows the registered name's initial as an
// immediate fallback and swaps in the QDN THUMBNAIL/avatar image once it
// resolves (cached for the session). Marked aria-hidden because the author's
// name is always rendered as adjacent text, so the avatar is decorative.
export function Avatar({ name, size = 24 }: { name: string; size?: number }) {
const [src, setSrc] = useState<string | null>(() => getCachedAvatar(name) ?? null);

useEffect(() => {
const cached = getCachedAvatar(name);

if (cached !== undefined) {
setSrc(cached);
return;
}

let active = true;

void resolveAvatar(name).then((resolvedSrc) => {
if (active) {
setSrc(resolvedSrc);
}
});

return () => {
active = false;
};
}, [name]);

return (
<span aria-hidden="true" className="avatar" style={{ height: size, width: size }}>
{src ? (
<img alt="" className="avatar__img" src={src} />
) : (
<span className="avatar__fallback">{getAvatarFallbackCharacter(name)}</span>
)}
</span>
);
}
export { Avatar } from './avatar';
40 changes: 40 additions & 0 deletions src/Reference.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,29 @@ const hostInfo = actions.includes('GET_HOST_INFO')
const usingPublicNode = actions.includes('IS_USING_PUBLIC_NODE')
? await window.qdnRequest({ action: 'IS_USING_PUBLIC_NODE' })
: null;`,
avatar: `const canFetchAuthorAvatar = [
'GET_NAME_DATA',
'FETCH_ACCOUNT_AVATAR',
].every((action) => actions.includes(action));

if (canFetchAuthorAvatar) {
// Resolve the current owner of the feedback resource's registered name.
const nameData = await window.qdnRequest({
action: 'GET_NAME_DATA',
name: resource.name,
});

if (typeof nameData?.owner === 'string') {
const avatar = await window.qdnRequest({
action: 'FETCH_ACCOUNT_AVATAR',
address: nameData.owner,
maxBytes: 500 * 1024,
});

// Validate address, base64, byte length, raster MIME type, and pointer
// descriptor before turning avatar.body into a Blob URL for one <img>.
}
}`,
notifications: `const postId = 'm1abc123';

await window.qdnRequest({
Expand Down Expand Up @@ -449,9 +472,26 @@ export default function Reference() {
</li>
</ul>
</ReferenceCard>
<ReferenceCard title="Author avatars">
<ul>
<li>
Resolve the feedback resource&apos;s registered name with <code>GET_NAME_DATA</code> before requesting
its current owner&apos;s account avatar.
</li>
<li>
Feature-detect both <code>GET_NAME_DATA</code> and <code>FETCH_ACCOUNT_AVATAR</code>; browser mode
keeps the initial fallback and never builds a direct thumbnail URL.
</li>
<li>
Fetch avatar bytes only for mounted controls. Validate Home&apos;s bounded base64 response, construct a
Blob URL, and revoke it when the control is replaced or unmounted.
</li>
</ul>
</ReferenceCard>
</div>

<CopyableCode label="Follow and unfollow replies" snippet="notifications" />
<CopyableCode label="Resolve and fetch a visible author avatar" snippet="avatar" />

<aside className="reference-callout">
<strong>Attachment publishing is staged, not atomic.</strong>
Expand Down
77 changes: 77 additions & 0 deletions src/avatar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchAccountAvatar, parseAccountAvatarResponse, resolveNameOwner } from './avatar';
import { hasHomeBridge, qdnRequest } from './qdnRequest';

vi.mock('./qdnRequest', () => ({
hasAction: (actions: string[], action: string) => actions.some((candidate) => candidate.toUpperCase() === action.toUpperCase()),
hasHomeBridge: vi.fn(),
qdnRequest: vi.fn(),
}));

const ADDRESS = 'QT4zHex8JEULmBhYmKd5UhpiNA46T5wUko';

describe('pointer-aware Help avatars', () => {
const hasHomeBridgeMock = vi.mocked(hasHomeBridge);
const qdnRequestMock = vi.mocked(qdnRequest);
const actions = ['GET_NAME_DATA', 'FETCH_ACCOUNT_AVATAR'];

beforeEach(() => {
hasHomeBridgeMock.mockReset();
qdnRequestMock.mockReset();
hasHomeBridgeMock.mockReturnValue(true);
});

it('uses the resolved name owner, then accepts only a bounded pointer avatar for that address', async () => {
qdnRequestMock.mockResolvedValueOnce({ name: 'Alice', owner: ADDRESS });
await expect(resolveNameOwner('Alice', actions)).resolves.toBe(ADDRESS);
expect(qdnRequestMock).toHaveBeenCalledWith({ action: 'GET_NAME_DATA', name: 'Alice' });

qdnRequestMock.mockResolvedValueOnce({
address: ADDRESS,
body: 'AQIDBA==',
contentLength: 4,
contentType: 'image/png',
descriptor: { identifier: '', name: 'Alice', service: 'THUMBNAIL' },
encoding: 'base64',
source: 'POINTER',
});
await expect(fetchAccountAvatar(ADDRESS, actions)).resolves.toMatchObject({ kind: 'ready' });
expect(qdnRequestMock).toHaveBeenLastCalledWith({ action: 'FETCH_ACCOUNT_AVATAR', address: ADDRESS, maxBytes: 500 * 1024 });
});

it('fails closed for a mismatched address, raw URL, or malformed pointer descriptor', () => {
expect(parseAccountAvatarResponse({ address: 'Qother' }, ADDRESS)).toEqual({ kind: 'unavailable' });
expect(parseAccountAvatarResponse({
address: ADDRESS,
body: 'https://node.invalid/avatar.png',
contentLength: 4,
contentType: 'image/png',
encoding: 'base64',
source: 'LEGACY',
}, ADDRESS)).toEqual({ kind: 'unavailable' });
expect(parseAccountAvatarResponse({
address: ADDRESS,
body: 'AQIDBA==',
contentLength: 4,
contentType: 'image/png',
encoding: 'base64',
source: 'POINTER',
}, ADDRESS)).toEqual({ kind: 'unavailable' });
});

it('keeps a bounded retry path for a matching pending pointer response', () => {
expect(parseAccountAvatarResponse({
address: ADDRESS,
descriptor: { identifier: '', name: 'Alice', service: 'THUMBNAIL' },
retryAfterSeconds: 999,
source: 'POINTER',
status: 'PENDING',
}, ADDRESS)).toEqual({ kind: 'pending', retryAfterSeconds: 30 });
});

it('does not resolve names or avatars when the Home capabilities are unavailable', async () => {
await expect(resolveNameOwner('Bob', [])).resolves.toBeNull();
await expect(fetchAccountAvatar(ADDRESS, [])).resolves.toEqual({ kind: 'unavailable' });
expect(qdnRequestMock).not.toHaveBeenCalled();
});
});
Loading