Compare commits

...

8 Commits

Author SHA1 Message Date
Chad Curtis f7821451c7 release: v2.2.1 2026-03-28 10:15:52 -05:00
Chad Curtis 9056b43696 Highlight flushed posts, fix new-posts pill positioning, fix desktop tab gap
- New posts flushed from the stream buffer now briefly highlight with a
  primary-tinted fade animation so users can see what appeared
- New-posts pill uses responsive CSS (new-posts-pill utility) so it sits
  correctly below the SubHeaderBar on both mobile and desktop
- SubHeaderBar desktop padding moved inside the inner wrapper so the arc
  background extends to the viewport edge, eliminating the gap above tabs
2026-03-28 10:12:11 -05:00
Chad Curtis cdf54e9eff Fix 'new posts' button on search page not loading posts
The button only called window.scrollTo() and relied on the scroll event
listener to auto-flush the stream buffer. This failed when smooth
scrolling didn't fire reliable scroll events (especially on mobile/
Capacitor WebView). Now explicitly calls flushStreamBuffer() on click.
2026-03-28 09:51:30 -05:00
Chad Curtis 7a49e9646c Bypass nudge for encrypt and decrypt, remove phase-transition toast
Encrypt and decrypt operations now call the signer directly without
nudge/timeout/retry wrapping. The sign operation already provides
the user-facing nudge when approval is needed, so the encrypt nudge
was redundant noise. Phase-transition toast and related constants
removed as dead code.
2026-03-28 09:43:43 -05:00
Chad Curtis 2189f5e7c4 Fix MobileTopBar double-opacity: remove bg from header, use dedicated safe-area fill
The header had bg-background/85 plus the ArcBackground SVG fill-background/85
stacking to ~98% opacity. Now the safe-area padding zone gets a single-layer
bg-background/85 fill div (same pattern as pinned SubHeaderBar), and the
ArcBackground provides the only fill for the content area.
2026-03-28 09:39:09 -05:00
Chad Curtis 2822b4c159 Remove decrypt nudge, fix pinned tab safe area fill
- Decrypt operations now bypass signerWithNudge entirely (no nudge toast)
- Pinned SubHeaderBar safe area uses a separate fill div matching
  MobileTopBar's bg-background/85, avoiding double-opacity stacking
  with the ArcBackground SVG below
2026-03-28 09:37:28 -05:00
Chad Curtis 3dd2591709 Fix pinned tab arc, signer toast spam, new-posts pill position, toast swipe
- Pinned SubHeaderBar uses safe-area-inset-top (top offset) instead of
  safe-area-top (padding) so the arc stays flush with the tab content
- Throttle signer nudge toasts (8s cooldown) to prevent rapid-fire storm
  when relay connection is unstable
- New posts pill fades out when nav hides instead of translating, avoiding
  it floating in the safe area zone
- Signer toasts use finite duration (120s) so Radix swipe-to-dismiss works
- Lower toast swipe threshold from 50px to 30px for easier dismissal
2026-03-28 09:32:08 -05:00
Chad Curtis ab2145ffe9 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
2026-03-28 09:16:00 -05:00
17 changed files with 301 additions and 127 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [2.2.1] - 2026-03-28
### Fixed
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.2.0"
versionName "2.2.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.0;
MARKETING_VERSION = 2.2.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.0;
MARKETING_VERSION = 2.2.1;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.2.0",
"version": "2.2.1",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [2.2.1] - 2026-03-28
### Fixed
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
+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[]>(
+6 -1
View File
@@ -24,9 +24,14 @@ export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps)
return (
<header
className="sticky top-0 z-20 sidebar:hidden safe-area-top bg-background/85 transition-transform duration-300 ease-in-out"
className="sticky top-0 z-20 sidebar:hidden safe-area-top transition-transform duration-300 ease-in-out"
style={navHidden ? { transform: 'translateY(calc(-100% - 20px - env(safe-area-inset-top, 0px)))' } : undefined}
>
{/* Safe-area fill — only covers the padding zone above the content with a single layer of bg. */}
<div
className="absolute top-0 left-0 right-0 bg-background/85"
style={{ height: 'env(safe-area-inset-top, 0px)' }}
/>
{/* Relative wrapper so ArcBackground only covers the content area, not the safe-area padding above it. */}
<div className="relative">
<ArcBackground variant={hasSubHeader ? 'rect' : 'down'} />
+4
View File
@@ -109,6 +109,8 @@ interface NoteCardProps {
threaded?: boolean;
/** Like threaded but without the connector line — used for the last item in a thread (e.g. sub-reply hint). */
threadedLast?: boolean;
/** If true, briefly highlight this card (e.g. newly loaded post). */
highlight?: boolean;
}
/** Gets a tag value by name. */
@@ -171,6 +173,7 @@ export const NoteCard = memo(function NoteCard({
compact,
threaded,
threadedLast,
highlight,
}: NoteCardProps) {
const { config } = useAppContext();
const { user } = useCurrentUser();
@@ -1073,6 +1076,7 @@ export const NoteCard = memo(function NoteCard({
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
highlight && "animate-highlight-fade",
className,
)}
onClick={handleCardClick}
+48 -36
View File
@@ -49,47 +49,59 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
return (
<SubHeaderBarContext.Provider value={{ onHover: setHover, onActive: setActive }}>
<div className={cn(
'relative sticky top-mobile-bar sidebar:top-0 sidebar:py-2 z-10',
'relative sticky top-mobile-bar sidebar:top-0 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'} />
{/* Per-tab arc hover highlight: full-width arc, clipped to the hovered tab's x-slice */}
{hover && !noArc && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full pointer-events-none"
style={{
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
clipPath: `inset(0 calc(100% - ${hover.left + hover.width}px) 0 ${hover.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M0,0 L100,0 L100,44 Q50,64 0,44 Z" className="fill-secondary/40" />
</svg>
{/* Safe-area fill — visible only when pinned and nav is hidden, covers the
padding zone above the tabs with the same translucent bg as the MobileTopBar. */}
{pinned && navHidden && (
<div
className="absolute top-0 left-0 right-0 bg-background/85 sidebar:hidden"
style={{ height: 'env(safe-area-inset-top, 0px)' }}
/>
)}
{/* Active tab indicator: the arc's bottom edge as a stroke, clipped to the active tab's x-slice */}
{active && !noArc && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full pointer-events-none"
style={{
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
clipPath: `inset(0 calc(100% - ${active.left + active.width}px) 0 ${active.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M100,44 Q50,64 0,44" fill="none" className="stroke-primary" strokeWidth="3" />
</svg>
)}
{/* Tab content sits above the SVG background */}
<div className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}>
{children}
{/* Inner wrapper so ArcBackground covers only the tab area, not the safe-area padding above.
sidebar:pt-2 adds desktop top padding inside the arc rather than outside it. */}
<div className="relative sidebar:pt-2">
<ArcBackground variant={noArc ? 'rect' : 'down'} />
{/* Per-tab arc hover highlight: full-width arc, clipped to the hovered tab's x-slice */}
{hover && !noArc && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full pointer-events-none"
style={{
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
clipPath: `inset(0 calc(100% - ${hover.left + hover.width}px) 0 ${hover.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M0,0 L100,0 L100,44 Q50,64 0,44 Z" className="fill-secondary/40" />
</svg>
)}
{/* Active tab indicator: the arc's bottom edge as a stroke, clipped to the active tab's x-slice */}
{active && !noArc && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full pointer-events-none"
style={{
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
clipPath: `inset(0 calc(100% - ${active.left + active.width}px) 0 ${active.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M100,44 Q50,64 0,44" fill="none" className="stroke-primary" strokeWidth="3" />
</svg>
)}
{/* Tab content sits above the SVG background */}
<div className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}>
{children}
</div>
</div>
</div>
</SubHeaderBarContext.Provider>
+1 -1
View File
@@ -25,7 +25,7 @@ export function Toaster() {
}, [])
return (
<ToastProvider swipeDirection={isMdScreen ? "right" : "up"}>
<ToastProvider swipeDirection={isMdScreen ? "right" : "up"} swipeThreshold={30}>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
+86 -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,50 @@ 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);
// IDs of events that were just flushed from the buffer (for highlight animation)
const [flushedIds, setFlushedIds] = useState<Set<string>>(new Set());
const flushedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
/** Merge buffered events into the main list and mark them as flushed. */
const doFlush = useCallback(() => {
if (streamBufferRef.current.length === 0) return;
const ids = new Set(streamBufferRef.current.map((e) => e.id));
setAllEvents((prev) => {
const merged = [...prev, ...streamBufferRef.current];
merged.sort((a, b) => b.created_at - a.created_at);
return merged;
});
streamBufferRef.current = [];
setStreamBufferCount(0);
// Show highlight briefly then clear
setFlushedIds(ids);
clearTimeout(flushedTimerRef.current);
flushedTimerRef.current = setTimeout(() => setFlushedIds(new Set()), 1500);
}, []);
// Clean up timer on unmount
useEffect(() => () => clearTimeout(flushedTimerRef.current), []);
// 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) {
doFlush();
}
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [doFlush]);
// Resolve authorPubkeys: accept hex or npub-encoded entries
const resolvedAuthorPubkeys = useMemo(() => {
@@ -180,26 +224,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 +348,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 +379,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 +396,9 @@ 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 = doFlush;
// 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 +412,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, flushedIds };
}
+12
View File
@@ -78,6 +78,18 @@
top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px));
}
/* New-posts pill: just below the SubHeaderBar on both mobile and desktop */
.new-posts-pill {
top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px) + 3.5rem);
}
@media (min-width: 900px) {
.new-posts-pill {
top: 4rem;
}
}
/* Slide the sub-header bar out of view together with the mobile top bar.
Must clear its own height (100%) + top bar + safe area + arc overhang (20px). */
@media (max-width: 899px) {
+26 -46
View File
@@ -2,7 +2,7 @@ import type { NostrEvent, NostrSigner } from '@nostrify/types';
import { createElement } from 'react';
import { toast } from '@/hooks/useToast';
import { androidResume } from '@/lib/androidResume';
import { NudgeToastContent, PhaseToastContent } from '@/components/SignerToastContent';
import { NudgeToastContent } from '@/components/SignerToastContent';
// ---------------------------------------------------------------------------
// Constants
@@ -11,24 +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;
/** Hard timeout — reject the op entirely after this long with no response. */
const HARD_TIMEOUT_MS = 45_000;
/**
* Event kinds whose content is encrypted by the user's signer before signing.
* A signEvent for one of these kinds immediately after a nip44 encrypt is
* treated as the second phase of the same operation (encrypt-then-sign), and
* a phase-transition toast is shown so the user knows a second approval is
* coming.
*
* Only kinds where Ditto calls `user.signer.nip44.encrypt()` then immediately
* `createEvent()` qualify — DM gift-wraps use ephemeral random signers and
* are excluded.
*/
const ENCRYPTED_CONTENT_KINDS = new Set([
10000, // Mute list (NIP-51, private items encrypted to self)
30078, // App settings (NIP-78, content encrypted to self)
]);
/** Max number of automatic retries on Android foreground resume. */
const MAX_RETRIES = 2;
@@ -88,6 +74,15 @@ const RESUME = Symbol('resume');
type Signal = typeof CANCEL | typeof TIMEOUT | typeof RESUME;
// ---------------------------------------------------------------------------
// Toast deduplication — prevent a storm of identical nudge toasts
// ---------------------------------------------------------------------------
/** Timestamp of the last nudge toast shown. Used to throttle. */
let lastNudgeShownAt = 0;
/** Minimum gap between nudge toasts (ms). Prevents rapid-fire replacements. */
const NUDGE_THROTTLE_MS = 8_000;
// ---------------------------------------------------------------------------
// Toast helpers
// ---------------------------------------------------------------------------
@@ -111,6 +106,14 @@ function showNudgeToast(opts: {
const relayOk = isBunkerConnected ? isBunkerConnected() : true;
const subject = labelForOp(kind, opType);
// Throttle: if a nudge was shown recently, return a no-op dismiss handle
// to avoid a storm of rapidly replacing toasts on unstable connections.
const now = Date.now();
if (now - lastNudgeShownAt < NUDGE_THROTTLE_MS) {
return { dismiss: () => {} };
}
lastNudgeShownAt = now;
let title: string;
let descriptionText: string;
@@ -140,7 +143,9 @@ function showNudgeToast(opts: {
onCancel: () => { dismissRef.fn?.(); onCancel(); },
});
const { dismiss } = toast({ title, description, duration: Infinity });
// Use a long but finite duration so Radix swipe-to-dismiss works on mobile.
// The toast is dismissed programmatically on operation completion anyway.
const { dismiss } = toast({ title, description, duration: 120_000 });
dismissRef.fn = dismiss;
return { dismiss };
@@ -151,16 +156,6 @@ function showSuccessToast(opType: OpType): void {
toast({ title: `${verb} approved`, duration: 3000, variant: 'success' });
}
function showPhaseTransitionToast(signKind: number | undefined): void {
const android = isAndroid();
const label = signKind !== undefined ? KIND_LABELS[signKind] : undefined;
const signDesc = label ? `approve ${label} signing` : 'approve signing';
const message = `Encryption approved — now ${signDesc} in your signer app.`;
const description = createElement(PhaseToastContent, { message, android });
toast({ title: 'Step 1 complete', description, duration: 8000 });
}
// ---------------------------------------------------------------------------
// Core: run a signer operation with nudge + retry logic
@@ -221,6 +216,7 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
// --- Nudge timer ---
let dismissNudge: (() => void) | undefined;
const delay = NUDGE_DELAY_MS;
const nudgeTimer = setTimeout(() => {
nudgeFired = true;
const handle = showNudgeToast({
@@ -228,7 +224,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);
@@ -320,11 +316,6 @@ export function signerWithNudge(
signer: NostrSigner,
isBunkerConnected?: () => boolean,
): NostrSigner {
// Multi-phase state: set to true when a nip44 encrypt completes with the
// nudge shown. Cleared on the next signEvent. Used to detect encrypt-then-sign
// flows for kinds whose content is encrypted by the user's signer.
let pendingEncryptNudge = false;
/** Run an op and return just the value (discarding nudge metadata). */
function run<T>(op: () => Promise<T>, kind: number | undefined, opType: OpType): Promise<T> {
return runWithNudge(op, { kind, opType, isBunkerConnected }).then((r) => r.value);
@@ -334,13 +325,6 @@ export function signerWithNudge(
getPublicKey: () => run(() => signer.getPublicKey(), undefined, 'sign'),
signEvent: (event: NostrEvent) => {
// Show a phase-transition toast when signing an event whose content was
// just encrypted by the user's signer and the nudge was shown for that
// encrypt. Only fires for kinds we know use encrypt-then-sign.
if (pendingEncryptNudge && ENCRYPTED_CONTENT_KINDS.has(event.kind)) {
showPhaseTransitionToast(event.kind);
}
pendingEncryptNudge = false;
return run(() => signer.signEvent(event), event.kind, 'sign');
},
};
@@ -357,13 +341,9 @@ export function signerWithNudge(
}) {
return {
encrypt: (pubkey: string, plaintext: string) =>
runWithNudge(() => crypto.encrypt(pubkey, plaintext), { kind: undefined, opType: 'encrypt', isBunkerConnected })
.then(({ value, nudgeFired }) => {
pendingEncryptNudge = nudgeFired;
return value;
}),
crypto.encrypt(pubkey, plaintext),
decrypt: (pubkey: string, ciphertext: string) =>
run(() => crypto.decrypt(pubkey, ciphertext), undefined, 'decrypt'),
crypto.decrypt(pubkey, ciphertext),
};
}
+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
+33 -4
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, flushStreamBuffer, flushedIds } = 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,29 @@ export function SearchPage() {
{/* ─── Posts Tab ─── */}
{activeTab === 'posts' && (
<>
{/* New posts pill — sticks below the SubHeaderBar arc, hides with nav.
Mobile: top = MobileTopBar (2.5rem) + safe-area + SubHeaderBar (~2.5rem).
Desktop: top = SubHeaderBar only (~2.5rem), no MobileTopBar. */}
{newPostCount > 0 && (
<div
className={cn(
'sticky new-posts-pill z-10 flex justify-center pointer-events-none',
'max-sidebar:transition-opacity max-sidebar:duration-300 max-sidebar:ease-in-out',
navHidden && 'max-sidebar:opacity-0 max-sidebar:pointer-events-none',
)}
style={{ marginBottom: '-3rem' }}
>
<button
onClick={() => {
flushStreamBuffer();
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">
@@ -748,14 +776,15 @@ export function SearchPage() {
) : posts.length > 0 ? (
<div>
{posts.map((event) => {
const isNew = flushedIds.has(event.id);
if (isRepostKind(event.kind)) {
const embedded = parseRepostContent(event);
if (embedded) {
return <NoteCard key={event.id} event={embedded} repostedBy={event.pubkey} />;
return <NoteCard key={event.id} event={embedded} repostedBy={event.pubkey} highlight={isNew} />;
}
return null;
}
return <NoteCard key={event.id} event={event} />;
return <NoteCard key={event.id} event={event} highlight={isNew} />;
})}
</div>
) : debouncedSearchQuery.trim() ? (
+10 -5
View File
@@ -102,16 +102,21 @@ export default {
boxShadow: '0 0 8px 2px hsl(var(--primary) / 0.15)'
}
},
'badge-spotlight': {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' }
}
'badge-spotlight': {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' }
},
'highlight-fade': {
from: { backgroundColor: 'hsl(var(--primary) / 0.10)' },
to: { backgroundColor: 'transparent' }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'pending-glow': 'pending-glow 2.5s ease-in-out infinite',
'badge-spotlight': 'badge-spotlight 8s linear infinite'
'badge-spotlight': 'badge-spotlight 8s linear infinite',
'highlight-fade': 'highlight-fade 1.5s ease-out forwards'
}
}
},