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:
Avi 2026-09-01 14:07:36 -05:00
commit ea56806c0c
6 changed files with 360 additions and 59 deletions

View 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 };
}

View file

@ -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. */

View file

@ -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>
);
}

View file

@ -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) {

View 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);
});
});

View file

@ -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();
});
});