Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7821451c7 | |||
| 9056b43696 | |||
| cdf54e9eff | |||
| 7a49e9646c | |||
| 2189f5e7c4 | |||
| 2822b4c159 | |||
| 3dd2591709 | |||
| ab2145ffe9 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]>(
|
||||
|
||||
@@ -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'} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, 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
@@ -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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user