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:
@@ -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[]>(
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user