Fix mobile UX: sticky safe area, page padding, stream buffering, media CW

- SubHeaderBar pinned mode adds safe-area-top padding when nav is hidden
- Increase signer nudge delay to 10s for decrypt ops (reduces false triggers on Amber)
- Add arc overhang spacer to Search, Notifications, and Profile pages
- Add PageHeader to Search page for consistent top-level layout
- Buffer streamed posts when user is scrolled down to prevent scroll jumps
- Show 'N new posts' pill that tracks SubHeaderBar position and nav state
- MediaCollage respects NIP-36 content warnings (blur/hide/show policy)
- Normalize main element classes across Profile and Notifications pages
This commit is contained in:
Chad Curtis
2026-03-28 09:16:00 -05:00
parent 3ee880d1dd
commit ab2145ffe9
7 changed files with 165 additions and 35 deletions
+41 -12
View File
@@ -6,7 +6,7 @@
*/
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { Images, Play } from 'lucide-react';
import { Images, Play, ShieldAlert } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import type { NostrEvent } from '@nostrify/nostrify';
import { Skeleton } from '@/components/ui/skeleton';
@@ -15,9 +15,11 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Lightbox, LOADING_SENTINEL } from '@/components/ImageGallery';
import { PhotoBottomBar } from '@/components/PhotoBottomBar';
import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { getContentWarning } from '@/lib/contentWarning';
// ── Media type detection ──────────────────────────────────────────────────────
@@ -139,6 +141,8 @@ export interface MediaItem {
allDims: (string | undefined)[];
event: NostrEvent;
hasMultiple: boolean;
/** NIP-36 content warning reason, or empty string if flagged with no reason, or undefined if clean. */
contentWarning?: string;
}
function parseImeta(tags: string[][]): { url: string; blurhash?: string; dim?: string; alt?: string; mime?: string }[] {
@@ -161,6 +165,7 @@ function extractMediaUrls(content: string): string[] {
export function eventToMediaItem(event: NostrEvent): MediaItem | null {
const imeta = parseImeta(event.tags);
const cw = getContentWarning(event);
if (imeta.length > 0) {
const first = imeta[0];
const firstType = detectType(first.url, first.mime, event.kind);
@@ -176,6 +181,7 @@ export function eventToMediaItem(event: NostrEvent): MediaItem | null {
allDims: imeta.map((e) => e.dim),
event,
hasMultiple: imeta.length > 1,
contentWarning: cw,
};
}
if (event.kind === 1) {
@@ -190,6 +196,7 @@ export function eventToMediaItem(event: NostrEvent): MediaItem | null {
allDims: urls.map(() => undefined),
event,
hasMultiple: urls.length > 1,
contentWarning: cw,
};
}
}
@@ -237,12 +244,17 @@ function AudioThumb({ pubkey }: { pubkey: string }) {
function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void }) {
const [loaded, setLoaded] = useState(false);
const { config } = useAppContext();
const hasCW = item.contentWarning !== undefined;
const policy = config.contentWarningPolicy;
const [cwRevealed, setCwRevealed] = useState(false);
const showBlur = hasCW && policy !== 'show' && !cwRevealed;
return (
<button
className="relative overflow-hidden rounded-lg bg-muted group focus:outline-none focus-visible:ring-2 focus-visible:ring-primary w-full h-full"
onClick={onClick}
aria-label="View media"
onClick={showBlur ? (e) => { e.stopPropagation(); setCwRevealed(true); } : onClick}
aria-label={showBlur ? 'Reveal sensitive content' : 'View media'}
>
{item.blurhash && (
<Blurhash
@@ -252,7 +264,7 @@ function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void })
resolutionX={32}
resolutionY={32}
punch={1}
className={cn('absolute inset-0 transition-opacity duration-300', loaded ? 'opacity-0' : 'opacity-100')}
className={cn('absolute inset-0 transition-opacity duration-300', loaded && !showBlur ? 'opacity-0' : 'opacity-100')}
style={{ width: '100%', height: '100%' }}
/>
)}
@@ -260,7 +272,7 @@ function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void })
<Skeleton className="absolute inset-0 w-full h-full rounded-none" />
)}
{item.type === 'video' && (
{item.type === 'video' && !showBlur && (
<video
src={item.url}
className={cn('absolute inset-0 w-full h-full object-cover transition-opacity duration-300 group-hover:scale-[1.04]', loaded ? 'opacity-100' : 'opacity-0')}
@@ -272,7 +284,7 @@ function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void })
onLoadedData={() => setLoaded(true)}
/>
)}
{item.type === 'image' && (
{item.type === 'image' && !showBlur && (
<img
src={item.url}
alt={item.alt ?? ''}
@@ -281,12 +293,22 @@ function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void })
onLoad={() => setLoaded(true)}
/>
)}
{item.type === 'audio' && (
{item.type === 'audio' && !showBlur && (
<AudioThumb pubkey={item.event.pubkey} />
)}
{/* Content warning overlay — matches sidebar presentation */}
{showBlur && (
<>
<div className="absolute inset-0 bg-muted/60 blur-lg" />
<div className="absolute inset-0 flex items-center justify-center z-10">
<ShieldAlert className="size-5 text-muted-foreground" />
</div>
</>
)}
{/* Play badge for video */}
{item.type === 'video' && (
{item.type === 'video' && !showBlur && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="bg-black/50 rounded-full p-2">
<Play className="size-5 text-white fill-white" />
@@ -294,12 +316,14 @@ function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void })
</div>
)}
{item.hasMultiple && item.type === 'image' && (
{item.hasMultiple && item.type === 'image' && !showBlur && (
<div className="absolute top-1.5 right-1.5 bg-black/60 text-white rounded p-0.5">
<Images className="size-3.5" />
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/15 transition-colors duration-200" />
{!showBlur && (
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/15 transition-colors duration-200" />
)}
</button>
);
}
@@ -376,10 +400,15 @@ interface MediaCollageProps {
export function MediaCollage({ events, className, initialOpenUrl, onInitialOpenConsumed, onNearEnd, hasNextPage }: MediaCollageProps) {
const isMobile = useIsMobile();
const { config } = useAppContext();
const items = useMemo(
() => events.map(eventToMediaItem).filter((x): x is MediaItem => x !== null),
[events],
() => events
.map(eventToMediaItem)
.filter((x): x is MediaItem => x !== null)
// Filter out content-warned items when policy is 'hide'
.filter((x) => !(x.contentWarning !== undefined && config.contentWarningPolicy === 'hide')),
[events, config.contentWarningPolicy],
);
const flat = useMemo<FlatEntry[]>(
+2 -2
View File
@@ -51,9 +51,9 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
<div className={cn(
'relative sticky top-mobile-bar sidebar:top-0 sidebar:py-2 z-10',
pinned
? 'max-sidebar:transition-[top] max-sidebar:duration-300 max-sidebar:ease-in-out'
? 'max-sidebar:transition-[top,padding-top] max-sidebar:duration-300 max-sidebar:ease-in-out'
: 'max-sidebar:transition-transform max-sidebar:duration-300 max-sidebar:ease-in-out',
navHidden && (pinned ? 'max-sidebar:!top-0' : 'nav-hidden-slide'),
navHidden && (pinned ? 'max-sidebar:!top-0 max-sidebar:safe-area-top' : 'nav-hidden-slide'),
className,
)}>
<ArcBackground variant={noArc ? 'rect' : 'down'} />
+78 -13
View File
@@ -1,5 +1,5 @@
import { useNostr } from '@nostrify/react';
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useFeedSettings } from './useFeedSettings';
import { useMuteList } from './useMuteList';
import { useContentFilters } from './useContentFilters';
@@ -140,6 +140,33 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
const { shouldFilterEvent } = useContentFilters();
const [allEvents, setAllEvents] = useState<NostrEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Buffer for streamed events — held separately until user scrolls back up
const streamBufferRef = useRef<NostrEvent[]>([]);
const [streamBufferCount, setStreamBufferCount] = useState(0);
// Track whether initial batch has loaded
const initialLoadDoneRef = useRef(false);
// Track whether user has scrolled away from the top
const isScrolledRef = useRef(false);
// Monitor scroll position — only buffer when user is scrolled down
useEffect(() => {
const threshold = 200; // px from top
function onScroll() {
isScrolledRef.current = window.scrollY > threshold;
// Auto-flush when user scrolls back to the top
if (!isScrolledRef.current && streamBufferRef.current.length > 0) {
setAllEvents((prev) => {
const merged = [...prev, ...streamBufferRef.current];
merged.sort((a, b) => b.created_at - a.created_at);
return merged;
});
streamBufferRef.current = [];
setStreamBufferCount(0);
}
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
// Resolve authorPubkeys: accept hex or npub-encoded entries
const resolvedAuthorPubkeys = useMemo(() => {
@@ -180,26 +207,49 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
setAllEvents([]);
setIsLoading(true);
initialLoadDoneRef.current = false;
streamBufferRef.current = [];
setStreamBufferCount(0);
const eventMap = new Map<string, NostrEvent>();
// Track IDs already in the initial batch to avoid dupes in the buffer
const knownIds = new Set<string>();
function addEvent(event: NostrEvent) {
function addEvent(event: NostrEvent, isStreamed: boolean) {
if (!alive) return;
const now = Math.floor(Date.now() / 1000);
if (event.created_at > now) return;
// Addressable events (30000-39999) dedupe by pubkey+kind+d
// Dedupe key
let dedupeKey: string;
if (event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
const key = `${event.pubkey}:${event.kind}:${dTag}`;
const existing = eventMap.get(key);
if (existing && existing.created_at >= event.created_at) return;
eventMap.set(key, event);
dedupeKey = `${event.pubkey}:${event.kind}:${dTag}`;
} else {
if (eventMap.has(event.id)) return;
eventMap.set(event.id, event);
dedupeKey = event.id;
}
// Buffer streamed events only when user is scrolled down to avoid scroll jumps.
// If at the top, merge immediately (natural top-insertion behavior).
if (isStreamed && initialLoadDoneRef.current && isScrolledRef.current) {
if (knownIds.has(dedupeKey)) return;
knownIds.add(dedupeKey);
streamBufferRef.current = [...streamBufferRef.current, event];
setStreamBufferCount(streamBufferRef.current.length);
return;
}
// Addressable events (30000-39999) dedupe by pubkey+kind+d
if (event.kind >= 30000 && event.kind < 40000) {
const existing = eventMap.get(dedupeKey);
if (existing && existing.created_at >= event.created_at) return;
eventMap.set(dedupeKey, event);
} else {
if (eventMap.has(dedupeKey)) return;
eventMap.set(dedupeKey, event);
}
knownIds.add(dedupeKey);
setAllEvents(Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at));
}
@@ -281,12 +331,15 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
{ signal: ac.signal },
);
for (const event of events) {
addEvent(event);
addEvent(event, false);
}
} catch {
// abort expected
}
if (alive) setIsLoading(false);
if (alive) {
initialLoadDoneRef.current = true;
setIsLoading(false);
}
})();
// 2. Stream new events WITHOUT search (relays don't support streaming search)
@@ -309,7 +362,7 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
if (!alive) break;
if (msg[0] === 'EVENT') {
addEvent(msg[2]);
addEvent(msg[2], true);
} else if (msg[0] === 'CLOSED') {
break;
}
@@ -326,6 +379,18 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; options.protocols is stabilized via protocolsKey; kindsOverride is stabilized via kindsOverrideKey; authorPubkeys is stabilized via authorPubkeysKey
}, [nostr, query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort]);
// Flush buffered streamed events into the main list (called by UI when user wants to see new posts)
const flushStreamBuffer = useCallback(() => {
if (streamBufferRef.current.length === 0) return;
setAllEvents((prev) => {
const merged = [...prev, ...streamBufferRef.current];
merged.sort((a, b) => b.created_at - a.created_at);
return merged;
});
streamBufferRef.current = [];
setStreamBufferCount(0);
}, []);
// Apply client-side filters (including mute filtering and content filters) without restarting the stream
const posts = useMemo(() => {
const authorSet = resolvedAuthorPubkeys ? new Set(resolvedAuthorPubkeys) : undefined;
@@ -339,5 +404,5 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- using specific options fields instead of the whole object for granular reactivity
}, [allEvents, options.includeReplies, options.mediaType, protocolsKey, query, muteItems, resolvedAuthorPubkeys, shouldFilterEvent, authorPubkeysKey]);
return { posts, isLoading };
return { posts, isLoading, newPostCount: streamBufferCount, flushStreamBuffer };
}
+6 -1
View File
@@ -11,6 +11,10 @@ import { NudgeToastContent, PhaseToastContent } from '@/components/SignerToastCo
/** Show the nudge toast after this delay if a signer op is still pending. */
const NUDGE_DELAY_MS = 4_000;
/** Longer delay for decrypt operations — auto-approve is common and nudging
* too early sends the user to the signer with nothing to approve. */
const NUDGE_DELAY_DECRYPT_MS = 10_000;
/** Hard timeout — reject the op entirely after this long with no response. */
const HARD_TIMEOUT_MS = 45_000;
@@ -221,6 +225,7 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
// --- Nudge timer ---
let dismissNudge: (() => void) | undefined;
const delay = opType === 'decrypt' ? NUDGE_DELAY_DECRYPT_MS : NUDGE_DELAY_MS;
const nudgeTimer = setTimeout(() => {
nudgeFired = true;
const handle = showNudgeToast({
@@ -228,7 +233,7 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
onCancel: () => cancelSignal.resolve(CANCEL),
});
dismissNudge = handle.dismiss;
}, NUDGE_DELAY_MS);
}, delay);
// --- Hard timeout ---
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
+3 -1
View File
@@ -39,6 +39,7 @@ import { Button } from '@/components/ui/button';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import type { BadgeData } from '@/components/BadgeContent';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
type NotificationTab = 'all' | 'mentions';
@@ -207,7 +208,7 @@ export function NotificationsPage() {
];
return (
<main className="">
<main className="flex-1 min-w-0">
{/* Tab bar */}
<SubHeaderBar>
{tabs.map(({ key, label }) => (
@@ -220,6 +221,7 @@ export function NotificationsPage() {
/>
))}
</SubHeaderBar>
<div style={{ height: ARC_OVERHANG_PX }} />
{/* Content */}
<PullToRefresh onRefresh={handleRefresh}>
+7 -4
View File
@@ -96,6 +96,7 @@ import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { formatNumber } from '@/lib/formatNumber';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { cn } from '@/lib/utils';
import type { AddrCoords } from '@/hooks/useEvent';
import type { FeedItem } from '@/lib/feedUtils';
@@ -1763,7 +1764,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
// If we're resolving a NIP-05, show loading state
if (isNip05Param && nip05Loading) {
return (
<main className="">
<main className="flex-1 min-w-0">
<div className="h-36 md:h-48 bg-secondary animate-pulse" />
<div className="px-4 pb-4">
<div className="flex justify-between items-start -mt-12 md:-mt-16 mb-3">
@@ -1778,7 +1779,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
// If NIP-05 resolved to null (not found), show error
if (isNip05Param && !nip05Loading) {
return (
<main className="">
<main className="flex-1 min-w-0">
<div className="p-8 text-center text-muted-foreground">
<p>User not found: {npub}</p>
<p className="text-xs mt-2">Could not resolve this NIP-05 identifier.</p>
@@ -1787,7 +1788,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
);
}
return (
<main className="">
<main className="flex-1 min-w-0">
<div className="p-8 text-center text-muted-foreground">
<p>Please log in to view your profile.</p>
</div>
@@ -1796,7 +1797,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
}
return (
<main>
<main className="flex-1 min-w-0">
<PullToRefresh onRefresh={handleRefresh}>
{/* Banner */}
<div className="h-36 md:h-48 bg-secondary relative">
@@ -2345,6 +2346,8 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
)}
</SubHeaderBar>
<div style={{ height: ARC_OVERHANG_PX }} />
{/* Add/edit single tab modal */}
{pubkey && (
<ProfileTabEditModal
+28 -2
View File
@@ -47,9 +47,11 @@ import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { cn, parseKindFilter } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useLayoutOptions, useNavHidden } from '@/contexts/LayoutContext';
import { PageHeader } from '@/components/PageHeader';
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
import { nip19 } from 'nostr-tools';
@@ -93,6 +95,7 @@ export function SearchPage() {
});
useLayoutOptions({ hasSubHeader: true });
const navHidden = useNavHidden();
const [searchParams, setSearchParams] = useSearchParams();
@@ -397,7 +400,7 @@ export function SearchPage() {
? authorPubkeys
: undefined;
const { posts, isLoading: postsLoading } = useStreamPosts(debouncedSearchQuery, {
const { posts, isLoading: postsLoading, newPostCount } = useStreamPosts(debouncedSearchQuery, {
includeReplies,
mediaType,
language,
@@ -410,10 +413,12 @@ export function SearchPage() {
return (
<main className="flex-1 min-w-0">
<PageHeader title="Search" icon={<SearchIcon className="size-5" />} />
<SubHeaderBar>
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
</SubHeaderBar>
<div style={{ height: ARC_OVERHANG_PX }} />
{/* Search input bar — always rendered right after tabs, like ComposeBox on Feed */}
<div className="px-4 py-3">
@@ -738,6 +743,27 @@ export function SearchPage() {
{/* ─── Posts Tab ─── */}
{activeTab === 'posts' && (
<>
{/* New posts pill — sticks below the SubHeaderBar arc, tracks nav show/hide */}
{newPostCount > 0 && (
<div
className={cn(
'sticky z-10 flex justify-center pointer-events-none',
'max-sidebar:transition-transform max-sidebar:duration-300 max-sidebar:ease-in-out',
navHidden && 'nav-hidden-slide',
)}
style={{
top: 'calc(var(--top-bar-height) + env(safe-area-inset-top, 0px) + 4rem)',
marginBottom: '-3rem',
}}
>
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
>
{newPostCount} new post{newPostCount !== 1 ? 's' : ''}
</button>
</div>
)}
{/* Post results — stream */}
{postsLoading && posts.length === 0 ? (
<div className="divide-y divide-border">