fix: show only fully published events in Most Recent Publication box
- Add PublicationStatus type and computePublicationStatus() helper - Add useProfilePublications hook that queries relays via feedGet and determines per-event publication status by comparing served relays against all enabled relays - Rewrite HomeScreen PublicationResult to show only fully published events in the main box; partial events appear only in the expandable Relay results section - Show empty state when no fully published events exist - Add tests: fully published shown, partial hidden, older full shown when newest is partial, all-partial shows empty, relay details expandable, no duplicates - Fix publishFlow integration test to seed profileFeedItems
This commit is contained in:
parent
9cf4003c31
commit
ea56806c0c
6 changed files with 360 additions and 59 deletions
69
frontend/src/lib/publications.ts
Normal file
69
frontend/src/lib/publications.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { FeedItem, PublicationStatus, RelayConfig } from './types';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
/** Determine whether a note is fully or partially published. */
|
||||
export function computePublicationStatus(
|
||||
itemRelays: string[],
|
||||
enabledRelays: RelayConfig[],
|
||||
): PublicationStatus {
|
||||
const enabled = enabledRelays.filter((r) => r.enabled).map((r) => r.url);
|
||||
if (enabled.length === 0) {
|
||||
return 'fully_published';
|
||||
}
|
||||
const served = new Set(itemRelays);
|
||||
const allServed = enabled.every((url) => served.has(url));
|
||||
return allServed ? 'fully_published' : 'partially_published';
|
||||
}
|
||||
|
||||
export interface ProfilePublication extends FeedItem {
|
||||
publicationStatus: PublicationStatus;
|
||||
}
|
||||
|
||||
export interface UseProfilePublicationsResult {
|
||||
publications: ProfilePublication[];
|
||||
fullyPublished: ProfilePublication[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useProfilePublications(): UseProfilePublicationsResult {
|
||||
const { state, feedGet } = useApp();
|
||||
const [publications, setPublications] = useState<ProfilePublication[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const authorNpub = state?.active_profile?.npub ?? null;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!authorNpub) {
|
||||
setPublications([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const items = await feedGet(50, false, authorNpub);
|
||||
const enabledRelays = state?.settings.relays ?? [];
|
||||
const enriched: ProfilePublication[] = items.map((item) => ({
|
||||
...item,
|
||||
publicationStatus: computePublicationStatus(item.relays, enabledRelays),
|
||||
}));
|
||||
enriched.sort((a, b) => b.created_at - a.created_at);
|
||||
setPublications(enriched);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setPublications([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authorNpub, feedGet, state?.settings.relays]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const fullyPublished = publications.filter((p) => p.publicationStatus === 'fully_published');
|
||||
|
||||
return { publications, fullyPublished, loading, error };
|
||||
}
|
||||
|
|
@ -76,6 +76,9 @@ export interface MetadataPublishReport {
|
|||
failed: RelayFailure[];
|
||||
}
|
||||
|
||||
/** Publication status derived from comparing served relays against all enabled relays. */
|
||||
export type PublicationStatus = 'fully_published' | 'partially_published';
|
||||
|
||||
/** A single note shown in the aggregated feed. */
|
||||
export interface FeedItem {
|
||||
/** Bech32 note id. */
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { EmptyState } from '../components/EmptyState';
|
|||
import { Icon } from '../components/Icon';
|
||||
import { shortenNpub } from '../lib/format';
|
||||
import type { Screen } from '../lib/navigation';
|
||||
import type { ProfilePublication } from '../lib/publications';
|
||||
import { useProfilePublications } from '../lib/publications';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
interface HomeScreenProps {
|
||||
|
|
@ -16,7 +18,8 @@ interface HomeScreenProps {
|
|||
}
|
||||
|
||||
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||
const { state, lastPublish, selectProfile } = useApp();
|
||||
const { state, selectProfile } = useApp();
|
||||
const { publications, fullyPublished, loading, error } = useProfilePublications();
|
||||
const [selecting, setSelecting] = useState<string | null>(null);
|
||||
|
||||
const onSelect = async (npub: string) => {
|
||||
|
|
@ -182,7 +185,10 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
|||
</header>
|
||||
<div className="card-body">
|
||||
<PublicationResult
|
||||
lastPublish={lastPublish}
|
||||
publications={publications}
|
||||
fullyPublished={fullyPublished}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onNavigateCompose={() => onNavigate('compose')}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -193,18 +199,34 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
|||
}
|
||||
|
||||
function PublicationResult({
|
||||
lastPublish,
|
||||
publications,
|
||||
fullyPublished,
|
||||
loading,
|
||||
error,
|
||||
onNavigateCompose,
|
||||
}: {
|
||||
lastPublish: ReturnType<typeof useApp>['lastPublish'];
|
||||
publications: ProfilePublication[];
|
||||
fullyPublished: ProfilePublication[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onNavigateCompose: () => void;
|
||||
}) {
|
||||
if (!lastPublish) {
|
||||
if (loading) {
|
||||
return <p className="muted">Loading publications…</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert tone="error" title="Could not load publications">
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (publications.length === 0) {
|
||||
return (
|
||||
<div className="home-publish-empty">
|
||||
<p className="muted">
|
||||
You haven't published anything yet.
|
||||
</p>
|
||||
<p className="muted">You haven't published anything yet.</p>
|
||||
<Button variant="secondary" size="sm" onClick={onNavigateCompose}>
|
||||
<Icon name="edit" size={16} />
|
||||
Compose your first note
|
||||
|
|
@ -213,63 +235,60 @@ function PublicationResult({
|
|||
);
|
||||
}
|
||||
|
||||
if (lastPublish.error) {
|
||||
const newest = fullyPublished[0] ?? null;
|
||||
const newestPartial =
|
||||
publications.find((p) => p.publicationStatus === 'partially_published') ?? null;
|
||||
|
||||
if (!newest && !newestPartial) {
|
||||
return (
|
||||
<Alert tone="error" title="Publication failed" details={lastPublish.details}>
|
||||
{lastPublish.error}
|
||||
</Alert>
|
||||
<div className="home-publish-empty">
|
||||
<p className="muted">No fully published publications found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const report = lastPublish.report;
|
||||
if (!report) {
|
||||
return null;
|
||||
if (!newest) {
|
||||
return (
|
||||
<div className="home-publish-empty">
|
||||
<p className="muted">No fully published publications found.</p>
|
||||
{newestPartial && <PartialPublicationDetails item={newestPartial} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (report.failed.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<div className="publish-result success">
|
||||
<Badge tone="success">
|
||||
<Icon name="check" size={14} /> Published
|
||||
</Badge>
|
||||
<span className="mono" title={report.event_id}>
|
||||
{shortenNpub(report.event_id, true)}
|
||||
<span className="mono" title={newest.id}>
|
||||
{shortenNpub(newest.id, true)}
|
||||
</span>
|
||||
<CopyButton text={report.event_id} label="event ID" />
|
||||
<CopyButton text={newest.id} label="event ID" />
|
||||
</div>
|
||||
{report.content && <p className="publish-preview">{report.content}</p>}
|
||||
{newest.content && <p className="publish-preview">{newest.content}</p>}
|
||||
{newestPartial && newestPartial.id !== newest.id && (
|
||||
<PartialPublicationDetails item={newestPartial} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PartialPublicationDetails({ item }: { item: ProfilePublication }) {
|
||||
const totalRelays = item.relays.length;
|
||||
return (
|
||||
<div className="publish-result">
|
||||
<Alert tone="warning" title="Partially published">
|
||||
The note reached {report.succeeded.length} of{' '}
|
||||
{report.succeeded.length + report.failed.length} enabled relays. Event ID:{' '}
|
||||
<code className="mono" title={report.event_id}>
|
||||
{shortenNpub(report.event_id, true)}
|
||||
</code>
|
||||
</Alert>
|
||||
<CopyButton text={report.event_id} label="event ID" />
|
||||
{report.content && <p className="publish-preview">{report.content}</p>}
|
||||
<details className="alert-details">
|
||||
<summary>Relay results</summary>
|
||||
<ul className="relay-result-list">
|
||||
{report.succeeded.map((url) => (
|
||||
{item.relays.map((url) => (
|
||||
<li key={url} className="ok">
|
||||
<span className="mono">{url}</span> — accepted
|
||||
</li>
|
||||
))}
|
||||
{report.failed.map((failure) => (
|
||||
<li key={failure.url} className="bad">
|
||||
<span className="mono">{failure.url}</span> — {failure.error}
|
||||
</li>
|
||||
))}
|
||||
{totalRelays === 0 && <li className="bad">No relays returned this event.</li>}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
if (!cancelled) {
|
||||
setState(initial);
|
||||
if (initial.last_publish) {
|
||||
setLastPublish({ report: initial.last_publish, error: null, details: null, at: Date.now() });
|
||||
setLastPublish({
|
||||
report: initial.last_publish,
|
||||
error: null,
|
||||
details: null,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
194
frontend/src/test/publications.test.tsx
Normal file
194
frontend/src/test/publications.test.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { HomeScreen } from '../screens/HomeScreen';
|
||||
import { renderWithApp } from './render';
|
||||
import { ALICE } from './apiMock';
|
||||
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||
import { computePublicationStatus } from '../lib/publications';
|
||||
import type { FeedItem, RelayConfig } from '../lib/types';
|
||||
|
||||
const RELAYS: RelayConfig[] = [
|
||||
{ url: 'wss://relay.damus.io', enabled: true },
|
||||
{ url: 'wss://relay.nostr.band', enabled: true },
|
||||
];
|
||||
|
||||
function makeItem(overrides: Partial<FeedItem> & { id: string; relays: string[] }): FeedItem {
|
||||
return {
|
||||
author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||
author_npub: ALICE,
|
||||
content: '',
|
||||
created_at: 1700000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHome(backend: ReturnType<typeof createFakeBackend>) {
|
||||
installFakeBackend(backend);
|
||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
||||
}
|
||||
|
||||
async function waitForData() {
|
||||
await waitFor(() => {
|
||||
const loading = screen.queryByText(/Loading publications/);
|
||||
expect(loading).not.toBeInTheDocument();
|
||||
const emptyNoPub = screen.queryByText("You haven't published anything yet.");
|
||||
expect(emptyNoPub).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
describe('computePublicationStatus', () => {
|
||||
it('returns fully_published when all enabled relays served the event', () => {
|
||||
const item = makeItem({ id: 'a', relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'] });
|
||||
expect(computePublicationStatus(item.relays, RELAYS)).toBe('fully_published');
|
||||
});
|
||||
|
||||
it('returns partially_published when only some relays served the event', () => {
|
||||
const item = makeItem({ id: 'a', relays: ['wss://relay.damus.io'] });
|
||||
expect(computePublicationStatus(item.relays, RELAYS)).toBe('partially_published');
|
||||
});
|
||||
|
||||
it('returns fully_published when no relays are configured', () => {
|
||||
expect(computePublicationStatus(['wss://x'], [])).toBe('fully_published');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeScreen — Most recent publication', () => {
|
||||
it('shows the newest fully published event', async () => {
|
||||
const backend = createFakeBackend();
|
||||
backend.profileFeedItems = [
|
||||
makeItem({
|
||||
id: 'note1full',
|
||||
content: 'Fully published note',
|
||||
created_at: 100,
|
||||
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
|
||||
}),
|
||||
];
|
||||
renderHome(backend);
|
||||
|
||||
await waitForData();
|
||||
expect(screen.getByText('Fully published note')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Published/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/note1full/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides a partially published newest event from the main box', async () => {
|
||||
const backend = createFakeBackend();
|
||||
backend.profileFeedItems = [
|
||||
makeItem({
|
||||
id: 'note1partial',
|
||||
content: 'Partial note',
|
||||
created_at: 100,
|
||||
relays: ['wss://relay.damus.io'],
|
||||
}),
|
||||
];
|
||||
renderHome(backend);
|
||||
|
||||
await waitForData();
|
||||
expect(screen.queryByText('Partial note')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/No fully published publications found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an older fully published event when the newest is partial', async () => {
|
||||
const backend = createFakeBackend();
|
||||
backend.profileFeedItems = [
|
||||
makeItem({
|
||||
id: 'note1partial',
|
||||
content: 'Newer partial',
|
||||
created_at: 200,
|
||||
relays: ['wss://relay.damus.io'],
|
||||
}),
|
||||
makeItem({
|
||||
id: 'note1full',
|
||||
content: 'Older full',
|
||||
created_at: 100,
|
||||
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
|
||||
}),
|
||||
];
|
||||
renderHome(backend);
|
||||
|
||||
await waitForData();
|
||||
expect(screen.getByText('Older full')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Published/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Newer partial')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when all events are partial', async () => {
|
||||
const backend = createFakeBackend();
|
||||
backend.profileFeedItems = [
|
||||
makeItem({
|
||||
id: 'note1a',
|
||||
content: 'Partial A',
|
||||
created_at: 200,
|
||||
relays: ['wss://relay.damus.io'],
|
||||
}),
|
||||
makeItem({
|
||||
id: 'note1b',
|
||||
content: 'Partial B',
|
||||
created_at: 100,
|
||||
relays: ['wss://relay.nostr.band'],
|
||||
}),
|
||||
];
|
||||
renderHome(backend);
|
||||
|
||||
await waitForData();
|
||||
expect(screen.getByText(/No fully published publications found/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Partial A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows partial event details in Relay results expandable', async () => {
|
||||
const backend = createFakeBackend();
|
||||
backend.profileFeedItems = [
|
||||
makeItem({
|
||||
id: 'note1full',
|
||||
content: 'Full note',
|
||||
created_at: 200,
|
||||
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
|
||||
}),
|
||||
makeItem({
|
||||
id: 'note1partial',
|
||||
content: 'Partial note',
|
||||
created_at: 100,
|
||||
relays: ['wss://relay.damus.io'],
|
||||
}),
|
||||
];
|
||||
renderHome(backend);
|
||||
|
||||
await waitForData();
|
||||
expect(screen.getByText('Full note')).toBeInTheDocument();
|
||||
|
||||
const relaySummary = screen.getByText('Relay results');
|
||||
expect(relaySummary).toBeInTheDocument();
|
||||
|
||||
const details = relaySummary.closest('details') as HTMLDetailsElement;
|
||||
details.open = true;
|
||||
details.dispatchEvent(new Event('toggle'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText((_, element) => {
|
||||
return (
|
||||
element?.textContent?.includes('wss://relay.damus.io') === true &&
|
||||
element?.textContent?.includes('accepted') === true &&
|
||||
element?.tagName === 'LI'
|
||||
);
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate events with the same ID', async () => {
|
||||
const backend = createFakeBackend();
|
||||
backend.profileFeedItems = [
|
||||
makeItem({
|
||||
id: 'note1same',
|
||||
content: 'Same event',
|
||||
created_at: 100,
|
||||
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
|
||||
}),
|
||||
];
|
||||
renderHome(backend);
|
||||
|
||||
await waitForData();
|
||||
const matches = screen.getAllByText(/note1same/);
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,23 @@
|
|||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import App from '../App';
|
||||
import { ALICE } from './apiMock';
|
||||
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||
|
||||
describe('publication flow across screens', () => {
|
||||
it('publishes from Compose and shows the result on Home', async () => {
|
||||
const backend = createFakeBackend();
|
||||
backend.state.settings.confirm_before_publish = false;
|
||||
backend.profileFeedItems = [
|
||||
{
|
||||
id: backend.publishReport.event_id,
|
||||
author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||
author_npub: ALICE,
|
||||
content: 'Hello from the flow test',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
|
||||
},
|
||||
];
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
|
@ -24,7 +35,7 @@ describe('publication flow across screens', () => {
|
|||
|
||||
await user.click(screen.getByRole('button', { name: 'Home' }));
|
||||
await screen.findByRole('heading', { name: 'Home' });
|
||||
expect(await screen.findByText('Published')).toBeInTheDocument();
|
||||
expect(screen.getByTitle(backend.publishReport.event_id)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Published/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Hello from the flow test/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue