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 Polls contributors

Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
Expand Down
13 changes: 12 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getBridgeState, qdnRequest } from './qdnRequest';
import { Reference } from './Reference';
import type { BridgeState, HostInfo, PendingVote, Poll, PollVotes } from './types';
import { Notice } from './ui';
import { loadVoterIdentities, type VoterIdentity } from './voterIdentities';
import { loadVoterIdentities, revokeVoterIdentityUrls, type VoterIdentity } from './voterIdentities';
import { getPollWriteAvailability } from './writeAvailability';

type Tab = 'browse' | 'create' | 'mine' | 'reference';
Expand Down Expand Up @@ -84,6 +84,7 @@ export function App() {
const browseResultKeyRef = useRef('');
const mineResultKeyRef = useRef('');
const selectedRef = useRef<Poll | null>(null);
const voterIdentityUrlsRef = useRef<ReadonlyMap<string, VoterIdentity>>(new Map());
const translate = useMemo(() => createTranslator(settings.language), [settings.language]);
const bridgeActionsKey = bridge.actions.join('\u0000');
const supports142 = versionAtLeast(host?.hostVersion);
Expand All @@ -108,6 +109,8 @@ export function App() {
const addresses = votes?.voteDetails?.map((detail) => detail.voterAddress) ?? [];

if (!selected || !addresses.length) {
revokeVoterIdentityUrls(voterIdentityUrlsRef.current);
voterIdentityUrlsRef.current = new Map();
setVoterIdentities(new Map());
return;
}
Expand All @@ -117,17 +120,25 @@ export function App() {
void loadVoterIdentities(addresses, bridge.actions)
.then((identities) => {
if (active) {
revokeVoterIdentityUrls(voterIdentityUrlsRef.current);
voterIdentityUrlsRef.current = identities;
setVoterIdentities(identities);
} else {
revokeVoterIdentityUrls(identities);
}
})
.catch(() => {
if (active) {
revokeVoterIdentityUrls(voterIdentityUrlsRef.current);
voterIdentityUrlsRef.current = new Map();
setVoterIdentities(new Map());
}
});

return () => {
active = false;
revokeVoterIdentityUrls(voterIdentityUrlsRef.current);
voterIdentityUrlsRef.current = new Map();
};
}, [selected?.pollId, votes, bridgeActionsKey]);

Expand Down
4 changes: 2 additions & 2 deletions src/PollDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ describe('poll result loading states', () => {
votesLoading: false,
voterIdentities: new Map([[
'Qalice',
{ address: 'Qalice', avatarSrc: 'https://node.test/avatar', name: 'Alice' },
{ address: 'Qalice', avatarSrc: 'blob:mock/avatar', name: 'Alice' },
]]),
});

expect(markup).toContain('src="https://node.test/avatar"');
expect(markup).toContain('src="blob:mock/avatar"');
expect(markup).toContain('<strong>Alice</strong>');
expect(markup).toContain('title="Qalice"');
expect(markup).not.toContain('<td>Qalice</td>');
Expand Down
2 changes: 1 addition & 1 deletion src/PollDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ export function PollDetail({
function VoterIdentityCell({ address, identity }: { address: string; identity?: VoterIdentity }) {
const [avatarFailed, setAvatarFailed] = useState(false);
const name = identity?.name ?? null;
const avatarSrc = identity?.avatarSrc ?? null;
const avatarSrc = identity?.avatarSrc?.startsWith('blob:') ? identity.avatarSrc : null;

useEffect(() => {
setAvatarFailed(false);
Expand Down
95 changes: 63 additions & 32 deletions src/voterIdentities.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { qdnRequest } from './qdnRequest';
import { loadVoterIdentities } from './voterIdentities';
import { loadVoterIdentities, revokeVoterIdentityUrls } from './voterIdentities';

vi.mock('./qdnRequest', () => ({
getNodeApiUrl: () => 'http://127.0.0.1:24891',
hasAction: (actions: string[], ...candidates: string[]) => {
const available = new Set(actions.map((action) => action.toUpperCase()));
return candidates.some((candidate) => available.has(candidate.toUpperCase()));
Expand All @@ -13,67 +12,99 @@ vi.mock('./qdnRequest', () => ({

describe('voter identity loading', () => {
const qdnRequestMock = vi.mocked(qdnRequest);
const createObjectURLMock = vi.fn((blob: Blob) => `blob:mock/${blob.type}`);
const revokeObjectURLMock = vi.fn();

beforeEach(() => {
qdnRequestMock.mockReset();
createObjectURLMock.mockClear();
revokeObjectURLMock.mockClear();
(URL as unknown as { createObjectURL: typeof createObjectURLMock }).createObjectURL = createObjectURLMock;
(URL as unknown as { revokeObjectURL: typeof revokeObjectURLMock }).revokeObjectURL = revokeObjectURLMock;
});

it('dedupes and chunks Home identity requests at 500 addresses', async () => {
it('dedupes and chunks name-only Home identity requests at 500 addresses', async () => {
const addresses = Array.from({ length: 501 }, (_value, index) => `Q${index}`);
qdnRequestMock.mockImplementation(async (request) => {
const batch = request.addresses as string[];
return batch.map((address) => ({
address,
avatarSrc: `https://node.test/${address}/avatar`,
name: `name-${address}`,
}));
return batch.map((address) => ({ address, avatarSrc: `https://node.test/${address}/avatar`, name: `name-${address}` }));
});

const identities = await loadVoterIdentities([...addresses, 'Q0'], ['RESOLVE_IDENTITIES']);

expect(identities).toHaveLength(501);
expect(identities.get('Q0')).toEqual({
address: 'Q0',
avatarSrc: 'https://node.test/Q0/avatar',
name: 'name-Q0',
});
expect(identities.get('Q0')).toEqual({ address: 'Q0', avatarSrc: null, name: 'name-Q0' });
expect(qdnRequestMock).toHaveBeenCalledTimes(2);
expect(qdnRequestMock.mock.calls[0][0].addresses).toHaveLength(500);
expect(qdnRequestMock.mock.calls[1][0].addresses).toEqual(['Q500']);
});

it('prefers a primary name in the fallback path', async () => {
it('uses a pointer-aware Blob avatar without trusting the legacy avatarSrc hint', async () => {
qdnRequestMock
.mockResolvedValueOnce([{ address: 'Qalice', avatarSrc: 'https://node.test/legacy', name: 'Alice' }])
.mockResolvedValueOnce({
address: 'Qalice', body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/png',
descriptor: { identifier: 'avatar', name: 'alice', service: 'THUMBNAIL' }, encoding: 'base64', source: 'POINTER',
});

const identities = await loadVoterIdentities(['Qalice'], ['RESOLVE_IDENTITIES', 'FETCH_ACCOUNT_AVATAR']);

expect(identities.get('Qalice')).toEqual({ address: 'Qalice', avatarSrc: 'blob:mock/image/png', name: 'Alice' });
expect(qdnRequestMock).toHaveBeenLastCalledWith({ action: 'FETCH_ACCOUNT_AVATAR', address: 'Qalice', maxBytes: 500 * 1024 });
});

it('keeps browser fallback name-only and makes no avatar request', async () => {
qdnRequestMock.mockResolvedValueOnce({ data: { name: 'Primary' }, ok: true });

const identities = await loadVoterIdentities(['Qprimary'], []);

expect(identities.get('Qprimary')).toEqual({
address: 'Qprimary',
avatarSrc: 'http://127.0.0.1:24891/arbitrary/THUMBNAIL/Primary/avatar?async=true',
name: 'Primary',
});
expect(identities.get('Qprimary')).toEqual({ address: 'Qprimary', avatarSrc: null, name: 'Primary' });
expect(qdnRequestMock).toHaveBeenCalledTimes(1);
});

it('falls back to the first registered name in API order', async () => {
qdnRequestMock
.mockResolvedValueOnce({ data: null, ok: true })
.mockResolvedValueOnce({ data: [{ name: 'First' }, { name: 'Second' }], ok: true });
it.each([
{ address: 'Qother', body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' },
{ address: 'Qalice', body: 'broken', contentLength: 8, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' },
{ address: 'Qalice', body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/svg+xml', descriptor: null, encoding: 'base64', source: 'LEGACY' },
])('fails closed for malformed avatar responses', async (avatarResponse) => {
qdnRequestMock.mockResolvedValueOnce([{ address: 'Qalice', name: 'Alice' }]).mockResolvedValueOnce(avatarResponse);

const identities = await loadVoterIdentities(['Qfirst'], []);
const identities = await loadVoterIdentities(['Qalice'], ['RESOLVE_IDENTITIES', 'FETCH_ACCOUNT_AVATAR']);

expect(identities.get('Qfirst')?.name).toBe('First');
expect(qdnRequestMock.mock.calls[1][0].path).toBe('/names/address/Qfirst?limit=0');
expect(identities.get('Qalice')).toEqual({ address: 'Qalice', avatarSrc: null, name: 'Alice' });
expect(createObjectURLMock).not.toHaveBeenCalled();
});

it('keeps an address-only fallback when no name resolves', async () => {
it('retries only an explicit pending avatar response', async () => {
vi.useFakeTimers();
qdnRequestMock
.mockRejectedValueOnce(new Error('primary unavailable'))
.mockResolvedValueOnce({ data: [], ok: true });
.mockResolvedValueOnce([{ address: 'Qalice', name: 'Alice' }])
.mockResolvedValueOnce({
address: 'Qalice', descriptor: { identifier: 'avatar', name: 'alice', service: 'THUMBNAIL' },
retryAfterSeconds: 1, source: 'POINTER', status: 'PENDING',
})
.mockResolvedValueOnce({
address: 'Qalice', body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/png',
descriptor: { identifier: 'avatar', name: 'alice', service: 'THUMBNAIL' }, encoding: 'base64', source: 'POINTER',
});

const pending = loadVoterIdentities(['Qalice'], ['RESOLVE_IDENTITIES', 'FETCH_ACCOUNT_AVATAR']);
await vi.runAllTimersAsync();

await expect(loadVoterIdentities(['Qanonymous'], [])).resolves.toEqual(new Map([[
'Qanonymous',
{ address: 'Qanonymous', avatarSrc: null, name: null },
await expect(pending).resolves.toMatchObject(new Map([[
'Qalice', { address: 'Qalice', avatarSrc: 'blob:mock/image/png', name: 'Alice' },
]]));
expect(qdnRequestMock).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});

it('revokes only loaded Blob URLs', () => {
revokeVoterIdentityUrls(new Map([
['Qalice', { address: 'Qalice', avatarSrc: 'blob:mock/image/png', name: 'Alice' }],
['Qbob', { address: 'Qbob', avatarSrc: 'https://node.test/avatar', name: 'Bob' }],
]));

expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock/image/png');
expect(revokeObjectURLMock).toHaveBeenCalledTimes(1);
});
});
Loading