Merge branch 'main' of gitlab.com:soapbox-pub/agora into feat/my-square

# Conflicts:
#	src/components/TopNav.tsx
#	src/components/auth/AccountSwitcher.tsx
This commit is contained in:
Chad Curtis
2026-05-24 16:27:43 -05:00
322 changed files with 40807 additions and 10719 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import { useProfileBadges } from '@/hooks/useProfileBadges';
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
import { cn } from '@/lib/utils';
export interface AcceptBadgeButtonProps {
interface AcceptBadgeButtonProps {
/** The kind 8 badge award event. */
awardEvent: NostrEvent;
/** Prominent pill style (large, rounded-full, colored). Otherwise compact outline variant. */
-24
View File
@@ -5,7 +5,6 @@ import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Skeleton } from '@/components/ui/skeleton';
import { NostrURI } from '@/lib/NostrURI';
import { hexToBase36 } from '@/lib/nsiteSubdomain';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -335,26 +334,3 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
);
}
/** Skeleton loading state for AppHandlerContent. */
export function AppHandlerSkeleton() {
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
<Skeleton className="aspect-[2/1] w-full" />
<div className="px-4 pb-4 space-y-3">
<div className="-mt-10">
<Skeleton className="size-20 rounded-2xl border-4 border-background" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
</div>
</div>
);
}
+19 -189
View File
@@ -1,10 +1,8 @@
import { ReactNode, useLayoutEffect, useEffect, useRef } from 'react';
import { ReactNode, useLayoutEffect } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext';
import { builtinThemes, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
import { resolveTheme } from '@/themes';
import { AppConfigSchema } from '@/lib/schemas';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils';
import { z } from 'zod';
interface AppProviderProps {
@@ -84,11 +82,8 @@ export function AppProvider(props: AppProviderProps) {
updateConfig,
};
// Apply theme effects to document
useApplyTheme(config.theme, config.customTheme, config.themes);
useApplyFonts(config.theme, config.customTheme, config.themes);
useApplyBackground(config.theme, config.customTheme, config.themes);
useApplyFavicon(config.theme, config.customTheme, config.themes);
// Keep the .dark class on <html> in sync with the active theme.
useApplyTheme(config.theme);
return (
<AppContext.Provider value={appContextValue}>
@@ -98,197 +93,32 @@ export function AppProvider(props: AppProviderProps) {
}
/**
* Hook to apply theme changes to the document root via an injected <style> tag.
* When theme is "system", resolves to "light" or "dark" based on OS preference
* and listens for changes to prefers-color-scheme.
* When theme is "custom", uses the provided customTheme colors (derived to full tokens).
* When theme is "light" or "dark", uses configured themes if available, otherwise builtin themes.
* Hook to apply the active theme to `<html>` by setting its className.
*
* Colors are defined statically in `src/index.css` — this hook only flips
* the `.dark` class so the right block wins. When the user picks
* `"system"`, we also subscribe to `prefers-color-scheme` changes so the
* app follows the OS in real time.
*
* There is no runtime CSS-variable injection, no font loading from event
* data, no background image, no recolored favicon. Anything that could
* paint untrusted strings into the document has been removed.
*/
function useApplyTheme(theme: Theme, customTheme: ThemeConfig | undefined, themes: ThemesConfig | undefined) {
function useApplyTheme(theme: Theme) {
useLayoutEffect(() => {
function apply() {
const resolved = resolveTheme(theme);
let css: string;
if (resolved === 'custom') {
// Use custom theme colors, falling back to dark if not yet set
const colors = customTheme?.colors ?? builtinThemes.dark;
css = buildThemeCssFromCore(colors);
} else {
css = buildThemeCssFromCore(resolveThemeConfig(resolved, themes).colors);
}
let el = document.getElementById('theme-vars') as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = 'theme-vars';
document.head.appendChild(el);
}
el.textContent = css;
document.documentElement.className = resolved;
// Now that CSS variables are set, the inline body background from
// theme.js is no longer needed — bg-background will resolve correctly.
document.documentElement.className = resolveTheme(theme);
// The inline body background set by public/theme.js before React
// mounted is no longer needed once Tailwind's bg-background kicks in.
document.body.removeAttribute('style');
}
apply();
// When theme is "system", listen for OS color scheme changes
if (theme === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}
}, [theme, customTheme, themes]);
}
/**
* Hook to load and apply custom fonts when the theme config changes.
* Applies fonts from custom themes, or from configured light/dark themes if available.
*/
function useApplyFonts(theme: Theme, customTheme: ThemeConfig | undefined, themes: ThemesConfig | undefined) {
const resolved = resolveTheme(theme);
const activeConfig = resolved === 'custom' ? customTheme : resolveThemeConfig(resolved, themes);
const fontFamily = activeConfig?.font?.family;
const fontUrl = activeConfig?.font?.url;
const titleFontFamily = activeConfig?.titleFont?.family;
const titleFontUrl = activeConfig?.titleFont?.url;
useEffect(() => {
if (fontFamily) {
loadAndApplyFont({ family: fontFamily, url: fontUrl });
} else {
// Clear any custom font overrides when no font is configured
loadAndApplyFont(undefined);
}
}, [theme, fontFamily, fontUrl]);
useEffect(() => {
if (titleFontFamily) {
loadAndApplyTitleFont({ family: titleFontFamily, url: titleFontUrl });
} else {
// Clear any custom title font overrides when no title font is configured
loadAndApplyTitleFont(undefined);
}
}, [theme, titleFontFamily, titleFontUrl]);
}
/** Style element ID for background image CSS. */
const BG_STYLE_ID = 'theme-background';
/**
* Hook to apply or remove a background image when the theme config changes.
* Supports backgrounds from custom themes and configured light/dark themes.
*/
function useApplyBackground(theme: Theme, customTheme: ThemeConfig | undefined, themes: ThemesConfig | undefined) {
const resolved = resolveTheme(theme);
const activeConfig = resolved === 'custom' ? customTheme : resolveThemeConfig(resolved, themes);
const bgUrl = activeConfig?.background?.url;
const bgMode = activeConfig?.background?.mode ?? 'cover';
useEffect(() => {
let style = document.getElementById(BG_STYLE_ID) as HTMLStyleElement | null;
if (!bgUrl) {
style?.remove();
return;
}
if (!style) {
style = document.createElement('style');
style.id = BG_STYLE_ID;
document.head.appendChild(style);
}
let css: string;
if (bgMode === 'tile') {
css = `body { background-image: url("${bgUrl}"); background-repeat: repeat; background-size: auto; }`;
} else {
css = `body { background-image: url("${bgUrl}"); background-size: cover; background-repeat: no-repeat; background-position: center; background-attachment: fixed; }`;
}
style.textContent = css;
return () => {
document.getElementById(BG_STYLE_ID)?.remove();
};
}, [theme, bgUrl, bgMode]);
}
/**
* Hook to dynamically recolor the favicon to match the current primary color.
* Uses a mask approach with /logo.svg: loads the SVG, draws it as a mask
* on a canvas filled with the primary color, and sets the result as the favicon.
*/
function useApplyFavicon(theme: Theme, customTheme: ThemeConfig | undefined, themes: ThemesConfig | undefined) {
const resolved = resolveTheme(theme);
const colors = resolved === 'custom'
? (customTheme?.colors ?? builtinThemes.dark)
: resolveThemeConfig(resolved, themes).colors;
const primary = colors.primary;
// Cache the loaded SVG blob URL across renders.
const svgBlobUrlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
async function updateFavicon() {
// Load the SVG once and cache it as a blob URL for canvas use.
if (!svgBlobUrlRef.current) {
try {
const resp = await fetch('/logo.svg');
const text = await resp.text();
const blob = new Blob([text], { type: 'image/svg+xml' });
svgBlobUrlRef.current = URL.createObjectURL(blob);
} catch {
return; // Silently fail if SVG can't be loaded
}
}
if (cancelled) return;
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = new Image();
img.src = svgBlobUrlRef.current!;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject();
});
if (cancelled) return;
// Fill the canvas with the primary color.
const { h, s, l } = parseHsl(primary);
const [r, g, b] = hslToRgb(h, s, l);
ctx.fillStyle = rgbToHex(r, g, b);
ctx.fillRect(0, 0, size, size);
// Use the SVG as a mask (destination-in keeps only the logo shape).
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(img, 0, 0, size, size);
const dataUrl = canvas.toDataURL('image/png');
// Update the favicon link element.
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (link) {
link.type = 'image/png';
link.href = dataUrl;
}
}
updateFavicon();
return () => {
cancelled = true;
};
}, [primary]);
}, [theme]);
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { cn } from '@/lib/utils';
/** Arc overhang for the downward arc (top bar / sub-header). */
export const ARC_OVERHANG_PX = 20;
const ARC_OVERHANG_PX = 20;
/** Larger overhang for the upward arc (bottom nav) so the harsher curve isn't clipped. */
export const ARC_UP_OVERHANG_PX = 28;
const ARC_UP_OVERHANG_PX = 28;
/** SVG path for a downward angled bar (used by top bar and sub-header bar).
* Bottom edge slopes from each corner down to a center apex, forming an
+7 -3
View File
@@ -11,6 +11,7 @@ import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
import { useAuthor } from '@/hooks/useAuthor';
import { useImageProxy } from '@/hooks/useImageProxy';
import { getDisplayName } from '@/lib/getDisplayName';
import { formatTime } from '@/lib/formatTime';
import { cn } from '@/lib/utils';
@@ -50,6 +51,7 @@ export function MusicTrackContent({ event }: { event: NostrEvent }) {
const player = useAudioPlayer();
const parsed = useMemo(() => parseMusicTrack(event), [event]);
const author = useAuthor(event.pubkey);
const proxy = useImageProxy();
if (!parsed) return null;
@@ -79,7 +81,7 @@ export function MusicTrackContent({ event }: { event: NostrEvent }) {
{/* Cover artwork — clicking anywhere here plays/pauses */}
{parsed.artwork ? (
<div className="relative aspect-square max-h-[280px] w-full overflow-hidden cursor-pointer" onClick={handlePlay}>
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" loading="lazy" />
<img src={proxy(parsed.artwork, 600)} alt={parsed.title} className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 flex items-center justify-center bg-black/0 hover:bg-black/10 transition-colors">
<PlayButton isPlaying={player.isPlaying} isActive={isNowPlaying} onClick={handlePlay} size="lg" />
</div>
@@ -112,6 +114,7 @@ export function MusicTrackContent({ event }: { event: NostrEvent }) {
export function MusicPlaylistContent({ event }: { event: NostrEvent }) {
const parsed = useMemo(() => parseMusicPlaylist(event), [event]);
const proxy = useImageProxy();
if (!parsed) return null;
@@ -122,7 +125,7 @@ export function MusicPlaylistContent({ event }: { event: NostrEvent }) {
{/* Cover artwork — clicks bubble up to NoteCard for navigation */}
{parsed.artwork ? (
<div className="aspect-video max-h-[200px] w-full overflow-hidden">
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" loading="lazy" />
<img src={proxy(parsed.artwork, 600)} alt={parsed.title} className="w-full h-full object-cover" loading="lazy" />
</div>
) : (
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px]">
@@ -151,6 +154,7 @@ export function PodcastEpisodeContent({ event }: { event: NostrEvent }) {
const player = useAudioPlayer();
const parsed = useMemo(() => parsePodcastEpisode(event), [event]);
const author = useAuthor(event.pubkey);
const proxy = useImageProxy();
const displayName = getDisplayName(author.data?.metadata, event.pubkey);
if (!parsed) return null;
@@ -182,7 +186,7 @@ export function PodcastEpisodeContent({ event }: { event: NostrEvent }) {
{/* Cover artwork — clicking anywhere here plays/pauses */}
{parsed.artwork ? (
<div className="relative aspect-square max-h-[280px] w-full overflow-hidden cursor-pointer" onClick={handlePlay}>
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" loading="lazy" />
<img src={proxy(parsed.artwork, 600)} alt={parsed.title} className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 flex items-center justify-center bg-black/0 hover:bg-black/10 transition-colors">
<PlayButton isPlaying={player.isPlaying} isActive={isNowPlaying} onClick={handlePlay} size="lg" />
</div>
+20 -7
View File
@@ -1,8 +1,9 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Award } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCardTilt } from '@/hooks/useCardTilt';
import { useImageProxy } from '@/hooks/useImageProxy';
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
interface BadgeContentProps {
@@ -76,10 +77,19 @@ const INTERACT_PAD = 48;
function BadgeImageTilt({ heroImage, badgeName }: { heroImage?: string; badgeName: string }) {
const tilt = useCardTilt(25, 1.08);
const glareRef = useRef<HTMLDivElement>(null);
const proxy = useImageProxy();
const [proxyFailed, setProxyFailed] = useState(false);
const imageMask: React.CSSProperties | undefined = heroImage ? {
maskImage: `url(${heroImage})`,
WebkitMaskImage: `url(${heroImage})`,
// Proxy the hero at 256 (the rendered `size-28` ≈ 112px, 2× DPR). The
// `<img>` and the CSS `mask-image` must use the same URL, otherwise the
// glare overlay would mask against a different image than the visible one.
const proxied = heroImage ? proxy(heroImage, 256) : heroImage;
const usingProxy = proxied !== heroImage;
const displaySrc = proxyFailed || !usingProxy ? heroImage : proxied;
const imageMask: React.CSSProperties | undefined = displaySrc ? {
maskImage: `url(${displaySrc})`,
WebkitMaskImage: `url(${displaySrc})`,
maskSize: 'cover',
WebkitMaskSize: 'cover',
maskRepeat: 'no-repeat',
@@ -132,13 +142,16 @@ function BadgeImageTilt({ heroImage, badgeName }: { heroImage?: string; badgeNam
onPointerLeave={handlePointerLeave}
className="relative z-[1] select-none"
>
{heroImage ? (
{displaySrc ? (
<img
src={heroImage}
src={displaySrc}
alt={badgeName}
className="size-28 rounded-2xl object-cover drop-shadow-lg"
loading="lazy"
decoding="async"
onError={() => {
if (usingProxy && !proxyFailed) setProxyFailed(true);
}}
/>
) : (
<div className="size-28 rounded-2xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
@@ -146,7 +159,7 @@ function BadgeImageTilt({ heroImage, badgeName }: { heroImage?: string; badgeNam
</div>
)}
{/* Specular glare overlay */}
{heroImage && imageMask && (
{displaySrc && imageMask && (
<div
ref={glareRef}
className="absolute pointer-events-none"
+16 -3
View File
@@ -28,6 +28,7 @@ import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
@@ -497,12 +498,21 @@ function BadgeHero({ heroImage, badgeName }: { heroImage: string; badgeName: str
const tilt = useCardTilt(30, 1.06);
const glareRef = useRef<HTMLDivElement>(null);
const glareFadeTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const proxy = useImageProxy();
const [proxyFailed, setProxyFailed] = useState(false);
// Proxy at 320 — rendered `size-36` is 144px, 2× DPR = 288. Round up.
// Mask URL and <img> src must match; both follow the same fallback path
// so the glare overlay always clips to the visible image.
const proxied = proxy(heroImage, 320);
const usingProxy = proxied !== heroImage;
const displaySrc = proxyFailed || !usingProxy ? heroImage : proxied;
// Mask string that clips overlays to the badge image's visible pixels.
// This ensures glare and edge effects don't paint over transparent areas.
const imageMask: React.CSSProperties = {
maskImage: `url(${heroImage})`,
WebkitMaskImage: `url(${heroImage})`,
maskImage: `url(${displaySrc})`,
WebkitMaskImage: `url(${displaySrc})`,
maskSize: 'cover',
WebkitMaskSize: 'cover',
maskRepeat: 'no-repeat',
@@ -614,11 +624,14 @@ function BadgeHero({ heroImage, badgeName }: { heroImage: string; badgeName: str
className="relative select-none"
>
<img
src={heroImage}
src={displaySrc}
alt={badgeName}
className="size-36 object-cover drop-shadow-lg"
loading="lazy"
draggable={false}
onError={() => {
if (usingProxy && !proxyFailed) setProxyFailed(true);
}}
/>
{/* Specular glare overlay — masked to the image's alpha channel */}
<div
+17 -3
View File
@@ -1,8 +1,9 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { Award } from 'lucide-react';
import type { BadgeData } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { useImageProxy } from '@/hooks/useImageProxy';
import { cn } from '@/lib/utils';
interface BadgeThumbnailProps {
@@ -21,6 +22,8 @@ interface BadgeThumbnailProps {
*/
export function BadgeThumbnail({ badge, size = 48, className }: BadgeThumbnailProps) {
const thumbUrl = pickThumb(badge, size);
const proxy = useImageProxy();
const [proxyFailed, setProxyFailed] = useState(false);
// Tight perspective relative to the small thumbnail makes the 3D rotation
// clearly visible even on 28-48px elements.
const tilt = useCardTilt(35, 1.15, size * 3);
@@ -46,6 +49,14 @@ export function BadgeThumbnail({ badge, size = 48, className }: BadgeThumbnailPr
touchAction: 'auto',
};
// Proxy at 2× the rendered size (retina), with a floor of 128 so the smallest
// 28px callouts still get a reasonable image. wsrv.nl won't upscale below
// the source's natural size.
const proxyWidth = Math.max(size * 2, 128);
const proxied = thumbUrl ? proxy(thumbUrl, proxyWidth) : thumbUrl;
const usingProxy = proxied !== thumbUrl;
const finalSrc = proxyFailed || !usingProxy ? thumbUrl : proxied;
return (
<div
ref={tilt.ref}
@@ -53,14 +64,17 @@ export function BadgeThumbnail({ badge, size = 48, className }: BadgeThumbnailPr
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
{thumbUrl ? (
{finalSrc ? (
<img
src={thumbUrl}
src={finalSrc}
alt={badge.name}
className={cn('rounded-lg object-cover', className)}
style={{ width: size, height: size }}
loading="lazy"
decoding="async"
onError={() => {
if (usingProxy && !proxyFailed) setProxyFailed(true);
}}
/>
) : (
<div
+11 -12
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
@@ -62,13 +63,15 @@ interface BitcoinPublicDisclaimerProps {
export function BitcoinPublicDisclaimer({
acknowledged,
onAcknowledgedChange,
leadText = 'Money you send is public and can be traced back to you.',
leadText,
tone = 'destructive',
includeCashOutAdvice = true,
popoverText,
}: BitcoinPublicDisclaimerProps) {
const { t } = useTranslation();
const showCheckbox = onAcknowledgedChange !== undefined;
const isSoft = tone === 'soft';
const resolvedLeadText = leadText ?? t('bitcoinPublic.lead');
return (
<Alert
@@ -92,25 +95,21 @@ export function BitcoinPublicDisclaimer({
{!isSoft && <AlertTriangle className="size-4 text-destructive" />}
<AlertDescription className="text-xs">
<p>
{leadText}{' '}
{resolvedLeadText}{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="underline underline-offset-2 font-medium hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
>
Learn more
{t('bitcoinPublic.learnMore')}
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
{popoverText ?? (
<>
Bitcoin is a public ledger. Transactions you send can
be traced back to you forever, even after being
exchanged by multiple people. Send it only to those
you wish to support publicly
{includeCashOutAdvice ? ', or cash out at an exchange.' : '.'}
</>
includeCashOutAdvice
? t('bitcoinPublic.bodyWithCashOut')
: t('bitcoinPublic.body')
)}
</PopoverContent>
</Popover>
@@ -126,9 +125,9 @@ export function BitcoinPublicDisclaimer({
? 'border-amber-600 data-[state=checked]:bg-amber-600 data-[state=checked]:text-white dark:border-amber-400 dark:data-[state=checked]:bg-amber-500'
: 'border-destructive data-[state=checked]:bg-destructive data-[state=checked]:text-destructive-foreground',
)}
aria-label="I understand this transaction is public"
aria-label={t('bitcoinPublic.iUnderstand')}
/>
<span>I understand this transaction is public.</span>
<span>{t('bitcoinPublic.iUnderstand')}</span>
</label>
)}
</AlertDescription>
+59 -54
View File
@@ -1,4 +1,5 @@
import { useMemo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import {
CalendarDays,
@@ -32,6 +33,7 @@ import { useComments } from '@/hooks/useComments';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { useMyRSVP } from '@/hooks/useMyRSVP';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
@@ -71,10 +73,10 @@ function getEventCoord(event: NostrEvent): string {
return `${event.kind}:${event.pubkey}:${d}`;
}
function formatDetailDate(event: NostrEvent): string {
function formatDetailDate(event: NostrEvent, locale: string, dateNotSpecified: string): string {
const startRaw = getTag(event.tags, 'start');
const endRaw = getTag(event.tags, 'end');
if (!startRaw) return 'Date not specified';
if (!startRaw) return dateNotSpecified;
if (event.kind === 31922) {
const parseDate = (d: string) => {
@@ -82,7 +84,7 @@ function formatDetailDate(event: NostrEvent): string {
return new Date(y, m - 1, day);
};
const fmt = (d: Date) => {
return d.toLocaleDateString('en-US', {
return d.toLocaleDateString(locale, {
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
});
};
@@ -112,13 +114,13 @@ function formatDetailDate(event: NostrEvent): string {
...(startTzid ? { timeZone: startTzid } : {}),
};
const dateFmt = new Intl.DateTimeFormat('en-US', opts);
const dateFmt = new Intl.DateTimeFormat(locale, opts);
const startStr = dateFmt.format(new Date(startTs));
if (endTs) {
const sameDay = new Date(startTs).toDateString() === new Date(endTs).toDateString();
if (sameDay) {
const timeFmt = new Intl.DateTimeFormat('en-US', {
const timeFmt = new Intl.DateTimeFormat(locale, {
hour: 'numeric', minute: '2-digit',
...(startTzid ? { timeZone: startTzid } : {}),
});
@@ -129,7 +131,7 @@ function formatDetailDate(event: NostrEvent): string {
return startStr;
}
function formatCalendarHeroDate(event: NostrEvent): string | null {
function formatCalendarHeroDate(event: NostrEvent, locale: string): string | null {
const startRaw = getTag(event.tags, 'start');
if (!startRaw) return null;
@@ -137,13 +139,13 @@ function formatCalendarHeroDate(event: NostrEvent): string | null {
const [year, month, day] = startRaw.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (isNaN(date.getTime())) return startRaw;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return date.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
}
const timestamp = Number(startRaw);
if (!Number.isFinite(timestamp)) return startRaw;
const startTzid = getTag(event.tags, 'start_tzid');
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
return new Date(timestamp * 1000).toLocaleDateString(locale, {
month: 'short', day: 'numeric', year: 'numeric',
...(startTzid ? { timeZone: startTzid } : {}),
});
@@ -159,6 +161,7 @@ function roleSort(a: string, b: string): number {
// --- Sub-components ---
function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: string; size?: 'sm' | 'md' }) {
const { t } = useTranslation();
const { data } = useAuthor(pubkey);
const metadata: NostrMetadata | undefined = data?.metadata;
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
@@ -177,7 +180,7 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str
</Avatar>
<div className="min-w-0 flex-1">
<p className={cn('font-semibold truncate group-hover:underline', size === 'sm' ? 'text-sm' : 'text-[15px]')}>{name}</p>
{!label && size === 'md' && <p className="text-xs text-muted-foreground">Organizer</p>}
{!label && size === 'md' && <p className="text-xs text-muted-foreground">{t('calendarEvents.detail.organizer')}</p>}
</div>
{label && (
<Badge variant="secondary" className="ml-auto capitalize text-xs shrink-0">{label}</Badge>
@@ -198,23 +201,24 @@ function EventDetailRow({ icon, children }: { icon: React.ReactNode; children: R
// --- Main Component ---
export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { user } = useCurrentUser();
const { toast } = useToast();
const author = useAuthor(event.pubkey);
const { translatedEvent: displayEvent, translateAction } = useEventTranslation(event);
const title = getTag(event.tags, 'title') ?? 'Untitled Event';
const image = sanitizeUrl(getTag(event.tags, 'image'));
const locationRaw = getTag(event.tags, 'location');
const title = getTag(displayEvent.tags, 'title') ?? t('calendarEvents.detail.untitledEvent');
const image = sanitizeUrl(getTag(displayEvent.tags, 'image'));
const locationRaw = getTag(displayEvent.tags, 'location');
const location = locationRaw ? parseLocation(locationRaw) : undefined;
const summary = getTag(event.tags, 'summary');
const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean);
const links = getAllTags(event.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
const summary = getTag(displayEvent.tags, 'summary');
const hashtags = getAllTags(displayEvent.tags, 't').map(([, v]) => v).filter(Boolean);
const links = getAllTags(displayEvent.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
const eventCoord = useMemo(() => getEventCoord(event), [event]);
const dateStr = useMemo(() => formatDetailDate(event), [event]);
const heroDate = useMemo(() => formatCalendarHeroDate(event), [event]);
const dateStr = useMemo(() => formatDetailDate(event, i18n.language, t('calendarEvents.detail.dateNotSpecified')), [event, i18n.language, t]);
const heroDate = useMemo(() => formatCalendarHeroDate(event, i18n.language), [event, i18n.language]);
const organizerMetadata: NostrMetadata | undefined = author.data?.metadata;
const organizerName = organizerMetadata?.display_name || organizerMetadata?.name || genUserName(event.pubkey);
const organizerProfileUrl = useProfileUrl(event.pubkey, organizerMetadata);
@@ -278,10 +282,10 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
);
const storyEvent = useMemo<NostrEvent>(() => ({
...event,
tags: event.tags.filter(([name]) => !['image', 'summary', 'title', 't'].includes(name)),
content: event.content || summary || '',
}), [event, summary]);
...displayEvent,
tags: displayEvent.tags.filter(([name]) => !['image', 'summary', 'title', 't'].includes(name)),
content: displayEvent.content || summary || '',
}), [displayEvent, summary]);
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
if (status === myRsvp.status) return;
@@ -291,22 +295,22 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
eventAuthorPubkey: event.pubkey,
status,
});
toast({ title: 'RSVP updated' });
toast({ title: t('calendarEvents.detail.rsvpUpdated') });
} catch {
toast({ title: 'Failed to update RSVP', variant: 'destructive' });
toast({ title: t('calendarEvents.detail.rsvpFailed'), variant: 'destructive' });
}
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast, t]);
const showRSVP = !!user;
const attendingCount = rsvps.accepted.length;
const interestedCount = rsvps.tentative.length;
const rsvpStatusLabel = myRsvp.status === 'accepted'
? 'You are going'
? t('calendarEvents.detail.youAreGoing')
: myRsvp.status === 'tentative'
? 'You are interested'
? t('calendarEvents.detail.youAreInterested')
: myRsvp.status === 'declined'
? "You can't go"
: 'Choose your RSVP';
? t('calendarEvents.detail.youCantGo')
: t('calendarEvents.detail.chooseRsvp');
const eventDetailsCard = (showRSVP || rsvps.total > 0 || links.length > 0) ? (
<Card className="rounded-none border-0 bg-transparent shadow-none lg:rounded-xl lg:border lg:bg-card lg:shadow-sm">
@@ -314,7 +318,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
{showRSVP && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">RSVP</div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t('calendarEvents.detail.rsvp')}</div>
<span className="text-xs font-medium text-muted-foreground">{rsvpStatusLabel}</span>
</div>
<div className="grid grid-cols-3 gap-2">
@@ -326,7 +330,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1" />
Going
{t('calendarEvents.detail.going')}
</Button>
<Button
size="sm"
@@ -336,7 +340,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1" />
Interested
{t('calendarEvents.detail.interested')}
</Button>
<Button
size="sm"
@@ -346,7 +350,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1" />
Can't Go
{t('calendarEvents.detail.cantGo')}
</Button>
</div>
</div>
@@ -354,12 +358,12 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
{rsvps.total > 0 && (
<div className={cn('space-y-3', showRSVP && 'border-t border-border/60 pt-4')}>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Attendees</div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t('calendarEvents.detail.attendees')}</div>
<div className="space-y-3">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
[t('calendarEvents.detail.going'), rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
[t('calendarEvents.detail.interested'), rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
[t('calendarEvents.detail.cantGo'), rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="space-y-2">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
@@ -372,7 +376,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
{links.length > 0 && (
<div className={cn('space-y-2', (showRSVP || rsvps.total > 0) && 'border-t border-border/60 pt-4')}>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Links</div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t('calendarEvents.detail.links')}</div>
<div className="space-y-1">
{links.map((url) => (
<button
@@ -396,7 +400,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const participantsCard = participantsByRole.length > 0 ? (
<Card className="border-0 bg-transparent shadow-none lg:border lg:bg-card lg:shadow-sm">
<CardContent className="p-0 lg:p-5 space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Participants</div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t('calendarEvents.detail.participants')}</div>
<div className="space-y-2">
{participantsByRole.map(([role, pubkeys]) =>
pubkeys.map((pk) => <PersonRow key={`${role}-${pk}`} pubkey={pk} label={role} size="sm" />),
@@ -425,10 +429,10 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="inline-flex items-center gap-1.5 h-10 pl-2 pr-3.5 rounded-full bg-black/30 text-white backdrop-blur-md hover:bg-black/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
aria-label="Go back"
aria-label={t('common.goBack')}
>
<ChevronLeft className="size-5" />
<span className="text-sm font-medium hidden sm:inline">Back</span>
<span className="text-sm font-medium hidden sm:inline">{t('common.back')}</span>
</button>
{canEdit && (
<Button
@@ -438,7 +442,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
className="h-10 rounded-full bg-black/30 text-white backdrop-blur-md shadow-none hover:bg-black/45 focus-visible:ring-white/80"
>
<Pencil className="size-4 mr-2" />
Edit
{t('common.edit')}
</Button>
)}
</div>
@@ -462,7 +466,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
</AvatarFallback>
</Avatar>
<span className="[text-shadow:0_1px_3px_rgba(0,0,0,0.7)]">
hosted by{' '}
{t('calendarEvents.detail.hostedBy')}{' '}
<span className="font-semibold underline-offset-4 group-hover:underline">
{organizerName}
</span>
@@ -485,13 +489,13 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
{attendingCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Users className="size-4" />
{attendingCount} attending
{t('calendarEvents.detail.attendingCount', { count: attendingCount })}
</span>
)}
{interestedCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Users className="size-4" />
{interestedCount} interested
{t('calendarEvents.detail.interestedCount', { count: interestedCount })}
</span>
)}
</div>
@@ -499,11 +503,12 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<div className="mt-4 pt-3 border-t border-white/15 [&_button]:!text-white/90 [&_button:hover]:!text-white [&_button:hover]:!bg-white/15 [&_button]:transition-colors [text-shadow:none]">
<PostActionBar
event={event}
replyLabel="Comment"
replyLabel={t('calendarEvents.detail.comment')}
hideZap
showShareInSidebar
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
translateAction={translateAction}
/>
</div>
</div>
@@ -558,9 +563,9 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<DetailStory
event={storyEvent}
hasContent={storyEvent.content.trim().length > 0}
heading="Description"
heading={t('calendarEvents.detail.description')}
headingId="event-details-heading"
emptyText="The organizer hasn't added a description for this event yet."
emptyText={t('calendarEvents.detail.emptyDescription')}
/>
</section>
@@ -574,10 +579,10 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<section id="event-comments" className="scroll-mt-20">
<div className="mt-6">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
<h2 className="text-lg font-semibold tracking-tight">{t('calendarEvents.detail.comments')}</h2>
{replyTree.length > 0 ? (
<span className="text-sm text-muted-foreground tabular-nums">
{replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'}
{replyTree.length.toLocaleString()} {t('calendarEvents.detail.commentNoun', { count: replyTree.length })}
</span>
) : null}
</div>
@@ -608,8 +613,8 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
onClick={() => setReplyOpen(true)}
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
>
<p className="text-base font-medium text-foreground">No comments yet</p>
<p className="mt-1 text-sm text-muted-foreground">Be the first to comment.</p>
<p className="text-base font-medium text-foreground">{t('calendarEvents.detail.noCommentsTitle')}</p>
<p className="mt-1 text-sm text-muted-foreground">{t('calendarEvents.detail.noCommentsHint')}</p>
</button>
)}
</div>
@@ -643,10 +648,10 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const wasPinned = isPinned(event.id);
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: wasPinned ? 'Unpinned from event' : 'Pinned to event' });
toast({ title: wasPinned ? t('calendarEvents.detail.unpinnedToast') : t('calendarEvents.detail.pinnedToast') });
},
onError: () => {
toast({ title: 'Failed to update event pins', variant: 'destructive' });
toast({ title: t('calendarEvents.detail.pinFailed'), variant: 'destructive' });
},
});
}
+19 -7
View File
@@ -12,10 +12,12 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import {
type ParsedCampaign,
encodeCampaignNaddr,
getCampaignCountryLabel,
parseCampaign,
} from '@/lib/campaign';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
@@ -42,7 +44,7 @@ function formatDeadline(unixSeconds: number): { label: string; isPast: boolean }
* time using the live BTC price. While the price is loading the raised
* amount falls back to sats.
*/
export function CampaignProgress({
function CampaignProgress({
raisedSats,
goalUsd,
btcPrice,
@@ -80,7 +82,7 @@ export function CampaignProgress({
* on-chain totals are unobservable by design. Shows the goal as a target
* (if set) but no progress bar or raised amount.
*/
export function CampaignPrivateNotice({
function CampaignPrivateNotice({
goalUsd,
className,
}: {
@@ -114,13 +116,18 @@ interface CampaignCardProps {
* `<Link>` to the campaign's naddr-based detail route.
*/
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
const { translatedEvent, translateAction } = useEventTranslation(campaign.event, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const { data: moderation } = useCampaignModeration();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
const cover = sanitizeUrl(campaign.banner);
const cover = sanitizeUrl(displayCampaign.banner);
const creatorName =
author.data?.metadata?.display_name ||
author.data?.metadata?.name ||
@@ -199,16 +206,16 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
isFeaturedVariant ? 'text-2xl sm:text-3xl' : 'text-lg line-clamp-2',
)}
>
{campaign.title}
{displayCampaign.title}
</h3>
{campaign.summary && (
{displayCampaign.summary && (
<p
className={cn(
'text-muted-foreground',
isFeaturedVariant ? 'text-base line-clamp-3' : 'text-sm line-clamp-2',
)}
>
{campaign.summary}
{displayCampaign.summary}
</p>
)}
</div>
@@ -252,7 +259,12 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
<div className="truncate">
by <span className="font-medium text-foreground">{creatorName}</span>
</div>
{footerBadge && <div className="shrink-0">{footerBadge}</div>}
{(footerBadge || translateAction) && (
<div className="flex shrink-0 items-center gap-1.5">
{footerBadge}
{translateAction}
</div>
)}
</div>
</div>
</Card>
+4 -17
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { AlertTriangle, Check, Copy, ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Check, Copy, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { QRCodeCanvas } from '@/components/ui/qrcode';
@@ -54,6 +55,7 @@ function buildQrPayload(wallets: CampaignWallets): string {
export function CampaignWalletDonatePanel({
wallets,
}: CampaignWalletDonatePanelProps) {
const { t } = useTranslation();
const qrPayload = buildQrPayload(wallets);
const { onchain, sp } = wallets;
@@ -103,7 +105,7 @@ export function CampaignWalletDonatePanel({
<Button asChild className="w-full text-white">
<a href={qrPayload}>
<ExternalLink className="size-4 mr-1.5" />
Open in wallet
{t('campaignsDetail.openInWallet')}
</a>
</Button>
</div>
@@ -153,18 +155,3 @@ function WalletCopyRow({ value, label }: { value: string; label: string }) {
</button>
);
}
/**
* Fallback rendered when the wallet failed to parse. The detail page
* should normally never reach this — `parseCampaign` rejects events
* without a valid `w` tag — but a defensive surface is cheap and helps
* debugging.
*/
export function CampaignWalletMissing() {
return (
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
<span>This campaign is missing a valid wallet endpoint.</span>
</div>
);
}
+10 -58
View File
@@ -1,8 +1,7 @@
import { useMemo, useState, useEffect, useId } from 'react';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { getColors, paletteToTheme } from '@/lib/colorMomentUtils';
import { getColors } from '@/lib/colorMomentUtils';
import type { NostrEvent } from '@nostrify/nostrify';
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
@@ -198,61 +197,14 @@ const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
diagonalStripes: DiagonalStripesLayout,
};
/** Standalone blinking eye button for setting a color moment as the active theme. */
export function ColorMomentEyeButton({ event }: { event: NostrEvent }) {
const colors = useMemo(() => getColors(event.tags), [event.tags]);
const { applyCustomTheme } = useTheme();
const [isBlinking, setIsBlinking] = useState(false);
const uid = useId();
useEffect(() => {
if (isBlinking) {
const timer = setTimeout(() => setIsBlinking(false), 500);
return () => clearTimeout(timer);
}
}, [isBlinking]);
const handleSetTheme = (e: React.MouseEvent) => {
e.stopPropagation();
setIsBlinking(true);
applyCustomTheme(paletteToTheme(colors));
};
if (colors.length === 0) return null;
const outer = colors[0] ?? '#888';
const iris = colors[colors.length - 1] ?? '#888';
// Build the eye as a data-URI SVG so the browser renders it as an image
// (smooth anti-aliasing, no compositing layer on siblings)
const eyeSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="${outer}"/>
<circle cx="50" cy="50" r="32" fill="white" opacity="0.9"/>
<circle cx="50" cy="50" r="20" fill="${iris}"/>
<circle cx="50" cy="50" r="10" fill="#1c1917"/>
<circle cx="43" cy="43" r="4" fill="white" opacity="0.8"/>
</svg>`;
const eyeUrl = `data:image/svg+xml,${encodeURIComponent(eyeSvg)}`;
return (
<button
onClick={handleSetTheme}
className="flex items-center gap-1.5 hover:opacity-80 active:scale-95 transition-all shrink-0"
title="Set as theme"
>
<span className="text-[11px] font-medium text-muted-foreground">Set as theme</span>
{/* Eye container — overflow-hidden clips the eyelid, doesn't touch siblings */}
<div className="relative size-9 rounded-full overflow-hidden" id={uid}>
<img src={eyeUrl} alt="" className="w-9 h-9" />
{isBlinking && (
<div className="absolute inset-0 animate-eyelid-blink">
<div className="absolute rounded-full bg-background w-full h-full bottom-0 left-0" />
</div>
)}
</div>
</button>
);
}
/** Standalone blinking eye button — REMOVED.
*
* This component used to let any user apply an arbitrary kind-3367 event's
* palette as their active theme via `applyCustomTheme(paletteToTheme(...))`.
* Agora no longer supports user-customizable themes, so the apply path has
* been removed entirely. Color Moments still render as palette art via
* `ColorMomentContent` below.
*/
export function ColorMomentContent({ event }: { event: NostrEvent }) {
const colors = useMemo(() => getColors(event.tags), [event.tags]);
-5
View File
@@ -917,11 +917,6 @@ function useCountryRootContext(event: NostrEvent): { iTag: string; code: string
* flag pill) in the current context. Useful for sibling components that
* want to coordinate styling.
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useIsCountryRooted(event: NostrEvent): boolean {
return useCountryRootContext(event) !== null;
}
/**
* Standalone country flag pill for kind-1111 events whose root is an ISO 3166
* identifier. Intended to be rendered in the upper-right of `NoteCard`'s
+74 -50
View File
@@ -1,4 +1,5 @@
import { useMemo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
@@ -66,10 +67,11 @@ import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { applyCommunityModerationToEvents, parseCommunityEvent } from '@/lib/communityUtils';
import type { ParsedCampaign } from '@/lib/campaign';
import type { Action } from '@/hooks/useActions';
import { parseAction, type Action } from '@/hooks/useActions';
import { getGeoDisplayName } from '@/lib/countries';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatNumber } from '@/lib/formatNumber';
@@ -223,16 +225,22 @@ function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: strin
}
function PledgeShelfCard({ pledge }: { pledge: Action }) {
const { t } = useTranslation();
const { data: btcPrice } = useBtcPrice();
const { translatedEvent, translateAction } = useEventTranslation(pledge.event, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayPledge = parseAction(translatedEvent) ?? pledge;
const author = useAuthor(pledge.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, pledge.pubkey);
const [imageLoadFailed, setImageLoadFailed] = useState(false);
const sanitizedCover = sanitizeUrl(pledge.image);
const sanitizedCover = sanitizeUrl(displayPledge.image);
const coverImage = sanitizedCover && !imageLoadFailed ? sanitizedCover : DEFAULT_COVER_IMAGE;
const deadline = pledge.deadline ? formatCompactPledgeDeadline(pledge.deadline) : null;
const countryLabel = pledge.countryCode ? getGeoDisplayName(pledge.countryCode) : undefined;
const deadline = displayPledge.deadline ? formatCompactPledgeDeadline(displayPledge.deadline) : null;
const countryLabel = displayPledge.countryCode ? getGeoDisplayName(displayPledge.countryCode) : undefined;
const naddr = nip19.naddrEncode({
kind: pledge.event.kind,
pubkey: pledge.pubkey,
@@ -255,7 +263,7 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
{deadline?.isPast && (
<div className="absolute right-3 top-3">
<Badge variant="secondary" className="backdrop-blur bg-background/85 border-border/40 text-muted-foreground">
Ended
{t('pledges.card.ended')}
</Badge>
</div>
)}
@@ -264,11 +272,11 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
<div className="flex flex-col gap-3 p-5 flex-1">
<div className="space-y-2">
<h3 className="font-bold leading-tight tracking-tight text-lg line-clamp-2">
{pledge.title}
{displayPledge.title}
</h3>
{pledge.description.trim() && (
{displayPledge.description.trim() && (
<p className="text-sm text-muted-foreground line-clamp-2">
{pledge.description}
{displayPledge.description}
</p>
)}
</div>
@@ -276,7 +284,7 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
<div className="flex-1" />
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">Pledged</p>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">{t('pledges.card.pledged')}</p>
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
{formatPledgeAmount(pledge.bounty, btcPrice)}
</p>
@@ -299,9 +307,12 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
by <span className="font-medium text-foreground">{displayName}</span>
{t('groups.detail.byAuthor', { name: displayName })}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label={t('groups.detail.pledge')} />
{translateAction}
</div>
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label="Pledge" />
</div>
</div>
</Card>
@@ -310,15 +321,20 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
}
function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
const { t } = useTranslation();
const { translatedEvent: displayEvent, translateAction } = useEventTranslation(event, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const [imageLoadFailed, setImageLoadFailed] = useState(false);
const title = getTag(event.tags, 'title') ?? 'Untitled event';
const image = sanitizeUrl(getTag(event.tags, 'image'));
const title = getTag(displayEvent.tags, 'title') ?? t('groups.detail.untitledEvent');
const image = sanitizeUrl(getTag(displayEvent.tags, 'image'));
const coverImage = image && !imageLoadFailed ? image : undefined;
const summary = getTag(event.tags, 'summary') || event.content;
const locationRaw = getTag(event.tags, 'location');
const summary = getTag(displayEvent.tags, 'summary') || displayEvent.content;
const locationRaw = getTag(displayEvent.tags, 'location');
const location = locationRaw ? parseShelfLocation(locationRaw) : undefined;
const dateLabel = formatShelfEventDate(event);
const timeLabel = formatShelfEventTime(event);
@@ -390,21 +406,24 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5" />
{interestedCount} {interestedCount === 1 ? 'person' : 'people'} interested
{t('groups.detail.interestedCount', { count: interestedCount })}
</span>
{event.kind === 31922 ? (
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="size-3.5" />
All-day
{t('groups.detail.allDay')}
</span>
) : null}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
by <span className="font-medium text-foreground">{displayName}</span>
{t('groups.detail.byAuthor', { name: displayName })}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<ActivityTypePill icon={<CalendarDays className="size-3.5 text-primary" />} label={t('groups.detail.event')} />
{translateAction}
</div>
<ActivityTypePill icon={<CalendarDays className="size-3.5 text-primary" />} label="Event" />
</div>
</div>
</Card>
@@ -460,6 +479,7 @@ function OfficialActivityShelves({
eventsLoading: boolean;
now: number;
}) {
const { t } = useTranslation();
// All loaded campaigns. Closure is via NIP-09 deletion (relay-level),
// so anything that reached us is current.
const liveCampaigns = campaigns;
@@ -517,7 +537,7 @@ function OfficialActivityShelves({
return (
<div className="space-y-1">
<OfficialShelf
title="Official activity"
title={t('groups.detail.officialActivity')}
count={mixedActivity.length}
isLoading={campaignsLoading || pledgesLoading || eventsLoading}
isEmpty={mixedActivity.length === 0}
@@ -529,7 +549,7 @@ function OfficialActivityShelves({
<CampaignCard
campaign={item.campaign}
className="h-full"
footerBadge={<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label="Campaign" />}
footerBadge={<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label={t('groups.detail.campaign')} />}
/>
</div>
);
@@ -553,6 +573,7 @@ function GroupActionColumn({
orgNaddr: string;
organizationName: string;
}) {
const { t } = useTranslation();
const createQuery = orgNaddr ? `?org=${orgNaddr}` : '';
return (
@@ -563,10 +584,10 @@ function GroupActionColumn({
<div className="relative flex items-start justify-between gap-3">
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Official tools</p>
<h2 className="text-xl font-semibold tracking-tight">Start something</h2>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{t('groups.detail.officialTools')}</p>
<h2 className="text-xl font-semibold tracking-tight">{t('groups.detail.startSomething')}</h2>
<p className="text-sm leading-5 text-muted-foreground">
Give {organizationName} a concrete next step.
{t('groups.detail.officialToolsDescription', { name: organizationName })}
</p>
</div>
<div className="hidden rounded-full border border-primary/15 bg-primary/10 p-2 text-primary sm:block lg:hidden xl:block">
@@ -589,8 +610,8 @@ function GroupActionColumn({
<ChevronRight className="size-5 transition-transform group-hover:translate-x-0.5" />
</div>
<span>
<span className="block text-lg font-semibold">Launch a campaign</span>
<span className="mt-1 block text-sm text-primary-foreground/80">Raise funds with an official group page.</span>
<span className="block text-lg font-semibold">{t('groups.detail.launchCampaign')}</span>
<span className="mt-1 block text-sm text-primary-foreground/80">{t('groups.detail.launchCampaignDescription')}</span>
</span>
</div>
</div>
@@ -606,8 +627,8 @@ function GroupActionColumn({
</span>
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
</span>
<span className="block font-semibold">Pledge</span>
<span className="mt-1 block text-xs leading-4 text-muted-foreground">Offer help people can act on.</span>
<span className="block font-semibold">{t('groups.detail.pledge')}</span>
<span className="mt-1 block text-xs leading-4 text-muted-foreground">{t('groups.detail.pledgeDescription')}</span>
</Link>
<Link
@@ -620,8 +641,8 @@ function GroupActionColumn({
</span>
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
</span>
<span className="block font-semibold">Event</span>
<span className="mt-1 block text-xs leading-4 text-muted-foreground">Bring supporters together.</span>
<span className="block font-semibold">{t('groups.detail.event')}</span>
<span className="mt-1 block text-xs leading-4 text-muted-foreground">{t('groups.detail.eventDescription')}</span>
</Link>
</div>
</CardContent>
@@ -632,6 +653,7 @@ function GroupActionColumn({
// ── Main component ────────────────────────────────────────────────────────────
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
@@ -644,9 +666,10 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const deleteMutation = useDeleteEvent();
const { translatedEvent: displayEvent, translateAction } = useEventTranslation(event);
// Parse community definition
const community = useMemo(() => parseCommunityEvent(event), [event]);
const community = useMemo(() => parseCommunityEvent(displayEvent), [displayEvent]);
const name = community?.name ?? 'Unnamed Group';
const description = community?.description ?? '';
const image = community?.image;
@@ -902,10 +925,10 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="inline-flex h-10 items-center gap-1.5 rounded-full bg-black/30 pl-2 pr-3.5 text-white backdrop-blur-md hover:bg-black/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
aria-label="Go back"
aria-label={t('common.goBack')}
>
<ChevronLeft className="size-5" />
<span className="hidden text-sm font-medium sm:inline">Back</span>
<span className="hidden text-sm font-medium sm:inline">{t('common.back')}</span>
</button>
<div className="flex items-center gap-1.5">
@@ -933,7 +956,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
{isFounder && community && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={bannerActionClassName} aria-label="More actions">
<button type="button" className={bannerActionClassName} aria-label={t('groups.detail.moreActions')}>
<MoreVertical className="size-5" />
</button>
</DropdownMenuTrigger>
@@ -949,7 +972,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}}
>
<Pencil className="size-4 mr-2" />
Edit group
{t('groups.detail.editGroup')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
@@ -959,7 +982,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
className="text-destructive focus:text-destructive focus:bg-destructive/10"
>
<Trash2 className="size-4 mr-2" />
Delete group
{t('groups.detail.deleteGroup')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -987,7 +1010,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</AvatarFallback>
</Avatar>
<span className="[text-shadow:0_1px_3px_rgba(0,0,0,0.7)]">
by{' '}
{t('groups.detail.by')}{' '}
<span className="font-semibold underline-offset-4 group-hover:underline">
{founderName}
</span>
@@ -1005,11 +1028,12 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<div className="mt-4 border-t border-white/15 pt-3 [text-shadow:none] [&_button]:!text-white/90 [&_button:hover]:!bg-white/15 [&_button:hover]:!text-white [&_button]:transition-colors">
<PostActionBar
event={event}
replyLabel="Comment"
replyLabel={t('groups.detail.comment')}
hideZap
showShareInSidebar
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
translateAction={translateAction}
/>
</div>
</div>
@@ -1023,7 +1047,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Members
{t('groups.detail.members')}
</div>
<PeopleAvatarStack
pubkeys={leadershipPubkeys}
@@ -1041,10 +1065,10 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
>
{hiddenMemberCount > 0 && (
<span className="mr-2 text-muted-foreground">
+{hiddenMemberCount.toLocaleString()} more
{t('groups.detail.moreMembers', { count: hiddenMemberCount })}
</span>
)}
View members
{t('groups.detail.viewMembers')}
<ChevronRight className="ml-1 size-4" />
</Button>
</div>
@@ -1053,7 +1077,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
{descriptionText && (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
About
{t('groups.detail.about')}
</div>
<div className="relative">
<div
@@ -1076,7 +1100,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
className="h-auto p-0 text-sm"
onClick={() => setDescriptionExpanded((expanded) => !expanded)}
>
{descriptionExpanded ? 'Show less' : 'Read more'}
{descriptionExpanded ? t('common.showLess') : t('common.readMore')}
</Button>
)}
</div>
@@ -1124,11 +1148,11 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<div id="org-activity" className="scroll-mt-20">
<div className="mt-6">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
<h2 className="text-lg font-semibold tracking-tight">{t('groups.detail.comments')}</h2>
{engagementStats?.replies ? (
<span className="text-sm text-muted-foreground tabular-nums">
{formatNumber(engagementStats.replies)}{' '}
{engagementStats.replies === 1 ? 'comment' : 'comments'}
{t('groups.detail.commentNoun', { count: engagementStats.replies })}
</span>
) : null}
</div>
@@ -1160,10 +1184,10 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
>
<p className="text-base font-medium text-foreground">
No comments yet
{t('groups.detail.noCommentsTitle')}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Be the first to start a discussion.
{t('groups.detail.noCommentsHint')}
</p>
</button>
)}
@@ -1186,7 +1210,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border shrink-0">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5" />
Members
{t('groups.detail.members')}
{leadershipPubkeys.length > 0 && (
<span className="text-muted-foreground font-normal text-sm">({leadershipPubkeys.length})</span>
)}
@@ -1198,7 +1222,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<MembersSkeleton />
) : memberSections.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No members found.
{t('groups.detail.noMembersFound')}
</div>
) : (
<div className="divide-y divide-border">
@@ -1214,7 +1238,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<PersonRow
key={m.pubkey}
pubkey={m.pubkey}
label={m.isFounder ? 'Founder' : 'Moderator'}
label={m.isFounder ? t('groups.detail.founder') : t('groups.detail.moderator')}
size="sm"
/>
))}
@@ -25,46 +25,6 @@ interface CommunityModerationMenuProps {
className?: string;
}
/**
* Per-card kebab menu exposing the moderator actions for an organization:
*
* Hide / Unhide (axis = hide)
* Feature / Unfeature (axis = featured)
*
* Organizations intentionally do **not** have an `approved` axis — unlike
* campaigns, which gate homepage placement on moderator approval, every
* Agora-tagged organization is publicly visible by default. Moderators
* curate via two narrower controls: lifting an org into the Featured
* shelf, or suppressing it with a Hidden label.
*
* Renders `null` for users who are not Team Soapbox pack members. Sits
* inside the clickable `CommunityMiniCard` `<Link>`, so the trigger
* swallows its own click and the dropdown content stops propagation —
* otherwise every menu interaction would navigate to the organization
* detail page.
*
* The moderation rollup is read inside this component (after the
* moderator gate) instead of at the parent so non-moderator viewers
* never subscribe to the heavy `useOrganizationModeration` query — every
* `CommunityMiniCard` in a grid would otherwise wake the same cache
* subscription up to 18+ times per page.
*/
export function CommunityModerationMenu({
coord,
organizationName,
className,
}: CommunityModerationMenuProps) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
// Bail before the heavy moderation query subscribes. Non-moderators
// (the overwhelming majority) never pay the network or render cost.
if (!isMod) return null;
return <CommunityModerationMenuInner coord={coord} organizationName={organizationName} className={className} />;
}
function CommunityModerationMenuInner({
coord,
organizationName,
+65 -61
View File
@@ -1,5 +1,6 @@
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, Check, Globe, HelpCircle } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { encode as blurhashEncode } from 'blurhash';
@@ -226,9 +227,9 @@ function CharRing({ count, max }: { count: number; max: number }) {
export function ComposeBox({
onSuccess,
onSuccess,
onPublished,
placeholder = "What's on your mind?",
placeholder,
compact = false,
replyTo,
quotedEvent,
@@ -242,10 +243,13 @@ export function ComposeBox({
initialMode = 'post',
customPublish,
hidePoll = false,
submitLabel = 'Post!',
submitLabel,
defaultTags,
defaultExpanded = false,
}: ComposeBoxProps) {
const { t } = useTranslation();
const effectivePlaceholder = placeholder ?? t('compose.placeholderDefault');
const effectiveSubmitLabel = submitLabel ?? t('compose.submitDefault');
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const { mutateAsync: createEvent, isPending, isPending: isPollPending } = useNostrPublish();
@@ -695,9 +699,9 @@ export function ComposeBox({
expand();
} catch {
toast({ title: 'Upload failed', description: 'Could not upload file.', variant: 'destructive' });
toast({ title: t('compose.submit.uploadFailed'), description: t('compose.submit.uploadFailedBody'), variant: 'destructive' });
}
}, [uploadFile, expand, toast, imageQuality]);
}, [uploadFile, expand, toast, imageQuality, t]);
const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
@@ -723,9 +727,9 @@ export function ComposeBox({
await voiceRecorder.startRecording();
expand();
} catch {
toast({ title: 'Microphone access denied', description: 'Please allow microphone access to record voice messages.', variant: 'destructive' });
toast({ title: t('compose.voice.micDeniedTitle'), description: t('compose.voice.micDeniedBody'), variant: 'destructive' });
}
}, [voiceRecorder, expand, toast]);
}, [voiceRecorder, expand, toast, t]);
/** Stop recording, upload, and publish as kind 1222 or 1244. */
const handleStopAndPublishVoice = useCallback(async () => {
@@ -860,14 +864,14 @@ export function ComposeBox({
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
}
notificationSuccess();
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
toast({ title: t('compose.voice.sentTitle'), description: t('compose.voice.sentBody') });
onSuccess?.();
} catch {
toast({ title: 'Error', description: 'Failed to send voice message.', variant: 'destructive' });
toast({ title: t('common.error'), description: t('compose.voice.sendFailed'), variant: 'destructive' });
} finally {
setIsPublishingVoice(false);
}
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess, canChooseDestination, selectedCountryCode, statsPubkey]);
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess, canChooseDestination, selectedCountryCode, statsPubkey, t]);
const handleSubmit = async () => {
if (!content.trim() || !user || charCount > MAX_CHARS) return;
@@ -1135,13 +1139,13 @@ export function ComposeBox({
notificationSuccess();
if (!customPublish?.suppressSuccessToast) {
toast({
title: customPublish?.successTitle ?? 'Posted!',
description: customPublish?.successDescription ?? (replyTo ? 'Your reply has been published.' : quotedEvent ? 'Your quote has been published.' : 'Your note has been published.'),
title: customPublish?.successTitle ?? t('compose.submit.posted'),
description: customPublish?.successDescription ?? (replyTo ? t('compose.submit.replyPublished') : quotedEvent ? t('compose.submit.quotePublished') : t('compose.submit.notePublished')),
});
}
onSuccess?.();
} catch {
toast({ title: 'Error', description: 'Failed to publish note.', variant: 'destructive' });
toast({ title: t('common.error'), description: t('compose.submit.publishFailed'), variant: 'destructive' });
}
};
@@ -1205,10 +1209,10 @@ export function ComposeBox({
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
}
notificationSuccess();
toast({ title: 'Poll published!' });
toast({ title: t('compose.poll.published') });
onSuccess?.();
} catch {
toast({ title: 'Error', description: 'Failed to publish poll.', variant: 'destructive' });
toast({ title: t('common.error'), description: t('compose.poll.publishFailed'), variant: 'destructive' });
}
};
@@ -1241,7 +1245,7 @@ export function ComposeBox({
: "text-muted-foreground hover:text-foreground"
)}
>
Edit
{t('compose.preview.edit')}
</button>
<button
onClick={() => setInternalPreviewMode(true)}
@@ -1252,7 +1256,7 @@ export function ComposeBox({
: "text-muted-foreground hover:text-foreground"
)}
>
Preview
{t('compose.preview.previewMode')}
</button>
</div>
</div>
@@ -1290,10 +1294,10 @@ export function ComposeBox({
onPaste={handlePaste}
placeholder={
mode === 'poll'
? 'Ask a question…'
? t('compose.placeholderPoll')
: selectedCountryInfo
? `What's happening in ${selectedCountryInfo.name}?`
: placeholder
? t('compose.placeholderCountry', { country: selectedCountryInfo.name })
: effectivePlaceholder
}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
@@ -1343,8 +1347,8 @@ export function ComposeBox({
onClick={() => setMode('post')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-3.5" />
Back to post
<ChevronLeft className="size-3.5 rtl:rotate-180" />
{t('compose.poll.backToPost')}
</button>
)}
@@ -1360,7 +1364,7 @@ export function ComposeBox({
prev.map((o) => (o.id === opt.id ? { ...o, label: e.target.value } : o)),
)
}
placeholder={`Option ${idx + 1}`}
placeholder={t('compose.poll.optionLabel', { number: idx + 1 })}
maxLength={100}
className="flex-1 bg-secondary/40 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-primary/40 placeholder:text-muted-foreground"
/>
@@ -1388,26 +1392,26 @@ export function ComposeBox({
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors pt-0.5"
>
<Plus className="size-3" />
Add option
{t('compose.poll.addOption')}
</button>
)}
</div>
{/* Settings row — pill toggles */}
<div className="flex flex-wrap gap-2 pt-0.5">
{(['singlechoice', 'multiplechoice'] as const).map((t) => (
{(['singlechoice', 'multiplechoice'] as const).map((pt) => (
<button
key={t}
key={pt}
type="button"
onClick={() => setPollType(t)}
onClick={() => setPollType(pt)}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
pollType === t
pollType === pt
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/30',
)}
>
{t === 'singlechoice' ? 'Single choice' : 'Multiple choice'}
{pt === 'singlechoice' ? t('compose.poll.singleChoice') : t('compose.poll.multipleChoice')}
</button>
))}
<div className="w-px bg-border self-stretch mx-0.5" />
@@ -1437,7 +1441,7 @@ export function ComposeBox({
<Input
value={cwText}
onChange={(e) => setCwText(e.target.value)}
placeholder="Content warning reason (optional)"
placeholder={t('compose.placeholderCwReason')}
className="h-8 text-base md:text-sm bg-secondary/50 border-0 rounded-lg"
/>
<button
@@ -1478,13 +1482,13 @@ export function ComposeBox({
{canChooseDestination && isExpanded && (
<div className="mt-2 flex items-center justify-end gap-2">
<span className="text-xs font-medium text-muted-foreground">
Post to
{t('compose.destination.label')}
</span>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="What's the difference between global and community posts?"
aria-label={t('compose.destination.ariaHelp')}
className={cn(
'inline-flex items-center justify-center size-5 rounded-full shrink-0',
'text-muted-foreground/70 hover:text-foreground hover:bg-muted/60',
@@ -1513,9 +1517,9 @@ export function ComposeBox({
<div className="flex gap-3">
<span className="text-2xl leading-none shrink-0" aria-hidden="true">🌍</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">Global</p>
<p className="text-sm font-semibold">{t('compose.destination.global')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
A regular post visible to everyone on Nostr. Anyone, anywhere can see it.
{t('compose.destination.globalExplainer')}
</p>
</div>
</div>
@@ -1524,9 +1528,9 @@ export function ComposeBox({
{exampleFlag ?? '🌐'}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">Your country community</p>
<p className="text-sm font-semibold">{t('compose.destination.community')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Shown in that country's local feed alongside posts from neighbors. Best for community-relevant updates.
{t('compose.destination.communityExplainer')}
</p>
</div>
</div>
@@ -1537,7 +1541,7 @@ export function ComposeBox({
</Popover>
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Post destination"
aria-label={t('compose.destination.ariaTrigger')}
className={cn(
'inline-flex items-center justify-center h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
'bg-muted/50 hover:bg-muted shadow-none',
@@ -1559,7 +1563,7 @@ export function ComposeBox({
>
<span className="inline-flex items-center gap-2 flex-1">
<span aria-hidden="true">🌍</span>
<span>Global</span>
<span>{t('compose.destination.global')}</span>
</span>
{destination === 'world' && (
<Check className="size-4 text-primary" aria-hidden />
@@ -1610,15 +1614,15 @@ export function ComposeBox({
className="cursor-pointer text-sm"
>
<Globe className="size-4 mr-2 text-muted-foreground" aria-hidden />
Choose another country
{t('compose.destination.chooseAnother')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{destination === defaultPostCountry ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{(() => {
if (defaultPostCountry === 'world') return 'Global is your default';
if (defaultPostCountry === 'world') return t('compose.destination.globalIsDefault');
const info = getCountryInfo(defaultPostCountry);
return info ? `${info.name} is your default` : 'This is your default';
return info ? t('compose.destination.isDefault', { name: info.name }) : t('compose.destination.thisIsDefault');
})()}
</div>
) : (
@@ -1629,15 +1633,15 @@ export function ComposeBox({
? null
: getCountryInfo(destination);
toast({
title: 'Default updated',
title: t('compose.destination.defaultUpdated'),
description: info
? `New posts will go to ${info.name} by default.`
: 'New posts will be global by default.',
? t('compose.destination.defaultUpdatedCountry', { name: info.name })
: t('compose.destination.defaultUpdatedGlobal'),
});
}}
className="cursor-pointer text-sm"
>
Set as default
{t('compose.destination.setAsDefault')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
@@ -1651,19 +1655,19 @@ export function ComposeBox({
open={countryPickerOpen}
onOpenChange={setCountryPickerOpen}
>
<CommandInput placeholder="Search countries..." />
<CommandInput placeholder={t('compose.destination.searchPlaceholder')} />
<CommandList>
<CommandEmpty>No countries found.</CommandEmpty>
<CommandEmpty>{t('compose.destination.noResults')}</CommandEmpty>
<CommandGroup>
<CommandItem
value="Global 🌍"
value={`${t('compose.destination.global')} 🌍`}
onSelect={() => {
setDestination('world');
setCountryPickerOpen(false);
}}
>
<span aria-hidden="true" className="mr-2">🌍</span>
<span>Global</span>
<span>{t('compose.destination.global')}</span>
{destination === 'world' && (
<Check className="ml-auto size-4 text-primary" aria-hidden />
)}
@@ -1732,7 +1736,7 @@ export function ComposeBox({
<X className="size-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>Cancel</TooltipContent>
<TooltipContent>{t('compose.voice.cancel')}</TooltipContent>
</Tooltip>
{/* Stop & send button */}
@@ -1747,7 +1751,7 @@ export function ComposeBox({
) : (
<Square className="size-3.5 mr-1.5" fill="currentColor" />
)}
{isPublishingVoice ? 'Sending...' : 'Send'}
{isPublishingVoice ? t('compose.voice.sending') : t('compose.voice.send')}
</Button>
</div>
) : (
@@ -1767,7 +1771,7 @@ export function ComposeBox({
{isUploading ? <Loader2 className="size-[18px] animate-spin" /> : <Paperclip className="size-[18px]" />}
</button>
</TooltipTrigger>
<TooltipContent>Attach file</TooltipContent>
<TooltipContent>{t('compose.toolbar.attachFile')}</TooltipContent>
</Tooltip>
<input
@@ -1798,7 +1802,7 @@ export function ComposeBox({
<Mic className="size-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>Voice message</TooltipContent>
<TooltipContent>{t('compose.voice.voiceMessage')}</TooltipContent>
</Tooltip>
)}
@@ -1818,7 +1822,7 @@ export function ComposeBox({
<Smile className="size-[18px]" />
</button>
</TooltipTrigger>
{!pickerOpen && <TooltipContent>Emoji / GIF</TooltipContent>}
{!pickerOpen && <TooltipContent>{t('compose.toolbar.emojiGif')}</TooltipContent>}
</Tooltip>
{/* Overflow: Poll + CW */}
@@ -1840,7 +1844,7 @@ export function ComposeBox({
</button>
</PopoverTrigger>
</TooltipTrigger>
{!trayOpen && <TooltipContent>More</TooltipContent>}
{!trayOpen && <TooltipContent>{t('compose.toolbar.more')}</TooltipContent>}
</Tooltip>
<PopoverContent side="top" align="start" sideOffset={6} className="w-44 p-1.5 rounded-xl border-border shadow-lg">
<div className="flex flex-col gap-0.5">
@@ -1853,7 +1857,7 @@ export function ComposeBox({
onClick={() => { setMode((m) => m === 'poll' ? 'post' : 'poll'); setTrayOpen(false); expand(); }}
className={cn('flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors', mode === 'poll' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60')}
>
<BarChart3 className="size-4" /><span className="font-medium">Poll</span>
<BarChart3 className="size-4" /><span className="font-medium">{t('compose.toolbar.poll')}</span>
</button>
)}
<button
@@ -1861,7 +1865,7 @@ export function ComposeBox({
onClick={() => { setCwEnabled((v) => !v); setTrayOpen(false); expand(); }}
className={cn('flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors', cwEnabled ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60')}
>
<AlertTriangle className="size-4" /><span className="font-medium">Spoiler</span>
<AlertTriangle className="size-4" /><span className="font-medium">{t('compose.toolbar.spoiler')}</span>
</button>
</div>
</PopoverContent>
@@ -1893,7 +1897,7 @@ export function ComposeBox({
className="rounded-full px-5 font-bold text-white"
size="sm"
>
{isPollPending ? 'Publishing...' : 'Publish poll'}
{isPollPending ? t('compose.poll.publishing') : t('compose.poll.publish')}
</Button>
) : (
<Button
@@ -1902,7 +1906,7 @@ export function ComposeBox({
className="rounded-full px-5 font-bold text-white"
size="sm"
>
{isPending || isCommentPending ? 'Posting...' : submitLabel}
{isPending || isCommentPending ? t('compose.submit.posting') : effectiveSubmitLabel}
</Button>
)}
</div>
@@ -1927,7 +1931,7 @@ export function ComposeBox({
)}
>
<Smile className="size-3.5" />
Emoji
{t('compose.toolbar.emoji')}
</button>
<button
type="button"
@@ -1957,7 +1961,7 @@ export function ComposeBox({
)}
>
<Sticker className="size-3.5" />
Stickers
{t('compose.toolbar.stickers')}
</button>
)}
</div>
+4 -2
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { MapPin, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { COUNTRIES, searchCountries, type CountryEntry } from '@/lib/countries';
@@ -22,8 +23,9 @@ export function CountrySelect({
onQueryChange,
onSelect,
onClear,
placeholder = 'Search countries, e.g. Venezuela',
placeholder,
}: CountrySelectProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedCountry = selectedCode ? COUNTRIES[selectedCode] : undefined;
@@ -67,7 +69,7 @@ export function CountrySelect({
}
}}
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={placeholder}
placeholder={placeholder ?? t('forms.countrySearchPlaceholder')}
autoComplete="off"
role="combobox"
aria-expanded={showResults}
+5 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { ImagePlus, Loader2, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { useToast } from '@/hooks/useToast';
@@ -11,7 +12,7 @@ import { cn } from '@/lib/utils';
* Template thumbnail row: each click sets the cover URL to that template's
* URL. The thumbnail strip is optional — pass `templates` to enable it.
*/
export interface CoverImageTemplate {
interface CoverImageTemplate {
id: string;
/** Sanitized https URL the picker will publish if this template is chosen. */
url: string;
@@ -54,6 +55,7 @@ interface CoverImageFieldProps {
* the same value is what gets published in the Nostr event's `image` tag.
*/
export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates }: CoverImageFieldProps) {
const { t } = useTranslation();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
@@ -171,7 +173,7 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
) : (
<>
<ImagePlus className="size-8" />
<span className="text-sm">Click or drag an image here</span>
<span className="text-sm">{t('forms.imageDropzone')}</span>
<span className="text-xs">PNG, JPG, or WEBP</span>
</>
)}
@@ -224,7 +226,7 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
<Input
type="url"
inputMode="url"
placeholder="Or paste an https:// image URL"
placeholder="https://imageurl.com/example"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
@@ -3,6 +3,7 @@ import { CalendarDays, ChevronLeft } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import {
Dialog,
@@ -36,6 +37,7 @@ interface CreateCommunityEventDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
event?: NostrEvent;
onPublished?: (naddr: string) => void;
}
const MANAGED_EDIT_TAGS = new Set([
@@ -92,7 +94,7 @@ function toLocalTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
}
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event }: CreateCommunityEventDialogProps) {
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event, onPublished }: CreateCommunityEventDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
@@ -374,6 +376,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
]);
toast({ title: isEditing ? 'Event updated!' : 'Event created!' });
onPublished?.(nip19.naddrEncode({ kind, pubkey: publishedEvent.pubkey, identifier: dTag }));
handleOpenChange(false);
} catch (err) {
toast({
@@ -395,6 +398,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
isEditing,
location,
nostr,
onPublished,
publishEvent,
publishRSVP,
queryClient,
+16 -1
View File
@@ -1,11 +1,17 @@
import { type ReactNode, useCallback, useState } from 'react';
import { useImageProxy } from '@/hooks/useImageProxy';
import { isCustomEmoji, getCustomEmojiUrl, buildEmojiMap, type ResolvedEmoji } from '@/lib/customEmoji';
import { cn } from '@/lib/utils';
/** Threshold at or below which we apply nearest-neighbor scaling. */
const PIXEL_ART_MAX = 16;
/** Proxy width for custom emojis. wsrv.nl doesn't upscale by default, so
* tiny pixel-art emojis stay at their natural size; only oversized PNGs/GIFs
* get downscaled. */
const EMOJI_PROXY_WIDTH = 48;
interface CustomEmojiImgProps {
/** The shortcode name (without colons). */
name: string;
@@ -23,6 +29,8 @@ interface CustomEmojiImgProps {
*/
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
const [pixelated, setPixelated] = useState(false);
const [proxyFailed, setProxyFailed] = useState(false);
const proxy = useImageProxy();
const handleLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
@@ -31,9 +39,13 @@ export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2
}
}, []);
const proxied = proxy(url, EMOJI_PROXY_WIDTH);
const usingProxy = proxied !== url;
const finalSrc = proxyFailed || !usingProxy ? url : proxied;
return (
<img
src={url}
src={finalSrc}
alt={`:${name}:`}
title={`:${name}:`}
className={className}
@@ -41,6 +53,9 @@ export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2
loading="lazy"
decoding="async"
onLoad={handleLoad}
onError={() => {
if (usingProxy && !proxyFailed) setProxyFailed(true);
}}
/>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ interface DetailCommentComposerProps {
export function DetailCommentComposer({
event,
placeholder = "What's on your mind?",
placeholder,
onSuccess,
className,
}: DetailCommentComposerProps) {
-3
View File
@@ -731,6 +731,3 @@ function SignerUnsupportedView({
// Loading skeleton (for callers that need a placeholder button)
// ─────────────────────────────────────────────────────────────────────
export function DonateButtonSkeleton() {
return <Skeleton className="h-11 w-full rounded-md" />;
}
+14 -3
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
@@ -16,6 +16,7 @@ import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { useImageProxy } from '@/hooks/useImageProxy';
import { genUserName } from '@/lib/genUserName';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { isProfileBadgesKind } from '@/lib/badgeUtils';
@@ -100,6 +101,8 @@ export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNa
function EmbeddedBadgeCard({ event, className }: { event: NostrEvent; className?: string }) {
const navigate = useNavigate();
const badge = useMemo(() => parseBadgeDefinition(event), [event]);
const proxy = useImageProxy();
const [proxyFailed, setProxyFailed] = useState(false);
const naddrId = useMemo(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
@@ -112,6 +115,11 @@ function EmbeddedBadgeCard({ event, className }: { event: NostrEvent; className?
?? badge.thumbs.find((t) => t.dimensions === '512x512')?.url
?? badge.thumbs[0]?.url;
// Proxy at 192 — rendered `size-20` is 80px, 2× DPR = 160. Round up.
const proxied = heroImage ? proxy(heroImage, 192) : heroImage;
const usingProxy = proxied !== heroImage;
const displaySrc = proxyFailed || !usingProxy ? heroImage : proxied;
return (
<div
className={cn(
@@ -162,13 +170,16 @@ function EmbeddedBadgeCard({ event, className }: { event: NostrEvent; className?
{/* Badge image */}
<div className="relative z-[1]">
{heroImage ? (
{displaySrc ? (
<img
src={heroImage}
src={displaySrc}
alt={badge.name}
className="size-20 rounded-xl object-cover drop-shadow-lg"
loading="lazy"
decoding="async"
onError={() => {
if (usingProxy && !proxyFailed) setProxyFailed(true);
}}
/>
) : (
<div className="size-20 rounded-xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
+1 -1
View File
@@ -17,7 +17,7 @@ const EmojiPackDialog = lazy(() => import('@/components/EmojiPackDialog').then(m
const PREVIEW_LIMIT = 24;
/** Parsed emoji pack data. */
export interface EmojiPackData {
interface EmojiPackData {
identifier: string;
name: string;
picture?: string;
+1 -139
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, Megaphone, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users } from 'lucide-react';
import { ArrowLeft, BookOpen, Coins, ExternalLink, Globe, Landmark, Languages, MapPin, MessageCircle, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';import { ExternalFavicon } from '@/components/ExternalFavicon';
@@ -31,8 +31,6 @@ import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
import { useCountryFacts, type CountryFacts } from '@/hooks/useCountryFacts';
import { useCommonsAudio } from '@/hooks/useCommonsAudio';
import { formatNumber } from '@/lib/formatNumber';
import { EXTRA_KINDS } from '@/lib/extraKinds';
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
@@ -1393,139 +1391,3 @@ export function ProfilePreview({ pubkey }: { pubkey: string }) {
// Addressable event preview (vines, music, articles, etc.)
// ---------------------------------------------------------------------------
/** Extract a thumbnail URL from an addressable event's tags. */
function extractThumbnail(tags: string[][]): string | undefined {
// 1. Explicit icon tag (used by zapstore kind 32267)
const iconTag = tags.find(([n]) => n === 'icon')?.[1];
if (iconTag) return iconTag;
// 2. Explicit image/thumb tag
const imageTag = tags.find(([n]) => n === 'image' || n === 'thumb')?.[1];
if (imageTag) return imageTag;
// 3. imeta tag (used by vines / kind 34236)
const imetaTag = tags.find(([n]) => n === 'imeta');
if (imetaTag) {
for (let i = 1; i < imetaTag.length; i++) {
const part = imetaTag[i];
if (part.startsWith('image ')) return part.slice(6);
}
}
return undefined;
}
/** Check if an event has video content (imeta with url containing video indicators). */
function hasVideo(tags: string[][]): boolean {
const imetaTag = tags.find(([n]) => n === 'imeta');
if (!imetaTag) return false;
for (let i = 1; i < imetaTag.length; i++) {
const part = imetaTag[i];
if (part.startsWith('url ') || part.startsWith('m video/')) return true;
}
return false;
}
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
9041: 'Goal',
31990: 'App',
32267: 'Zapstore App',
30063: 'Zapstore Release',
3063: 'Zapstore Asset',
15128: 'Nsite',
35128: 'Nsite',
36639: 'Pledge',
};
export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) {
const { data: event, isLoading } = useAddrEvent(addr);
const author = useAuthor(addr.pubkey);
const authorMeta = author.data?.metadata;
const authorName = authorMeta?.name ?? genUserName(addr.pubkey);
const kindDef = useMemo(
() => EXTRA_KINDS.find((d) => d.kind === addr.kind || d.subKinds?.some((s) => s.kind === addr.kind)),
[addr.kind],
);
const kindLabel = useMemo(() => {
if (kindDef) return kindDef.label;
const sub = EXTRA_KINDS.flatMap((d) => d.subKinds ?? []).find((s) => s.kind === addr.kind);
if (sub) return sub.label;
return WELL_KNOWN_KIND_LABELS[addr.kind] ?? `Kind ${addr.kind}`;
}, [kindDef, addr.kind]);
const KindIcon = useMemo(() => {
if (kindDef?.id) return CONTENT_KIND_ICONS[kindDef.id] ?? FileText;
// Fallback icons for well-known kinds not in EXTRA_KINDS
if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063 || addr.kind === 3063) return Package;
if (addr.kind === 15128 || addr.kind === 35128) return Globe;
if (addr.kind === 36639) return Megaphone;
return FileText;
}, [kindDef, addr.kind]);
const title = event?.tags.find(([n]) => n === 'title')?.[1]
|| event?.tags.find(([n]) => n === 'name')?.[1]
|| event?.tags.find(([n]) => n === 'd')?.[1]
|| kindLabel;
const thumbnail = event ? extractThumbnail(event.tags) : undefined;
const isVideo = event ? hasVideo(event.tags) : false;
const link = useMemo(() => {
return `/${nip19.naddrEncode({ kind: addr.kind, pubkey: addr.pubkey, identifier: addr.identifier })}`;
}, [addr]);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
);
}
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
{thumbnail ? (
<div className="relative size-12 rounded-lg overflow-hidden shrink-0">
<img
src={thumbnail}
alt={title}
className="size-full object-cover"
loading="lazy"
/>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<Play className="size-4 text-white fill-white" />
</div>
)}
</div>
) : (
<div className="size-12 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<KindIcon className="size-5 text-primary/50" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<KindIcon className="size-3 shrink-0" />
<span>{kindLabel}</span>
<span className="text-muted-foreground/60">&middot;</span>
<span className="truncate">{authorName}</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{title}
</p>
</div>
</Link>
);
}
+14 -11
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useInView } from 'react-intersection-observer';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { ComposeBox } from '@/components/ComposeBox';
@@ -49,6 +50,7 @@ function isFeedMode(value: string): value is FeedMode {
}
export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, feedId = 'home' }: FeedProps = {}) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
@@ -192,14 +194,14 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// Per-mode empty-state copy for the home feed.
const homeEmptyMessage = (() => {
if (homeFeedMode === 'agora') {
return "Quiet moment on Agora. New campaigns, pledges, donations, and posts will appear here as they happen.";
return t('feed.empty.homeAgora');
}
if (homeFeedMode === 'following') {
return user
? "Your follow feed is empty. Follow some people to see what they're up to, or switch to Agora or All Nostr."
: "Log in to see posts from people you follow.";
? t('feed.empty.homeFollowingLoggedIn')
: t('feed.empty.homeFollowingLoggedOut');
}
return 'Nothing to show. Check your relay connections or try again in a moment.';
return t('feed.empty.homeOther');
})();
return (
@@ -229,7 +231,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
compact
hideBorder={isHomeAgoraFeed}
defaultExpanded
placeholder="What's happening?"
placeholder={t('feed.compose.placeholder')}
/>
)}
@@ -237,8 +239,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
the FeedModeSwitcher above. */}
{user && (isKindSpecificPage || tagFilters) && (
<SubHeaderBar>
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
<TabButton label={isKindSpecificPage || tagFilters ? t('feed.tabs.follows') : t('feed.tabs.following')} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
<TabButton label={t('feed.tabs.global')} active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
</SubHeaderBar>
)}
@@ -280,8 +282,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
message={
emptyMessage ?? (
activeTab === 'follows'
? 'Your feed is empty. Follow some people to see their posts here.'
: 'No posts found. Check your relay connections or come back soon.'
? t('feed.empty.follows')
: t('feed.empty.global')
)
}
showDiscover={!emptyMessage && activeTab === 'follows'}
@@ -314,13 +316,14 @@ interface HomeFeedEmptyStateProps {
}
function HomeFeedEmptyState({ mode, message, onSwitchToAgora, onLoginClick }: HomeFeedEmptyStateProps) {
const { t } = useTranslation();
return (
<div className="py-20 px-8 flex flex-col items-center text-center">
<p className="text-muted-foreground max-w-sm leading-relaxed">{message}</p>
<div className="flex flex-col gap-2 mt-6 w-full max-w-xs">
{onLoginClick && (
<Button className="rounded-full" onClick={onLoginClick}>
Log in
{t('feed.empty.logIn')}
</Button>
)}
{onSwitchToAgora && (
@@ -329,7 +332,7 @@ function HomeFeedEmptyState({ mode, message, onSwitchToAgora, onLoginClick }: Ho
className="rounded-full"
onClick={onSwitchToAgora}
>
Browse the Agora feed
{t('feed.empty.browseAgora')}
</Button>
)}
</div>
+4 -2
View File
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/useAppContext';
@@ -28,6 +29,7 @@ export function FeedEmptyState({
showDiscover,
className,
}: FeedEmptyStateProps) {
const { t } = useTranslation();
const { config } = useAppContext();
// The /packs page defaults to the Follows tab, which is also empty when the
@@ -51,12 +53,12 @@ export function FeedEmptyState({
<div className="flex flex-col gap-2 mt-5 w-full max-w-xs">
{showDiscover && (
<Button asChild className="rounded-full">
<Link to="/packs" onClick={handleDiscoverClick}>Discover people to follow</Link>
<Link to="/packs" onClick={handleDiscoverClick}>{t('feed.empty.discoverPeople')}</Link>
</Button>
)}
{onSwitchToGlobal && (
<Button variant="ghost" className="rounded-full" onClick={onSwitchToGlobal}>
Browse the Global feed
{t('feed.empty.browseGlobal')}
</Button>
)}
</div>
+43 -14
View File
@@ -1,5 +1,5 @@
import { Check, ChevronDown, Globe, Sparkles, Users } from 'lucide-react';
import type { ComponentType } from 'react';
import { Check, ChevronDown, Globe, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
DropdownMenu,
@@ -17,16 +17,33 @@ import { cn } from '@/lib/utils';
interface FeedModeOption {
mode: FeedMode;
label: string;
icon: ComponentType<{ className?: string }>;
/** Translation key suffix under `feed.modeSwitcher`. */
i18nKey: 'agora' | 'allNostr' | 'following';
}
const OPTIONS: FeedModeOption[] = [
{ mode: 'agora', label: 'Agora', icon: Sparkles },
{ mode: 'all-nostr', label: 'All Nostr', icon: Globe },
{ mode: 'following', label: 'Following', icon: Users },
{ mode: 'agora', i18nKey: 'agora' },
{ mode: 'all-nostr', i18nKey: 'allNostr' },
{ mode: 'following', i18nKey: 'following' },
];
function FeedModeIcon({ mode, className }: { mode: FeedMode; className?: string }) {
if (mode === 'agora') {
return (
<span
className={cn(
"inline-block shrink-0 bg-current [mask-image:url('/logo.svg')] [mask-position:center] [mask-repeat:no-repeat] [mask-size:contain]",
className,
)}
aria-hidden
/>
);
}
const Icon = mode === 'following' ? Users : Globe;
return <Icon className={cn('shrink-0', className)} aria-hidden />;
}
interface FeedModeSwitcherProps {
value: FeedMode;
onChange: (mode: FeedMode) => void;
@@ -54,7 +71,9 @@ export function FeedModeSwitcher({
onLoginRequested,
className,
}: FeedModeSwitcherProps) {
const { t } = useTranslation();
const active = OPTIONS.find((opt) => opt.mode === value) ?? OPTIONS[0];
const activeLabel = t(`feed.modeSwitcher.${active.i18nKey}`);
return (
<DropdownMenu>
@@ -65,10 +84,17 @@ export function FeedModeSwitcher({
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background',
className,
)}
aria-label={`Feed mode: ${active.label}. Click to change.`}
aria-label={t('feed.modeSwitcher.ariaLabel', { label: activeLabel })}
>
<span className="text-2xl sm:text-3xl font-bold tracking-tight leading-none">
{active.label}
<FeedModeIcon
mode={active.mode}
className={cn('size-6 sm:size-7', active.mode === 'agora' ? 'text-primary' : 'text-muted-foreground')}
/>
<span className={cn(
'text-2xl sm:text-3xl font-bold tracking-tight leading-none',
active.mode === 'agora' && 'text-primary',
)}>
{activeLabel}
</span>
<ChevronDown
className="size-5 text-muted-foreground motion-safe:transition-transform group-data-[state=open]:rotate-180"
@@ -77,7 +103,7 @@ export function FeedModeSwitcher({
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-56 p-1.5">
{OPTIONS.map((opt) => {
const Icon = opt.icon;
const label = t(`feed.modeSwitcher.${opt.i18nKey}`);
const isActive = opt.mode === value;
const isFollowing = opt.mode === 'following';
const disabled = isFollowing && !followingAvailable;
@@ -101,8 +127,11 @@ export function FeedModeSwitcher({
)}
data-disabled={disabled || undefined}
>
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 text-sm font-medium">{opt.label}</span>
<FeedModeIcon
mode={opt.mode}
className={cn('size-4', opt.mode === 'agora' ? 'text-primary' : 'text-muted-foreground')}
/>
<span className={cn('flex-1 text-sm font-medium', opt.mode === 'agora' && 'text-primary')}>{label}</span>
{isActive && <Check className="size-4 shrink-0 text-primary" aria-hidden />}
</DropdownMenuItem>
);
@@ -112,7 +141,7 @@ export function FeedModeSwitcher({
<Tooltip key={opt.mode}>
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
<TooltipContent side="right">
Log in to see posts from people you follow
{t('feed.modeSwitcher.loginRequired')}
</TooltipContent>
</Tooltip>
);
+11 -8
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -52,6 +53,7 @@ export function FollowToggleButton({
followingIcon,
hoverToUnfollow = false,
}: FollowToggleButtonProps) {
const { t } = useTranslation();
const leadingIcon = isFollowing ? (followingIcon ?? icon) : icon;
const followedLabel = (
isPending
@@ -61,12 +63,12 @@ export function FollowToggleButton({
// Two spans crossfade on hover/focus via group state — keeps button width stable.
? (
<>
<span className="group-hover:hidden group-focus-visible:hidden">Following</span>
<span className="hidden group-hover:inline group-focus-visible:inline">Unfollow</span>
<span className="group-hover:hidden group-focus-visible:hidden">{t('follow.following')}</span>
<span className="hidden group-hover:inline group-focus-visible:inline">{t('follow.unfollow')}</span>
</>
)
: 'Unfollow'
: 'Follow'
: t('follow.unfollow')
: t('follow.follow')
);
return (
@@ -94,6 +96,7 @@ export function FollowToggleButton({
* Hides itself when the target is the logged-in user or when no user is logged in.
*/
export function FollowButton({ pubkey, className, size = 'sm' }: FollowButtonProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const { isPending, follow, unfollow } = useFollowActions();
@@ -113,17 +116,17 @@ export function FollowButton({ pubkey, className, size = 'sm' }: FollowButtonPro
if (isFollowing) {
await unfollow(pubkey);
impactMedium();
toast({ title: 'Unfollowed' });
toast({ title: t('follow.unfollowed') });
} else {
await follow(pubkey);
impactMedium();
toast({ title: 'Followed' });
toast({ title: t('follow.followed') });
}
} catch (err) {
console.error('Follow toggle failed:', err);
toast({ title: 'Failed to update follow list', variant: 'destructive' });
toast({ title: t('follow.updateFailed'), variant: 'destructive' });
}
}, [user, pubkey, isFollowing, follow, unfollow, toast]);
}, [user, pubkey, isFollowing, follow, unfollow, toast, t]);
// Don't render for own profile or when logged out
if (!user || user.pubkey === pubkey) return null;
+4 -4
View File
@@ -28,7 +28,7 @@ type Tab = 'feed' | 'members';
// ─── Feed Tab ─────────────────────────────────────────────────────────────────
export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
const { muteItems } = useMuteList();
const { posts, isLoading } = useStreamPosts('', {
@@ -89,7 +89,7 @@ export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
// ─── Members Tab ──────────────────────────────────────────────────────────────
export function PackMembersTab({
function PackMembersTab({
pubkeys,
membersMap,
membersLoading,
@@ -322,7 +322,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
}
/** Individual member card in the follow pack. */
export function MemberCard({
function MemberCard({
pubkey,
metadata,
isFollowed,
@@ -401,7 +401,7 @@ export function MemberCard({
);
}
export function MemberCardSkeleton() {
function MemberCardSkeleton() {
return (
<div className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-11 rounded-full shrink-0" />
+9 -1
View File
@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
/**
* Section wrapper used by the long single-column form pages
* (`CreateActionPage`, `CreateCampaignPage`). Each section is a titled
@@ -13,13 +15,19 @@ export function FormSection({
requirement: 'Required' | 'Recommended' | 'Optional';
children: React.ReactNode;
}) {
const { t } = useTranslation();
const requirementLabel = {
Required: t('forms.required'),
Recommended: t('forms.recommended'),
Optional: t('forms.optional'),
}[requirement];
return (
<section className="space-y-2.5 rounded-xl p-3 sm:p-4">
<div className="space-y-0.5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
{title}
<span className="text-xs font-medium text-muted-foreground">
{requirement}
{requirementLabel}
</span>
</h2>
</div>
+60 -52
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { nip19 } from 'nostr-tools';
import {
AlertTriangle,
@@ -61,13 +62,6 @@ const USD_PRESETS = [1, 5, 10, 25, 100];
type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
const FEE_SPEED_LABELS: Record<FeeSpeed, string> = {
fastest: '~10 min',
halfHour: '~30 min',
hour: '~1 hour',
economy: '~1 day',
};
const FEE_SPEED_ORDER: FeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
function getRateForSpeed(rates: BlockbookFeeRates, speed: FeeSpeed): number {
@@ -192,6 +186,7 @@ interface SendResult {
* keys, and emits change to a fresh internal address.
*/
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoinDialogProps) {
const { t } = useTranslation();
const availability = useHdWalletAccess();
const {
scan,
@@ -207,6 +202,16 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
const isReady = availability.status === 'available';
const feeSpeedLabels: Record<FeeSpeed, string> = useMemo(
() => ({
fastest: t('walletSend.feeSpeed.fastest'),
halfHour: t('walletSend.feeSpeed.halfHour'),
hour: t('walletSend.feeSpeed.hour'),
economy: t('walletSend.feeSpeed.economy'),
}),
[t],
);
// ── Form state ───────────────────────────────────────────────
const [recipientInput, setRecipientInput] = useState('');
const [usdAmount, setUsdAmount] = useState<number | string>(5);
@@ -359,14 +364,14 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
const sendMutation = useMutation<SendResult, Error, void>({
mutationFn: async () => {
if (availability.status !== 'available') {
throw new Error('HD wallet is not available for this login type.');
throw new Error(t('walletSend.errors.unavailable'));
}
if (!recipient) throw new Error('Enter a Bitcoin address, sp1… address, or npub.');
if (!ownedInputs.length) throw new Error('No spendable Bitcoin in this wallet.');
if (!feeRates) throw new Error('Fee rates not loaded.');
if (recipient.pubkey === availability.pubkey) throw new Error("You can't send to yourself.");
if (amountSats <= 0) throw new Error('Enter an amount.');
if (insufficient) throw new Error('Not enough Bitcoin for this amount + network fee.');
if (!recipient) throw new Error(t('walletSend.errors.enterRecipient'));
if (!ownedInputs.length) throw new Error(t('walletSend.errors.noSpendable'));
if (!feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
if (recipient.pubkey === availability.pubkey) throw new Error(t('walletSend.errors.cantSendToSelf'));
if (amountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
if (insufficient) throw new Error(t('walletSend.errors.insufficient'));
const rate = getRateForSpeed(feeRates, feeSpeed);
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
@@ -417,7 +422,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
void refetchWallet();
},
onError: (err) => {
toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' });
toast({ title: t('walletSend.toast.failedTitle'), description: err.message, variant: 'destructive' });
},
onSettled: () => setProgress('idle'),
});
@@ -425,20 +430,21 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
const handleSend = useCallback(() => {
setError('');
if (availability.status !== 'available') {
setError('HD wallet is not available for this login type.'); return;
setError(t('walletSend.errors.unavailable')); return;
}
if (!recipient) { setError('Enter a Bitcoin address, sp1… address, or npub.'); return; }
if (recipient.pubkey === availability.pubkey) { setError("You can't send to yourself."); return; }
if (!btcPrice) { setError('Waiting for BTC price'); return; }
if (amountSats <= 0) { setError('Enter an amount.'); return; }
if (!ownedInputs.length) { setError("You don't have any Bitcoin yet."); return; }
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
if (!recipient) { setError(t('walletSend.errors.enterRecipient')); return; }
if (recipient.pubkey === availability.pubkey) { setError(t('walletSend.errors.cantSendToSelf')); return; }
if (!btcPrice) { setError(t('walletSend.errors.waitingPrice')); return; }
if (amountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
if (!ownedInputs.length) { setError(t('walletSend.errors.noneYet')); return; }
if (insufficient) { setError(t('walletSend.errors.insufficient')); return; }
if (isRawAddress && !acknowledgedPublic) {
setError('Acknowledge the privacy warning before sending.'); return;
setError(t('walletSend.errors.acknowledgePrivacy')); return;
}
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
sendMutation.mutate();
}, [
t,
availability,
recipient,
btcPrice,
@@ -472,14 +478,14 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
const sendButtonLabel = (() => {
if (sendMutation.isPending) {
switch (progress) {
case 'building': return 'Building transaction…';
case 'signing': return 'Signing';
case 'broadcasting': return 'Broadcasting';
default: return 'Sending';
case 'building': return t('walletSend.progress.building');
case 'signing': return t('walletSend.progress.signing');
case 'broadcasting': return t('walletSend.progress.broadcasting');
default: return t('walletSend.progress.sending');
}
}
if (confirmArmed) return 'Tap again to confirm';
return 'Send Bitcoin';
if (confirmArmed) return t('walletSend.tapAgainToConfirm');
return t('walletSend.send');
})();
const sendDisabled =
@@ -495,7 +501,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
return (
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
<DialogContent className="sm:max-w-md p-0 gap-0 overflow-hidden [&>button]:hidden">
<DialogTitle className="sr-only">Send Bitcoin</DialogTitle>
<DialogTitle className="sr-only">{t('walletSend.title')}</DialogTitle>
{success ? (
<SuccessScreen
@@ -508,11 +514,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
<div className="grid gap-5 px-6 py-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Send Bitcoin</h2>
<h2 className="text-base font-semibold">{t('walletSend.title')}</h2>
<button
onClick={handleClose}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close"
aria-label={t('common.close')}
>
<X className="size-4" />
</button>
@@ -545,7 +551,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
)}
{amountSats > 0 && btcPrice && (
<span className="text-xs text-muted-foreground mt-1 tabular-nums">
{amountSats.toLocaleString()} sats
{t('walletSend.approxSats', { sats: amountSats.toLocaleString() })}
</span>
)}
</div>
@@ -572,26 +578,24 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
{/* Recipient */}
<div className="grid gap-1">
<label className="text-xs text-muted-foreground" htmlFor="hd-recipient-input">
Recipient
{t('walletSend.recipient.label')}
</label>
<Input
id="hd-recipient-input"
value={recipientInput}
onChange={(e) => setRecipientInput(e.target.value)}
placeholder="bc1…, sp1…, or npub…"
placeholder={t('walletSend.recipient.placeholder')}
autoComplete="off"
spellCheck={false}
className="font-mono text-sm"
/>
{recipient && (
<p className="text-xs text-muted-foreground">
{recipient.kind === 'sp' ? (
<>Sending via a silent payment the recipient gets a fresh, unlinkable on-chain address.</>
) : recipient.pubkey ? (
<>Sending to a Nostr user&apos;s on-chain address.</>
) : (
<>Sending to a raw Bitcoin address.</>
)}
{recipient.kind === 'sp'
? t('walletSend.recipient.sendingSp')
: recipient.pubkey
? t('walletSend.recipient.sendingNostr')
: t('walletSend.recipient.sendingRaw')}
</p>
)}
</div>
@@ -606,7 +610,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
{/* Fee speed */}
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Network fee</span>
<span className="text-muted-foreground">{t('walletSend.networkFee')}</span>
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
<PopoverTrigger asChild>
<button
@@ -616,12 +620,12 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
{estimatedFeeSats > 0 && btcPrice ? (
<> {satsToUSD(estimatedFeeSats, btcPrice)}</>
) : currentFeeRate ? (
<>{currentFeeRate} sat/vB</>
<>{t('walletSend.satPerVB', { rate: currentFeeRate })}</>
) : (
<></>
)}
<span className="opacity-60">·</span>
{FEE_SPEED_LABELS[feeSpeed]}
{feeSpeedLabels[feeSpeed]}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end">
@@ -636,10 +640,10 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
feeSpeed === speed && 'bg-muted',
)}
>
<span>{FEE_SPEED_LABELS[speed]}</span>
<span>{feeSpeedLabels[speed]}</span>
{feeRates && (
<span className="text-muted-foreground tabular-nums">
{getRateForSpeed(feeRates, speed)} sat/vB
{t('walletSend.satPerVB', { rate: getRateForSpeed(feeRates, speed) })}
</span>
)}
</button>
@@ -651,7 +655,10 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
{showBalance && totalBalance > 0 && btcPrice && (
<p className="text-xs text-muted-foreground text-center">
Available: {satsToUSD(totalBalance, btcPrice)} ({totalBalance.toLocaleString()} sats)
{t('walletSend.available', {
usd: satsToUSD(totalBalance, btcPrice),
sats: totalBalance.toLocaleString(),
})}
</p>
)}
@@ -695,6 +702,7 @@ interface SuccessScreenProps {
}
function SuccessScreen({ txid, amountSats, btcPrice, onClose }: SuccessScreenProps) {
const { t } = useTranslation();
const usdDisplay = btcPrice ? satsToUSD(amountSats, btcPrice) : '';
return (
@@ -721,9 +729,9 @@ function SuccessScreen({ txid, amountSats, btcPrice, onClose }: SuccessScreenPro
</div>
<div className="grid gap-1">
<h2 className="text-lg font-semibold tracking-tight">Bitcoin sent</h2>
<h2 className="text-lg font-semibold tracking-tight">{t('walletSend.success.title')}</h2>
<div className="text-4xl font-bold tabular-nums bg-gradient-to-br from-amber-500 to-orange-600 bg-clip-text text-transparent">
{usdDisplay || `${amountSats.toLocaleString()} sats`}
{usdDisplay || t('walletSend.success.satsAmount', { sats: amountSats.toLocaleString() })}
</div>
</div>
@@ -731,10 +739,10 @@ function SuccessScreen({ txid, amountSats, btcPrice, onClose }: SuccessScreenPro
<Button type="button" variant="outline" asChild className="w-full">
<Link to={`/i/bitcoin:tx:${txid}`} onClick={onClose}>
<ExternalLink className="size-4 mr-2" />
View transaction
{t('walletSend.success.viewTransaction')}
</Link>
</Button>
<Button type="button" onClick={onClose} className="w-full">Done</Button>
<Button type="button" onClick={onClose} className="w-full">{t('walletSend.success.done')}</Button>
</div>
</div>
);
+39 -35
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
import {
@@ -25,12 +26,13 @@ import { useHdWalletSp } from '@/hooks/useHdWalletSp';
// targeted backfill.
// ---------------------------------------------------------------------------
export interface HDSilentPaymentScanDialogProps {
interface HDSilentPaymentScanDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymentScanDialogProps) {
const { t } = useTranslation();
const sp = useHdWalletSp();
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
@@ -82,9 +84,9 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Scan for silent payments</DialogTitle>
<DialogTitle>{t('spScan.title')}</DialogTitle>
<DialogDescription>
Walks the configured BIP-352 indexer block-by-block to detect incoming silent payments.
{t('spScan.description')}
</DialogDescription>
</DialogHeader>
@@ -92,7 +94,7 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="sp-scan-from" className="text-xs">
From block
{t('spScan.fromBlock')}
</Label>
<Input
id="sp-scan-from"
@@ -110,14 +112,14 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-scan-to" className="text-xs">
To block
{t('spScan.toBlock')}
</Label>
<Input
id="sp-scan-to"
type="number"
inputMode="numeric"
min={0}
placeholder="tip"
placeholder={t('spScan.tipPlaceholder')}
value={to}
onChange={(e) => {
setTouched(true);
@@ -131,13 +133,13 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
{sp.tipHeight !== undefined && (
<p className="text-xs text-muted-foreground">
Indexer tip: <span className="font-mono">{sp.tipHeight.toLocaleString()}</span>
{t('spScan.indexerTip')}: <span className="font-mono">{sp.tipHeight.toLocaleString()}</span>
{sp.storage && (
<>
{' · '}
Last fully scanned:{' '}
{t('spScan.lastFullyScanned')}:{' '}
<span className="font-mono">
{sp.storage.scanHeight > 0 ? sp.storage.scanHeight.toLocaleString() : 'never'}
{sp.storage.scanHeight > 0 ? sp.storage.scanHeight.toLocaleString() : t('spScan.never')}
</span>
</>
)}
@@ -164,11 +166,10 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
/>
<div className="space-y-0.5">
<Label htmlFor="sp-include-spent" className="text-xs cursor-pointer">
Include already-spent
{t('spScan.includeSpent')}
</Label>
<p className="text-xs text-muted-foreground">
Also detect silent payments that have since been spent. Use when
rebuilding receive history after a missed scan or a reset.
{t('spScan.includeSpentDesc')}
</p>
</div>
</div>
@@ -179,12 +180,13 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Block {sp.scanProgress.currentHeight.toLocaleString()} /{' '}
{sp.scanProgress.toHeight.toLocaleString()}
{t('spScan.blockProgress', {
current: sp.scanProgress.currentHeight.toLocaleString(),
to: sp.scanProgress.toHeight.toLocaleString(),
})}
</span>
<span>
{sp.scanProgress.matchesFound} match
{sp.scanProgress.matchesFound === 1 ? '' : 'es'}
{t('spScan.matches', { count: sp.scanProgress.matchesFound })}
</span>
</div>
</div>
@@ -201,13 +203,13 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<CheckCircle2 className="size-4 shrink-0 mt-0.5 text-green-500" />
<p>
Scanned blocks {sp.scanProgress.fromHeight.toLocaleString()} {' '}
{sp.scanProgress.currentHeight.toLocaleString()}.{' '}
{t('spScan.scannedRange', {
from: sp.scanProgress.fromHeight.toLocaleString(),
to: sp.scanProgress.currentHeight.toLocaleString(),
})}{' '}
{sp.scanProgress.matchesFound > 0
? `Found ${sp.scanProgress.matchesFound} new ${
sp.scanProgress.matchesFound === 1 ? 'output' : 'outputs'
}.`
: 'No new payments.'}
? t('spScan.foundOutputs', { count: sp.scanProgress.matchesFound })
: t('spScan.noNewPayments')}
</p>
</div>
)}
@@ -224,21 +226,23 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
{sp.storage && sp.storage.utxos.length > 0 && (
<div className="space-y-2 border-t pt-3">
<div className="space-y-1">
<Label className="text-xs">Reconcile spent UTXOs</Label>
<Label className="text-xs">{t('spScan.reconcile.title')}</Label>
<p className="text-xs text-muted-foreground">
Checks each stored silent-payment UTXO against Blockbook and removes any
that have been spent. Use this if the balance is higher than it should
be after a send.
{t('spScan.reconcile.description')}
</p>
</div>
{sp.reconcileProgress && !sp.reconcileError && (
<p className="text-xs text-muted-foreground">
{sp.isReconciling
? `Checking ${sp.reconcileProgress.checked} / ${sp.reconcileProgress.total}`
: `Checked ${sp.reconcileProgress.checked} UTXO${
sp.reconcileProgress.checked === 1 ? '' : 's'
} · pruned ${sp.reconcileProgress.prunedSoFar}.`}
? t('spScan.reconcile.checking', {
checked: sp.reconcileProgress.checked,
total: sp.reconcileProgress.total,
})
: t('spScan.reconcile.checked', {
count: sp.reconcileProgress.checked,
pruned: sp.reconcileProgress.prunedSoFar,
})}
</p>
)}
@@ -260,10 +264,10 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
{sp.isReconciling ? (
<>
<Loader2 className="size-3 animate-spin mr-2" />
Reconciling
{t('spScan.reconcile.reconciling')}
</>
) : (
'Reconcile now'
t('spScan.reconcile.reconcileNow')
)}
</Button>
</div>
@@ -272,15 +276,15 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
<div className="flex justify-end gap-2 pt-2">
{sp.isScanning ? (
<Button variant="outline" onClick={() => sp.cancelScan()}>
Cancel
{t('common.cancel')}
</Button>
) : (
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
{t('common.close')}
</Button>
<Button onClick={handleScan} disabled={!inputsValid}>
Start scan
{t('spScan.startScan')}
</Button>
</>
)}
+9 -1
View File
@@ -1,4 +1,5 @@
import { useMemo, useState, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import {
Accordion,
@@ -77,6 +78,11 @@ export function HelpFAQSection({
listTone = 'default',
}: HelpFAQSectionProps) {
const { config } = useAppContext();
const { i18n } = useTranslation();
// `getFAQCategories` resolves strings against `i18n.language` at call
// time. Capturing the language in a local lets us include it as a memo
// dep so a language switch re-runs the resolver.
const lng = i18n.language;
const filteredCategories = useMemo(() => {
let cats: FAQCategory[] = getFAQCategories(config.appName);
@@ -104,7 +110,9 @@ export function HelpFAQSection({
}
return cats;
}, [categories, items, config.appName]);
// `lng` is in the dep list to force re-resolution on a language switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categories, items, config.appName, lng]);
// Tab state: first category is selected by default.
const [activeTab, setActiveTab] = useState<string | null>(
+4
View File
@@ -1,5 +1,6 @@
import { HelpCircle } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppContext } from '@/hooks/useAppContext';
@@ -29,6 +30,9 @@ interface HelpTipProps {
*/
export function HelpTip({ faqId, iconSize = 'size-4', className }: HelpTipProps) {
const { config } = useAppContext();
// Subscribing to `useTranslation()` makes the component re-render on a
// language switch so the popover content reflects the new locale.
useTranslation();
const item = getFAQItem(config.appName, faqId);
if (!item) return null;
+86 -30
View File
@@ -1,12 +1,14 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
import { ChevronLeft, ChevronRight, X, Download, Image as ImageIcon } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
import { isValidBlurhash } from '@/lib/blurhash';
import { openUrl } from '@/lib/downloadFile';
import { Skeleton } from '@/components/ui/skeleton';
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
import { useAppContext } from '@/hooks/useAppContext';
import { useImageProxy } from '@/hooks/useImageProxy';
import { VideoPlayer } from '@/components/VideoPlayer';
import { AudioVisualizer } from '@/components/AudioVisualizer';
import { useAuthor } from '@/hooks/useAuthor';
@@ -176,8 +178,22 @@ function GridImage({
}) {
const [loaded, setLoaded] = useState(false);
const [probedAspectRatio, setProbedAspectRatio] = useState<string | undefined>(undefined);
const [proxyFailed, setProxyFailed] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const { src, onError } = useBlossomFallback(url);
const { config } = useAppContext();
const proxy = useImageProxy();
// In gated mode (low-bandwidth) the user must tap to load the image. We
// also fall back to the placeholder if the proxy errors and the user is
// low-bandwidth, so we don't silently fetch the original.
const lowBandwidth = config.lowBandwidthMode;
const shouldGate = lowBandwidth;
const [revealed, setRevealed] = useState(!shouldGate);
const proxied = proxy(src, 600);
const usingProxy = proxied !== src;
const finalSrc = proxyFailed || !usingProxy ? src : proxied;
// If the image is already cached by the browser, onLoad may have
// fired before the ref was attached. Check on mount.
@@ -227,8 +243,9 @@ function GridImage({
style={containerStyle}
onClick={onOpen}
>
{/* Placeholder shown while the image is loading */}
{!loaded && (
{/* Placeholder shown while the image is loading, or as a tap-to-load
gate when low-bandwidth mode is on. */}
{(!loaded || !revealed) && (
isValidBlurhash(blurhash) ? (
// Blurhash canvas fills the container via CSS — pass small integer decode
// resolution; the canvas is stretched to 100%×100% by the style prop.
@@ -250,29 +267,52 @@ function GridImage({
<Skeleton className="absolute inset-0 w-full h-full rounded-none" />
)
)}
<img
ref={imgRef}
src={src}
alt=""
width={dimensions?.width}
height={dimensions?.height}
className={cn(
'absolute inset-0 w-full h-full object-cover transition-all duration-300 hover:scale-[1.02]',
loaded ? 'opacity-100' : 'opacity-0',
)}
loading="lazy"
onLoad={(e) => {
setLoaded(true);
if (!dim) {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) {
setProbedAspectRatio(`${img.naturalWidth} / ${img.naturalHeight}`);
{!revealed && (
<div
role="presentation"
className="absolute inset-0 z-10 flex items-center justify-center"
onClick={(e) => { e.stopPropagation(); setRevealed(true); }}
>
<span className="flex items-center gap-2 rounded-full bg-background/80 px-3.5 py-1.5 text-xs font-medium text-foreground backdrop-blur-sm">
<ImageIcon className="size-3.5" aria-hidden />
Load image
</span>
</div>
)}
{revealed && (
<img
ref={imgRef}
src={finalSrc}
alt=""
width={dimensions?.width}
height={dimensions?.height}
className={cn(
'absolute inset-0 w-full h-full object-cover transition-all duration-300 hover:scale-[1.02]',
loaded ? 'opacity-100' : 'opacity-0',
)}
loading="lazy"
onLoad={(e) => {
setLoaded(true);
if (!dim) {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) {
setProbedAspectRatio(`${img.naturalWidth} / ${img.naturalHeight}`);
}
}
}
}}
onError={onError}
/>
{/* "+N" overlay on last visible image */}
}}
onError={() => {
// Proxy failed: in low-bandwidth mode, re-gate so we don't load the
// original until the user explicitly asks. Otherwise fall through
// to original via Blossom fallback chain.
if (usingProxy && !proxyFailed) {
if (lowBandwidth) setRevealed(false);
setProxyFailed(true);
return;
}
onError();
}}
/>
)} {/* "+N" overlay on last visible image */}
{overflow > 0 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-[2px]">
<span className="text-white text-2xl font-bold">+{overflow}</span>
@@ -283,9 +323,9 @@ function GridImage({
}
/** Sentinel URL — pass as an image entry to render a loading spinner slot in the lightbox. */
export const LOADING_SENTINEL = '__lightbox_loading__';
const LOADING_SENTINEL = '__lightbox_loading__';
export interface LightboxMediaMeta {
interface LightboxMediaMeta {
mime?: string;
dim?: string;
blurhash?: string;
@@ -295,7 +335,7 @@ export interface LightboxMediaMeta {
pubkey?: string;
}
export interface LightboxProps {
interface LightboxProps {
images: string[];
currentIndex: number;
onClose: () => void;
@@ -659,9 +699,17 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked, onZoomChange }:
onZoomChange?: (zoomed: boolean) => void;
}) {
const { src, onError } = useBlossomFallback(url);
const proxy = useImageProxy();
const imgRef = useRef<HTMLImageElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
// Proxy lightbox images at 1200 — large enough to look sharp, much
// smaller than original. On proxy failure, fall back to the original URL.
const [proxyFailed, setProxyFailed] = useState(false);
const proxied = proxy(src, 1200);
const usingProxy = proxied !== src;
const finalSrc = proxyFailed || !usingProxy ? src : proxied;
// Zoom/pan state — mutated directly on DOM for 60fps
const scale = useRef(1);
const panX = useRef(0);
@@ -872,14 +920,22 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked, onZoomChange }:
<div ref={wrapRef} style={{ transformOrigin: 'center center', willChange: 'transform', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img
ref={imgRef}
src={src}
src={finalSrc}
alt=""
className={cn(
'block max-w-full max-h-full object-contain select-none transition-opacity duration-300',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
onLoad={handleLoaded}
onError={onError}
onError={() => {
// First failure: proxy was wrong/down. Swap to original and try
// once more before invoking the Blossom-fallback chain.
if (usingProxy && !proxyFailed) {
setProxyFailed(true);
return;
}
onError();
}}
draggable={false}
/>
</div>
+10 -2
View File
@@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useAppContext } from '@/hooks/useAppContext';
import { cn } from '@/lib/utils';
interface LinkPreviewProps {
@@ -30,6 +32,12 @@ function displayDomain(url: string): string {
export function LinkPreview({ url, className, hideImage, navigateToComments, showActions = true }: LinkPreviewProps) {
const { data, isLoading } = useLinkPreview(url);
const navigate = useNavigate();
const proxy = useImageProxy();
const { config } = useAppContext();
// In low-bandwidth mode, suppress the preview thumbnail — the card still
// renders the title/description/domain, just no image.
const suppressImage = hideImage || config.lowBandwidthMode;
if (isLoading) {
return <LinkPreviewSkeleton className={className} />;
@@ -58,10 +66,10 @@ export function LinkPreview({ url, className, hideImage, navigateToComments, sho
onClick={handleClick}
>
{/* Thumbnail image */}
{image && !hideImage && (
{image && !suppressImage && (
<div className="w-full overflow-hidden">
<img
src={image}
src={proxy(image, 400)}
alt=""
className="w-full h-[180px] object-cover"
loading="lazy"
+7 -1
View File
@@ -2,6 +2,7 @@ import { useRef, useEffect, useState, useCallback } from 'react';
import type Hls from 'hls.js';
import { Play, Pause, Volume1, Volume2, VolumeX, Expand, Minimize } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppContext } from '@/hooks/useAppContext';
import { usePlayerControls } from '@/hooks/usePlayerControls';
interface LiveStreamPlayerProps {
@@ -18,6 +19,11 @@ export function LiveStreamPlayer({ src, poster, className, title, artist }: Live
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls | null>(null);
const { config } = useAppContext();
// Live streams normally autoplay (muted), but in low-bandwidth mode we
// start paused so the user has to tap to begin pulling HLS segments.
const shouldAutoPlay = !config.lowBandwidthMode;
const [isPlaying, setIsPlaying] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -213,7 +219,7 @@ export function LiveStreamPlayer({ src, poster, className, title, artist }: Live
poster={poster}
className="w-full h-full object-contain cursor-pointer"
playsInline
autoPlay
autoPlay={shouldAutoPlay}
muted
{...({ 'webkit-playsinline': 'true' } as React.HTMLAttributes<HTMLVideoElement>)}
onClick={handleVideoClick}
+86
View File
@@ -0,0 +1,86 @@
import { ImageIcon } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import { isValidBlurhash } from '@/lib/blurhash';
import { cn } from '@/lib/utils';
interface MediaPlaceholderProps {
/** Optional blurhash from the event's imeta tag. Rendered if valid. */
blurhash?: string;
/** Reveal handler. The whole placeholder is a button. */
onReveal: () => void;
/** Accessible label for the reveal button. Defaults to "Load image". */
label?: string;
/** Optional extra classes for the outer button — set the container size. */
className?: string;
/** Aspect ratio (`width / height`) when the container is unsized. */
aspectRatio?: number;
}
/**
* Tap-to-load placeholder used by low-bandwidth mode when the image proxy
* is disabled (or has failed). Shows a blurhash if one is available,
* otherwise a muted background, with a centered icon and "Load image" label.
*
* Fills its parent (`w-full h-full`) with a `min-h-[200px]` floor so it's
* always tappable. Rendered as a `<div role="button">` rather than a real
* `<button>` because consumers often mount it inside another button
* (e.g. NoteContent.InlineImage's lightbox trigger) — nested `<button>`
* elements are invalid HTML.
*/
export function MediaPlaceholder({
blurhash,
onReveal,
label = 'Load image',
className,
aspectRatio,
}: MediaPlaceholderProps) {
const hasBlurhash = blurhash && isValidBlurhash(blurhash);
const handleClick = (e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
onReveal();
};
return (
// Rendered as a div (not a button) because this component is often
// mounted inside another <button> — e.g. NoteContent.InlineImage wraps
// images in a click-to-open-lightbox button. Nested <button> elements
// are invalid HTML and render inconsistently across browsers.
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick(e);
}
}}
aria-label={label}
className={cn(
'relative flex w-full h-full min-h-[200px] items-center justify-center overflow-hidden bg-muted cursor-pointer',
'transition-colors hover:bg-muted/80',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
style={aspectRatio ? { aspectRatio: String(aspectRatio) } : undefined}
>
{hasBlurhash && (
<Blurhash
hash={blurhash}
width="100%"
height="100%"
resolutionX={32}
resolutionY={32}
punch={1}
style={{ position: 'absolute', inset: 0 }}
/>
)}
<div className="relative z-10 flex items-center gap-2 rounded-full bg-background/80 px-3.5 py-1.5 text-xs font-medium text-foreground backdrop-blur-sm">
<ImageIcon className="size-3.5" aria-hidden />
<span>{label}</span>
</div>
</div>
);
}
+7 -3
View File
@@ -10,6 +10,7 @@ import { RepostIcon } from '@/components/icons/RepostIcon';
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventStats } from '@/hooks/useTrending';
@@ -53,6 +54,7 @@ function TrackDetail({ event }: { event: NostrEvent }) {
const navigate = useNavigate();
const player = useAudioPlayer();
const parsed = useMemo(() => parseMusicTrack(event), [event]);
const proxy = useImageProxy();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
@@ -109,7 +111,7 @@ function TrackDetail({ event }: { event: NostrEvent }) {
{/* Artwork */}
<div className="shrink-0 w-32 sm:w-40 aspect-square rounded-2xl overflow-hidden bg-muted shadow-lg">
{parsed?.artwork && !imgError ? (
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" onError={() => setImgError(true)} />
<img src={proxy(parsed.artwork, 320)} alt={parsed.title} className="w-full h-full object-cover" onError={() => setImgError(true)} />
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10">
<Music className="size-12 text-primary/30" />
@@ -326,6 +328,7 @@ function PlaylistDetail({ event }: { event: NostrEvent }) {
const parsed = useMemo(() => parseMusicPlaylist(event), [event]);
const [imgError, setImgError] = useState(false);
const author = useAuthor(event.pubkey);
const proxy = useImageProxy();
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
@@ -393,7 +396,7 @@ function PlaylistDetail({ event }: { event: NostrEvent }) {
<div className="px-4 flex gap-5 items-start">
<div className="shrink-0 w-32 sm:w-40 aspect-square rounded-2xl overflow-hidden bg-muted shadow-lg">
{coverArt ? (
<img src={coverArt} alt={parsed?.title ?? ''} className="w-full h-full object-cover" onError={() => setImgError(true)} />
<img src={proxy(coverArt, 320)} alt={parsed?.title ?? ''} className="w-full h-full object-cover" onError={() => setImgError(true)} />
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10">
<FallbackIcon className="size-12 text-primary/30" />
@@ -515,6 +518,7 @@ function PlaylistTrackRow({
}) {
const player = useAudioPlayer();
const parsed = useMemo(() => parseMusicTrack(event), [event]);
const proxy = useImageProxy();
const [imgError, setImgError] = useState(false);
const naddrPath = useMemo(() => {
@@ -568,7 +572,7 @@ function PlaylistTrackRow({
{/* Artwork */}
<div className="size-12 rounded-lg overflow-hidden shrink-0 bg-muted">
{parsed.artwork && !imgError ? (
<img src={parsed.artwork} alt={parsed.title} className="size-full object-cover" loading="lazy" onError={() => setImgError(true)} />
<img src={proxy(parsed.artwork, 96)} alt={parsed.title} className="size-full object-cover" loading="lazy" onError={() => setImgError(true)} />
) : (
<div className="size-full flex items-center justify-center bg-primary/10">
<Music className="size-5 text-primary/30" />
-25
View File
@@ -217,10 +217,6 @@ export function NostrSync() {
updates.theme = "system";
changed = true;
}
if (current.customTheme !== undefined) {
updates.customTheme = undefined;
changed = true;
}
// Reset sidebar order and homepage to the app defaults so the previous
// user's layout doesn't bleed into the new account.
if ((current.sidebarOrder ?? []).length > 0) {
@@ -289,27 +285,6 @@ export function NostrSync() {
updates.theme = "system";
changed = true;
}
if (current.customTheme !== undefined) {
updates.customTheme = undefined;
changed = true;
}
}
if (
encryptedSettings.customTheme &&
JSON.stringify(encryptedSettings.customTheme) !==
JSON.stringify(current.customTheme)
) {
updates.customTheme = encryptedSettings.customTheme;
changed = true;
} else if (
isSwitch &&
!encryptedSettings.customTheme &&
current.customTheme !== undefined
) {
// Clear stale custom theme from the previous account.
updates.customTheme = undefined;
changed = true;
}
if (
+122 -99
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { type ReactNode, lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
/** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the main feed bundle. */
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
@@ -38,10 +39,7 @@ import { BadgeContent } from "@/components/BadgeContent";
import { CampaignNoteCardContent } from "@/components/CampaignNoteCardContent";
import { CommunityContent } from "@/components/CommunityContent";
import { CalendarEventContent } from "@/components/CalendarEventContent";
import {
ColorMomentContent,
ColorMomentEyeButton,
} from "@/components/ColorMomentContent";
import { ColorMomentContent } from "@/components/ColorMomentContent";
import { CommentContext, CountryCommentPill } from "@/components/CommentContext";
import { CommunityContentWarning } from "@/components/CommunityContentWarning";
import { ContentWarningGuard } from "@/components/ContentWarningGuard";
@@ -93,6 +91,7 @@ import { useProfileUrl } from "@/hooks/useProfileUrl";
import { useShareOrigin } from "@/hooks/useShareOrigin";
import { toast } from "@/hooks/useToast";
import { useEventStats } from "@/hooks/useTrending";
import { useEventTranslation } from "@/hooks/useEventTranslation";
import { canZap } from "@/lib/canZap";
import { extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions";
import { getZapAmountSats } from "@/lib/zapHelpers";
@@ -186,7 +185,7 @@ export function ActivityCard({
}
/** Reusable actor row: small avatar + display name + action label + timestamp. */
export interface ActorRowProps {
interface ActorRowProps {
pubkey: string;
profileUrl: string;
picture?: string;
@@ -200,7 +199,7 @@ export interface ActorRowProps {
timestampLabel: string;
}
export function ActorRow({ pubkey, profileUrl, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
function ActorRow({ pubkey, profileUrl, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
if (isLoading) {
return (
<div className="flex items-center gap-2">
@@ -380,6 +379,7 @@ export const NoteCard = memo(function NoteCard({
commentContextPrefix,
actionEvent,
}: NoteCardProps) {
const { t } = useTranslation();
const actionTarget = actionEvent ?? event;
const { config } = useAppContext();
const { user } = useCurrentUser();
@@ -540,6 +540,7 @@ export const NoteCard = memo(function NoteCard({
const isComment = event.kind === 1111;
const isReply = isTextNote && !isComment && isReplyEvent(event);
const { translatedEvent: contentEvent, translateAction } = useEventTranslation(event, { includePlainContent: isTextNote });
// Find all people being replied to (for "Replying to @user1 and @user2")
const replyToPubkeys = useMemo(() => {
@@ -687,7 +688,7 @@ export const NoteCard = memo(function NoteCard({
<ActionContent event={event} />
) : isCampaign ? (
<CampaignNoteCardContent event={event} />
<CampaignNoteCardContent event={contentEvent} />
) : isVoiceMessage ? (
<VoiceMessagePlayer event={event} />
@@ -743,7 +744,7 @@ export const NoteCard = memo(function NoteCard({
<ProfileCardContent event={event} />
) : (
<TruncatedNoteContent
event={event}
event={contentEvent}
/>
)}
</ContentWarningGuard>
@@ -776,7 +777,7 @@ export const NoteCard = memo(function NoteCard({
</Link>
</ProfileHoverCard>
{metadata?.bot && (
<span className="text-xs text-primary shrink-0" title="Bot account">
<span className="text-xs text-primary shrink-0" title={t('noteCard.botAccount')}>
🤖
</span>
)}
@@ -819,7 +820,7 @@ export const NoteCard = memo(function NoteCard({
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mt-3">
<button
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Reply"
title={t('feed.actions.reply')}
onClick={(e) => {
e.stopPropagation();
setReplyOpen(true);
@@ -840,7 +841,7 @@ export const NoteCard = memo(function NoteCard({
? "text-accent hover:text-accent/80 hover:bg-accent/10"
: "text-muted-foreground hover:text-accent hover:bg-accent/10",
)}
title={isReposted ? "Undo repost" : "Repost"}
title={isReposted ? t('feed.actions.undoRepost') : t('feed.actions.repost')}
>
<RepostIcon className="size-[18px]" />
{stats?.reposts || stats?.quotes ? (
@@ -864,7 +865,7 @@ export const NoteCard = memo(function NoteCard({
<ZapDialog target={actionTarget}>
<button
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Zap"
title={t('feed.actions.zap')}
>
<Zap className="size-[18px]" />
{stats?.zapAmount ? (
@@ -878,15 +879,17 @@ export const NoteCard = memo(function NoteCard({
<div className="flex-1" />
{translateAction}
<button
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
title="Share"
title={t('feed.actions.share')}
onClick={async (e) => {
e.stopPropagation();
impactLight();
const url = `${shareOrigin}/${encodedId}`;
const result = await shareOrCopy(url);
if (result === "copied") toast({ title: "Link copied to clipboard" });
if (result === "copied") toast({ title: t('feed.actions.linkCopied') });
}}
>
<Share2 className="size-[18px]" />
@@ -894,7 +897,7 @@ export const NoteCard = memo(function NoteCard({
<button
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
title={t('feed.actions.more')}
onClick={(e) => {
e.stopPropagation();
setMoreMenuOpen(true);
@@ -982,7 +985,7 @@ export const NoteCard = memo(function NoteCard({
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reacted" timestampLabel={timeAgo(event.created_at)} />
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label={t('noteCard.reacted')} timestampLabel={timeAgo(event.created_at)} />
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
@@ -1002,7 +1005,7 @@ export const NoteCard = memo(function NoteCard({
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reposted" timestampLabel={timeAgo(event.created_at)} />
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label={t('noteCard.reposted')} timestampLabel={timeAgo(event.created_at)} />
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
@@ -1017,9 +1020,9 @@ export const NoteCard = memo(function NoteCard({
const zapAmountSats = getZapAmountSats(event);
const zapMessage = (event.kind === 8333 ? event.content : extractZapMessage(event)).trim();
const usdLabel = btcPrice ? satsToUSD(zapAmountSats, btcPrice) : undefined;
const satsLabel = `${formatNumber(zapAmountSats)} ${zapAmountSats === 1 ? 'sat' : 'sats'}`;
const satsLabel = t('noteCard.zap.sat', { count: zapAmountSats, formattedCount: formatNumber(zapAmountSats) });
const amountText = usdLabel ?? satsLabel;
const donationPrefix = zapAmountSats > 0 ? `Donated ${amountText} to` : "Donated to";
const donationPrefix = zapAmountSats > 0 ? t('noteCard.zap.donatedAmountTo', { amount: amountText }) : t('noteCard.zap.donatedTo');
const zapCommentEvent = buildZapCommentEvent(
event,
zapRequestTags,
@@ -1065,7 +1068,7 @@ export const NoteCard = memo(function NoteCard({
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">voted</span>
<span className="text-sm text-muted-foreground shrink-0">{t('noteCard.voted')}</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
</div>
}
@@ -1169,7 +1172,7 @@ export const NoteCard = memo(function NoteCard({
pubkey={repostedBy}
icon={RepostIcon}
iconClassName="text-accent"
action="reposted"
action="noteCard.reposted"
/>
) : (
!hideKindHeader && KIND_HEADER_MAP[event.kind] &&
@@ -1206,7 +1209,6 @@ export const NoteCard = memo(function NoteCard({
<div className="flex items-center gap-3">
{avatarElement}
{authorInfo}
{isColor && <ColorMomentEyeButton event={event} />}
<CountryCommentPill
event={event}
className="shrink-0 [text-shadow:none]"
@@ -1246,6 +1248,7 @@ function TruncatedNoteContent({
}: {
event: NostrEvent;
}) {
const { t } = useTranslation();
const contentRef = useRef<HTMLDivElement>(null);
const [overflows, setOverflows] = useState(false);
const [expanded, setExpanded] = useState(false);
@@ -1293,15 +1296,17 @@ function TruncatedNoteContent({
)}
</div>
{overflows && (
<button
className="mt-1 text-sm text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
setExpanded((v) => !v);
}}
>
{expanded ? "Show less" : "Read more"}
</button>
<div className="mt-1 flex flex-wrap items-center gap-2">
<button
className="text-sm text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
setExpanded((v) => !v);
}}
>
{expanded ? t('noteCard.showLess') : t('noteCard.readMore')}
</button>
</div>
)}
</div>
);
@@ -1577,28 +1582,28 @@ function VineMedia({
);
}
/** Stream status badge config. */
/** Stream status badge config. The `label` is an i18n key under `noteCard.stream.*`. */
function getStreamStatusConfig(status: string | undefined) {
switch (status) {
case "live":
return {
label: "LIVE",
labelKey: "noteCard.stream.live",
className: "bg-red-600 hover:bg-red-600 text-white border-red-600",
};
case "ended":
return {
label: "ENDED",
labelKey: "noteCard.stream.ended",
className: "bg-muted text-muted-foreground border-border",
};
case "planned":
return {
label: "PLANNED",
labelKey: "noteCard.stream.planned",
className:
"bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600",
};
default:
return {
label: status?.toUpperCase() || "UNKNOWN",
labelKey: "noteCard.stream.unknown",
className: "bg-muted text-muted-foreground border-border",
};
}
@@ -1606,7 +1611,8 @@ function getStreamStatusConfig(status: string | undefined) {
/** Inline content for kind 30311 live stream events. */
function StreamContent({ event }: { event: NostrEvent }) {
const title = getTag(event.tags, "title") || "Untitled Stream";
const { t } = useTranslation();
const title = getTag(event.tags, "title") || t('noteCard.stream.untitled');
const summary = getTag(event.tags, "summary");
const imageUrl = getTag(event.tags, "image");
const streamingUrl = getTag(event.tags, "streaming");
@@ -1646,7 +1652,7 @@ function StreamContent({ event }: { event: NostrEvent }) {
className={cn("text-[10px]", statusConfig.className)}
>
<div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />
{statusConfig.label}
{t(statusConfig.labelKey)}
</Badge>
{currentParticipants && (
<span className="flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
@@ -1673,7 +1679,7 @@ function StreamContent({ event }: { event: NostrEvent }) {
variant="outline"
className={cn("text-[10px]", statusConfig.className)}
>
{statusConfig.label}
{t(statusConfig.labelKey)}
</Badge>
</div>
{currentParticipants && (
@@ -1694,7 +1700,7 @@ function StreamContent({ event }: { event: NostrEvent }) {
{status === "live" && (
<div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />
)}
{statusConfig.label}
{t(statusConfig.labelKey)}
</Badge>
{currentParticipants && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -1731,16 +1737,20 @@ function StreamContent({ event }: { event: NostrEvent }) {
);
}
export interface EventActionHeaderProps {
interface EventActionHeaderProps {
/** Pubkey of the person performing the action. */
pubkey: string;
/** Lucide icon component shown to the left of the author name. */
icon: React.ComponentType<{ className?: string }>;
/** Optional className for the icon (defaults to text-primary). */
iconClassName?: string;
/** Verb phrase shown after the author name, e.g. "hid a" or "is streaming". */
/**
* Translation key for the verb phrase shown after the author name,
* e.g. "noteCard.kindHeader.treasureHidCreated". Translated at render
* time via i18next.
*/
action: string;
/** Optional noun shown after the verb, linked to a page route, e.g. "treasure" → /treasures. */
/** Translation key for an optional noun shown after the verb. */
noun?: string;
/** Route to link the noun to, e.g. "/treasures". */
nounRoute?: string;
@@ -1750,88 +1760,98 @@ export interface EventActionHeaderProps {
interface KindHeaderConfig {
icon: React.ComponentType<{ className?: string }>;
iconClassName?: string;
/** Static action string, or a function that computes it from the event. */
/**
* Either a static i18n key under `noteCard.kindHeader`, or a function
* that computes the action key from the event. The materialized verb
* phrase is filled in by `useKindHeader()` at render time.
*/
action: string | ((event: NostrEvent) => string);
/** Optional i18n key for the linked noun under `noteCard.kindHeader`. */
noun?: string;
nounRoute?: string;
}
/** Resolves a `publishedAtAction` outcome to a noteCard.kindHeader.* key. */
function publishedAtKey(event: NostrEvent, keys: { created: string; updated: string; fallback: string }): string {
return publishedAtAction(event, keys);
}
const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
20: {
icon: Camera,
action: "shared a",
noun: "photo",
action: "noteCard.kindHeader.photo.action",
noun: "noteCard.kindHeader.photo.noun",
nounRoute: "/photos",
},
4: {
icon: Mail,
action: "sent an",
noun: "encrypted message",
action: "noteCard.kindHeader.encryptedMessage.action",
noun: "noteCard.kindHeader.encryptedMessage.noun",
},
8211: {
icon: Mail,
action: "sent a",
noun: "letter",
action: "noteCard.kindHeader.letter.action",
noun: "noteCard.kindHeader.letter.noun",
nounRoute: "/letters",
},
37516: {
icon: ChestIcon,
action: (event) => publishedAtAction(event, { created: "hid a", updated: "updated a", fallback: "hid a" }),
noun: "treasure",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.treasureHidCreated", updated: "noteCard.kindHeader.treasureHidUpdated", fallback: "noteCard.kindHeader.treasureHidCreated" }),
noun: "noteCard.kindHeader.treasureNoun",
nounRoute: "/treasures",
},
7516: {
icon: ChestIcon,
action: "found a",
noun: "treasure",
action: "noteCard.kindHeader.treasureFound",
noun: "noteCard.kindHeader.treasureNoun",
nounRoute: "/treasures",
},
37381: {
icon: CardsIcon,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "deck",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.deckCreated", updated: "noteCard.kindHeader.deckUpdated", fallback: "noteCard.kindHeader.deckFallback" }),
noun: "noteCard.kindHeader.deckNoun",
nounRoute: "/decks",
},
30030: {
icon: SmilePlus,
action: (event) => publishedAtAction(event, { created: "created an", updated: "updated an", fallback: "shared an" }),
noun: "emoji pack",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.emojiPackCreated", updated: "noteCard.kindHeader.emojiPackUpdated", fallback: "noteCard.kindHeader.emojiPackFallback" }),
noun: "noteCard.kindHeader.emojiPackNoun",
nounRoute: "/emojis",
},
34550: {
icon: Users,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "group",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.groupCreated", updated: "noteCard.kindHeader.groupUpdated", fallback: "noteCard.kindHeader.groupFallback" }),
noun: "noteCard.kindHeader.groupNoun",
nounRoute: "/groups",
},
33863: {
icon: HandHeart,
action: (event) => publishedAtAction(event, { created: "launched a", updated: "updated a", fallback: "shared a" }),
noun: "campaign",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.campaignLaunched", updated: "noteCard.kindHeader.campaignUpdated", fallback: "noteCard.kindHeader.campaignFallback" }),
noun: "noteCard.kindHeader.campaignNoun",
nounRoute: "/campaigns/all",
},
8: {
icon: Award,
action: "awarded a",
noun: "badge",
action: "noteCard.kindHeader.badgeAwarded",
noun: "noteCard.kindHeader.badgeNoun",
nounRoute: "/badges",
},
30009: {
icon: Award,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
noun: "badge",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.badgeCreated", updated: "noteCard.kindHeader.badgeDefUpdated", fallback: "noteCard.kindHeader.badgeCreated" }),
noun: "noteCard.kindHeader.badgeNoun",
nounRoute: "/badges",
},
10008: {
icon: Award,
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
noun: "badges",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.badgesCreatedTheir", updated: "noteCard.kindHeader.badgesUpdatedTheir", fallback: "noteCard.kindHeader.badgesUpdatedTheir" }),
noun: "noteCard.kindHeader.badgesNoun",
nounRoute: "/badges",
},
30008: {
icon: Award,
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
noun: "badges",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.badgesCreatedTheir", updated: "noteCard.kindHeader.badgesUpdatedTheir", fallback: "noteCard.kindHeader.badgesUpdatedTheir" }),
noun: "noteCard.kindHeader.badgesNoun",
nounRoute: "/badges",
},
30311: {
@@ -1839,81 +1859,81 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
iconClassName: undefined, // computed dynamically below
action: (event) =>
getEffectiveStreamStatus(event) === "live"
? "is streaming"
: "streamed",
? "noteCard.kindHeader.streamingLive"
: "noteCard.kindHeader.streamed",
},
32267: {
icon: Package,
action: (event) => publishedAtAction(event, { created: "published a Zapstore app", updated: "updated a Zapstore app", fallback: "published a Zapstore app" }),
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.zapstoreAppPublished", updated: "noteCard.kindHeader.zapstoreAppUpdated", fallback: "noteCard.kindHeader.zapstoreAppPublished" }),
},
30063: {
icon: Package,
action: (event) => publishedAtAction(event, { created: "published a Zapstore release", updated: "updated a Zapstore release", fallback: "published a Zapstore release" }),
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.zapstoreReleasePublished", updated: "noteCard.kindHeader.zapstoreReleaseUpdated", fallback: "noteCard.kindHeader.zapstoreReleasePublished" }),
},
3063: {
icon: Package,
action: "published a Zapstore asset",
action: "noteCard.kindHeader.zapstoreAssetPublished",
},
31990: {
icon: Package,
action: (event) => publishedAtAction(event, { created: "published an app", updated: "updated an app", fallback: "published an app" }),
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.appPublished", updated: "noteCard.kindHeader.appUpdated", fallback: "noteCard.kindHeader.appPublished" }),
},
30617: {
icon: GitBranch,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "repository",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.repoCreated", updated: "noteCard.kindHeader.repoUpdated", fallback: "noteCard.kindHeader.repoFallback" }),
noun: "noteCard.kindHeader.repoNoun",
nounRoute: "/development",
},
1617: {
icon: FileText,
action: "submitted a",
noun: "patch",
action: "noteCard.kindHeader.patchSubmitted",
noun: "noteCard.kindHeader.patchNoun",
nounRoute: "/development",
},
1618: {
icon: GitPullRequest,
action: "opened a",
noun: "pull request",
action: "noteCard.kindHeader.prOpened",
noun: "noteCard.kindHeader.prNoun",
nounRoute: "/development",
},
30817: {
icon: FileCode,
action: (event) => publishedAtAction(event, { created: "proposed a", updated: "updated a", fallback: "proposed a" }),
noun: "NIP",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.nipProposed", updated: "noteCard.kindHeader.nipUpdated", fallback: "noteCard.kindHeader.nipProposed" }),
noun: "noteCard.kindHeader.nipNoun",
nounRoute: "/development",
},
15128: {
icon: Rocket,
action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }),
noun: "nsite",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.nsiteDeployed", updated: "noteCard.kindHeader.nsiteRedeployed", fallback: "noteCard.kindHeader.nsiteDeployed" }),
noun: "noteCard.kindHeader.nsiteNoun",
nounRoute: "/development",
},
35128: {
icon: Rocket,
action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }),
noun: "nsite",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.nsiteDeployed", updated: "noteCard.kindHeader.nsiteRedeployed", fallback: "noteCard.kindHeader.nsiteDeployed" }),
noun: "noteCard.kindHeader.nsiteNoun",
nounRoute: "/development",
},
9735: {
icon: Zap,
action: "zapped",
action: "noteCard.kindHeader.zapped",
},
36639: {
icon: Megaphone,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
noun: "pledge",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.pledgeCreated", updated: "noteCard.kindHeader.pledgeUpdated", fallback: "noteCard.kindHeader.pledgeCreated" }),
noun: "noteCard.kindHeader.pledgeNoun",
nounRoute: "/pledges",
},
39089: {
icon: PartyPopper,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "follow pack",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.followPackCreated", updated: "noteCard.kindHeader.followPackUpdated", fallback: "noteCard.kindHeader.followPackFallback" }),
noun: "noteCard.kindHeader.followPackNoun",
nounRoute: "/packs",
},
30000: {
icon: PartyPopper,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "follow set",
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.followSetCreated", updated: "noteCard.kindHeader.followSetUpdated", fallback: "noteCard.kindHeader.followSetFallback" }),
noun: "noteCard.kindHeader.followSetNoun",
nounRoute: "/packs",
},
};
@@ -1927,9 +1947,12 @@ export function EventActionHeader({
noun,
nounRoute,
}: EventActionHeaderProps) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name || genUserName(pubkey);
const url = useProfileUrl(pubkey, author.data?.metadata);
const actionText = t(action);
const nounText = noun ? t(noun) : undefined;
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3 min-w-0">
@@ -1962,8 +1985,8 @@ export function EventActionHeader({
</ProfileHoverCard>
)}
<span className={cn("shrink-0", author.isLoading && "ml-1")}>
{action}
{noun && nounRoute && (
{actionText}
{nounText && nounRoute && (
<>
{" "}
<Link
@@ -1971,11 +1994,11 @@ export function EventActionHeader({
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{noun}
{nounText}
</Link>
</>
)}
{noun && !nounRoute && <> {noun}</>}
{nounText && !nounRoute && <> {nounText}</>}
</span>
</div>
</div>
+5 -2
View File
@@ -10,6 +10,7 @@ import { LinkEmbed } from '@/components/LinkEmbed';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
import { ProxiedImage } from '@/components/ProxiedImage';
import { VideoPlayer } from '@/components/VideoPlayer';
import { AudioVisualizer } from '@/components/AudioVisualizer';
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
@@ -846,13 +847,15 @@ function InlineImage({ url, onClick }: { url: string; onClick: (e: React.MouseEv
onClick={onClick}
>
<div className={cn('relative w-full rounded-lg overflow-hidden', !loaded && 'bg-muted')} style={!loaded ? { minHeight: 200 } : undefined}>
<img
<ProxiedImage
src={src}
width={600}
gated
alt=""
className="block w-full h-auto rounded-lg hover:opacity-90 transition-opacity"
loading="lazy"
onLoad={() => setLoaded(true)}
onError={() => { setLoaded(true); onError(); }}
onLoadError={() => { setLoaded(true); onError(); }}
/>
</div>
</button>
+47 -42
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { nip19 } from 'nostr-tools';
import { useNavigate } from 'react-router-dom';
@@ -117,12 +118,13 @@ interface EventJsonDialogProps {
}
function CopyButton({ text, label }: { text: string; label: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
toast({ title: `${label} copied to clipboard` });
toast({ title: t('noteMoreMenu.toast.copied', { label }) });
setTimeout(() => setCopied(false), 2000);
};
@@ -139,6 +141,7 @@ function CopyButton({ text, label }: { text: string; label: string }) {
}
function EventJsonDialog({ event, nip19Id, open, onOpenChange }: EventJsonDialogProps) {
const { t } = useTranslation();
const { nostr } = useNostr();
const [broadcasting, setBroadcasting] = useState(false);
@@ -148,9 +151,9 @@ function EventJsonDialog({ event, nip19Id, open, onOpenChange }: EventJsonDialog
setBroadcasting(true);
try {
await nostr.event(event, { signal: AbortSignal.timeout(5000) });
toast({ title: 'Event broadcast to relays' });
toast({ title: t('noteMoreMenu.toast.eventBroadcast') });
} catch {
toast({ title: 'Failed to broadcast event', variant: 'destructive' });
toast({ title: t('noteMoreMenu.toast.broadcastFailed'), variant: 'destructive' });
} finally {
setBroadcasting(false);
}
@@ -160,24 +163,24 @@ function EventJsonDialog({ event, nip19Id, open, onOpenChange }: EventJsonDialog
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85dvh] flex flex-col gap-0 p-0 rounded-2xl overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3 shrink-0">
<DialogTitle className="text-base font-semibold">Event Details</DialogTitle>
<DialogTitle className="text-base font-semibold">{t('noteMoreMenu.jsonDialog.title')}</DialogTitle>
</DialogHeader>
<div className="px-5 pb-3 shrink-0">
<p className="text-xs font-medium text-muted-foreground mb-1">Event ID</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('noteMoreMenu.jsonDialog.eventId')}</p>
<div className="relative flex items-center bg-muted rounded-lg px-3 py-2">
<p className="font-mono text-xs break-all text-foreground/80 flex-1 pr-2 select-all">
{nip19Id}
</p>
<CopyButton text={nip19Id} label="Event ID" />
<CopyButton text={nip19Id} label={t('noteMoreMenu.jsonDialog.eventIdLabel')} />
</div>
</div>
<div className="px-5 pb-5 flex flex-col flex-1 min-h-0">
<p className="text-xs font-medium text-muted-foreground mb-1">Raw JSON</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('noteMoreMenu.jsonDialog.rawJson')}</p>
<div className="relative flex-1 min-h-0 overflow-auto rounded-lg bg-muted border border-border">
<div className="sticky top-2 right-2 float-right mr-2">
<CopyButton text={jsonText} label="Event JSON" />
<CopyButton text={jsonText} label={t('noteMoreMenu.jsonDialog.eventJsonLabel')} />
</div>
<pre className="p-4 text-xs font-mono text-foreground/80 whitespace-pre leading-relaxed">
{jsonText}
@@ -193,7 +196,7 @@ function EventJsonDialog({ event, nip19Id, open, onOpenChange }: EventJsonDialog
disabled={broadcasting}
>
<Radio className="size-4" />
{broadcasting ? 'Broadcasting...' : 'Broadcast Event'}
{broadcasting ? t('noteMoreMenu.jsonDialog.broadcasting') : t('noteMoreMenu.jsonDialog.broadcast')}
</Button>
</div>
</DialogContent>
@@ -202,6 +205,7 @@ function EventJsonDialog({ event, nip19Id, open, onOpenChange }: EventJsonDialog
}
export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
const { t } = useTranslation();
// These states live here (not in NoteMoreMenuContent) so they persist after the menu closes
const [reportOpen, setReportOpen] = useState(false);
const [banContentOpen, setBanContentOpen] = useState(false);
@@ -235,10 +239,10 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
{
onSuccess: () => {
setDeleteConfirmOpen(false);
toast({ title: 'Post deleted' });
toast({ title: t('noteMoreMenu.toast.postDeleted') });
},
onError: () => {
toast({ title: 'Failed to delete post', variant: 'destructive' });
toast({ title: t('noteMoreMenu.toast.deleteFailed'), variant: 'destructive' });
},
},
);
@@ -312,13 +316,13 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete post?</AlertDialogTitle>
<AlertDialogTitle>{t('noteMoreMenu.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>
This will request deletion from relays. Some relays may still keep a copy of the original event. This action cannot be undone.
{t('noteMoreMenu.deleteDialog.description')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isDeleting}>{t('noteMoreMenu.deleteDialog.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
@@ -327,7 +331,7 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete'}
{isDeleting ? t('noteMoreMenu.deleteDialog.deleting') : t('noteMoreMenu.deleteDialog.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -347,6 +351,7 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
}
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useCurrentUser();
const { isBookmarked, toggleBookmark } = useBookmarks();
@@ -399,10 +404,10 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
const handleToggleSidebar = () => {
if (isInSidebar) {
removeFromSidebar(nostrUri);
toast({ title: 'Removed from sidebar' });
toast({ title: t('noteMoreMenu.toast.removedFromSidebar') });
} else {
addToSidebar(nostrUri);
toast({ title: 'Added to sidebar' });
toast({ title: t('noteMoreMenu.toast.addedToSidebar') });
}
close();
};
@@ -411,10 +416,10 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
impactLight();
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: pinned ? 'Unpinned from profile' : 'Pinned to profile' });
toast({ title: pinned ? t('noteMoreMenu.toast.unpinned') : t('noteMoreMenu.toast.pinned') });
},
onError: () => {
toast({ title: 'Failed to update pinned posts', variant: 'destructive' });
toast({ title: t('noteMoreMenu.toast.pinFailed'), variant: 'destructive' });
},
});
close();
@@ -430,15 +435,15 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
onSuccess: () => {
toast({
title: postIsPinnedInCountry
? 'Unpinned from country feed'
: 'Pinned to country feed',
? t('noteMoreMenu.toast.countryUnpinned')
: t('noteMoreMenu.toast.countryPinned'),
});
},
onError: () => {
toast({
title: postIsPinnedInCountry
? 'Failed to unpin from country feed'
: 'Failed to pin to country feed',
? t('noteMoreMenu.toast.countryUnpinFailed')
: t('noteMoreMenu.toast.countryPinFailed'),
variant: 'destructive',
});
},
@@ -455,10 +460,10 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
{ type: 'thread', value: threadId },
{
onSuccess: () => {
toast({ title: 'Conversation muted' });
toast({ title: t('noteMoreMenu.toast.convoMuted') });
},
onError: () => {
toast({ title: 'Failed to mute conversation', variant: 'destructive' });
toast({ title: t('noteMoreMenu.toast.convoMuteFailed'), variant: 'destructive' });
},
},
);
@@ -470,10 +475,10 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
const mutation = userMuted ? removeMute : addMute;
mutation.mutate(muteItem, {
onSuccess: () => {
toast({ title: userMuted ? `Unmuted @${displayName}` : `Muted @${displayName}` });
toast({ title: userMuted ? t('noteMoreMenu.toast.unmuted', { name: displayName }) : t('noteMoreMenu.toast.muted', { name: displayName }) });
},
onError: () => {
toast({ title: userMuted ? 'Failed to unmute user' : 'Failed to mute user', variant: 'destructive' });
toast({ title: userMuted ? t('noteMoreMenu.toast.unmuteFailed') : t('noteMoreMenu.toast.muteFailed'), variant: 'destructive' });
},
});
close();
@@ -482,7 +487,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[85dvh] p-0 gap-0 rounded-2xl overflow-y-auto [&>button]:hidden">
<DialogTitle className="sr-only">Post options</DialogTitle>
<DialogTitle className="sr-only">{t('noteMoreMenu.title')}</DialogTitle>
{/* Post preview */}
<div className="px-4 pt-4 pb-3">
@@ -505,7 +510,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
</div>
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3 overflow-wrap-anywhere">
{/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? (
<span className="italic">Encrypted content</span>
<span className="italic">{t('noteMoreMenu.encryptedContent')}</span>
) : (
<NoteContent event={event} className="text-sm leading-snug whitespace-normal" disableEmbeds disableNoteEmbeds as="span" />
)}
@@ -519,63 +524,63 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
<div className="py-1">
<MenuItem
icon={<StickyNote className="size-5" />}
label="View post details"
label={t('noteMoreMenu.viewPost')}
onClick={handleViewPostDetails}
/>
<MenuItem
icon={<FileJson className="size-5" />}
label="View Event JSON"
label={t('noteMoreMenu.viewEventJson')}
onClick={onViewEventJson}
/>
<MenuItem
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
label={bookmarked ? 'Remove Bookmark' : 'Bookmark'}
label={bookmarked ? t('noteMoreMenu.removeBookmark') : t('noteMoreMenu.bookmark')}
onClick={handleBookmark}
/>
{user && (
<MenuItem
icon={<ListPlus className="size-5" />}
label="Add to list"
label={t('noteMoreMenu.addToList')}
onClick={() => { onAddToList(); }}
/>
)}
<MenuItem
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
label={isInSidebar ? 'Remove from sidebar' : 'Add to sidebar'}
label={isInSidebar ? t('noteMoreMenu.removeFromSidebar') : t('noteMoreMenu.addToSidebar')}
onClick={handleToggleSidebar}
/>
{isOwnPost && (
<MenuItem
icon={<Pin className={cn("size-5", pinned && "fill-current")} />}
label={pinned ? 'Unpin from profile' : 'Pin on profile'}
label={pinned ? t('noteMoreMenu.unpinProfile') : t('noteMoreMenu.pinProfile')}
onClick={handleTogglePin}
/>
)}
{canPinHere && (
<MenuItem
icon={<Pin className={cn('size-5', postIsPinnedInCountry && 'fill-current')} />}
label={postIsPinnedInCountry ? 'Unpin from country feed' : 'Pin to country feed'}
label={postIsPinnedInCountry ? t('noteMoreMenu.unpinCountry') : t('noteMoreMenu.pinCountry')}
onClick={handleToggleCountryPin}
/>
)}
{!isOwnPost && (
<MenuItem
icon={<BellOff className="size-5" />}
label="Mute Conversation"
label={t('noteMoreMenu.muteConversation')}
onClick={handleMuteConversation}
/>
)}
{!isOwnPost && (
<MenuItem
icon={<VolumeX className="size-5" />}
label={userMuted ? `Unmute @${displayName}` : `Mute @${displayName}`}
label={userMuted ? t('noteMoreMenu.unmute', { name: displayName }) : t('noteMoreMenu.mute', { name: displayName })}
onClick={handleMuteUser}
/>
)}
{!isOwnPost && (
<MenuItem
icon={<Flag className="size-5" />}
label={communityContext ? 'Report post to group' : `Report @${displayName}`}
label={communityContext ? t('noteMoreMenu.reportToGroup') : t('noteMoreMenu.report', { name: displayName })}
onClick={onReport}
destructive
/>
@@ -583,7 +588,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
{!isOwnPost && communityContext?.canBan && (
<MenuItem
icon={<ShieldBan className="size-5" />}
label="Remove from group"
label={t('noteMoreMenu.removeFromGroup')}
onClick={onBanContent}
destructive
/>
@@ -591,7 +596,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
{isOwnPost && (
<MenuItem
icon={<Trash2 className="size-5" />}
label="Delete post"
label={t('noteMoreMenu.delete')}
onClick={onDelete}
destructive
/>
@@ -606,7 +611,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
className="w-full h-auto py-3 text-[15px] font-medium text-muted-foreground hover:bg-secondary/60 rounded-none"
onClick={close}
>
Close
{t('noteMoreMenu.close')}
</Button>
</div>
</DialogContent>
+10 -10
View File
@@ -1,4 +1,5 @@
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface OrganizationContextChipProps {
/** The org `A` tag coordinate currently attached to this draft (or empty). */
@@ -42,6 +43,8 @@ export function OrganizationContextChip({
manageableLoading,
isEditMode = false,
}: OrganizationContextChipProps) {
const { t } = useTranslation();
// Edit mode: surface the org the event is already attached to. No
// permission check here — the underlying publish flow re-resolves the
// user's authority before emitting the tags.
@@ -53,12 +56,12 @@ export function OrganizationContextChip({
<Users className="size-4" />
</div>
<div className="min-w-0 space-y-0.5">
<p className="text-xs font-semibold uppercase tracking-wide">Attached to group</p>
<p className="text-xs font-semibold uppercase tracking-wide">{t('organizationContext.attachedToGroup')}</p>
<p className="truncate text-sm font-semibold text-foreground">
{authorizedOrg?.community.name ?? 'Group'}
{authorizedOrg?.community.name ?? t('organizationContext.groupFallback')}
</p>
<p className="text-xs text-muted-foreground">
Updates will stay connected to this group's official activity.
{t('organizationContext.editSubtext')}
</p>
</div>
</div>
@@ -73,7 +76,7 @@ export function OrganizationContextChip({
if (!paramDecoded) {
return (
<p className="mt-2 w-full text-xs text-muted-foreground">
Couldn't read the group in the link. Publishing under your account.
{t('organizationContext.malformedParam')}
</p>
);
}
@@ -83,7 +86,7 @@ export function OrganizationContextChip({
if (manageableLoading) {
return (
<div className="mt-3 w-full rounded-xl border border-border bg-card px-4 py-3 text-sm text-muted-foreground">
Checking group permissions
{t('organizationContext.checkingPermissions')}
</div>
);
}
@@ -94,7 +97,7 @@ export function OrganizationContextChip({
if (!authorizedOrg) {
return (
<p className="mt-2 w-full text-xs text-muted-foreground">
You aren't a founder or moderator of that group. Publishing under your account.
{t('organizationContext.unauthorized')}
</p>
);
}
@@ -105,11 +108,8 @@ export function OrganizationContextChip({
<Users className="size-4" />
</div>
<div className="min-w-0 space-y-0.5">
<p className="text-xs font-semibold uppercase tracking-wide">Publishing as group</p>
<p className="text-xs font-semibold uppercase tracking-wide">{t('organizationContext.publishingAsGroup')}</p>
<p className="truncate text-sm font-semibold text-foreground">{authorizedOrg.community.name}</p>
<p className="text-xs text-muted-foreground">
This will appear as official group activity instead of only under your profile.
</p>
</div>
</div>
);
+3 -1
View File
@@ -9,6 +9,7 @@ import { ArrowLeft, Play, Pause, Podcast, Zap, Clock } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAuthor } from '@/hooks/useAuthor';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventStats } from '@/hooks/useTrending';
@@ -49,6 +50,7 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
const navigate = useNavigate();
const player = useAudioPlayer();
const parsed = useMemo(() => parsePodcastEpisode(event), [event]);
const proxy = useImageProxy();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
@@ -107,7 +109,7 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
{/* Artwork */}
<div className="shrink-0 w-32 sm:w-40 aspect-square rounded-2xl overflow-hidden bg-muted shadow-lg">
{parsed?.artwork ? (
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" />
<img src={proxy(parsed.artwork, 320)} alt={parsed.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10">
<Podcast className="size-12 text-primary/30" />
+111
View File
@@ -0,0 +1,111 @@
import { useMemo } from 'react';
import Markdown, { type Components } from 'react-markdown';
import rehypeSanitize from 'rehype-sanitize';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface PolicyMarkdownProps {
/** Raw markdown source string, e.g. from `usePolicyMarkdown(slug)`. */
source: string;
/**
* Map of `{{name}}` placeholders to interpolate before render. Values are
* escaped against the markdown literally — they're treated as plain text,
* not as markdown — so user-supplied values can't smuggle in syntax.
*
* The escape strategy is conservative: backslash-escape markdown's special
* characters. Combined with rehype-sanitize this keeps the rendered output
* safe even if `values` ever holds untrusted content (today it's only
* `appName` from `AppConfig`, which is operator-controlled).
*/
values?: Record<string, string>;
className?: string;
}
/** Escape markdown special characters in an interpolated value. */
function escapeMarkdown(value: string): string {
return value.replace(/([\\`*_{}[\]()#+\-.!|<>])/g, '\\$1');
}
function interpolate(source: string, values: Record<string, string>): string {
return source.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
const v = values[key];
return v === undefined ? match : escapeMarkdown(v);
});
}
/**
* Component overrides that match the typography of the legacy hand-rolled
* `PrivacyPolicyPage` JSX (text-sm body, base-bold h2, inline-disc lists,
* primary-colored external links opening in a new tab).
*/
const components: Components = {
h1: ({ children, node: _n, ...rest }) => (
<h1 {...rest} className="text-lg font-bold text-foreground">{children}</h1>
),
h2: ({ children, node: _n, ...rest }) => (
<h2 {...rest} className="text-base font-bold text-foreground mt-6">{children}</h2>
),
h3: ({ children, node: _n, ...rest }) => (
<h3 {...rest} className="text-sm font-bold text-foreground mt-4">{children}</h3>
),
p: ({ children, node: _n, ...rest }) => (
<p {...rest} className="mt-2">{children}</p>
),
ul: ({ children, node: _n, ...rest }) => (
<ul {...rest} className="list-disc list-inside space-y-1 ml-2 mt-2">{children}</ul>
),
ol: ({ children, node: _n, ...rest }) => (
<ol {...rest} className="list-decimal list-inside space-y-1 ml-2 mt-2">{children}</ol>
),
li: ({ children, node: _n, ...rest }) => (
<li {...rest}>{children}</li>
),
strong: ({ children, node: _n, ...rest }) => (
<strong {...rest} className="font-semibold text-foreground">{children}</strong>
),
em: ({ children, node: _n, ...rest }) => (
<em {...rest}>{children}</em>
),
a: ({ href, children, node: _n, ...rest }) => {
const safe = sanitizeUrl(href);
if (!safe) {
return <span>{children}</span>;
}
return (
<a
{...rest}
href={safe}
target="_blank"
rel="noopener noreferrer"
className={cn('text-primary hover:underline', rest.className as string | undefined)}
>
{children}
</a>
);
},
hr: ({ node: _n, ...rest }) => (
<hr {...rest} className="my-6 border-border" />
),
};
/**
* Renders a markdown source as a Privacy / CSAE / long-form policy article
* with consistent typography, sanitized HTML, and `{{placeholder}}`
* interpolation. Wrap in the existing `<article>` container or pass a
* `className` override.
*/
export function PolicyMarkdown({ source, values, className }: PolicyMarkdownProps) {
const interpolated = useMemo(
() => (values ? interpolate(source, values) : source),
[source, values],
);
return (
<div className={cn('space-y-2 text-sm text-foreground/90 leading-relaxed', className)}>
<Markdown components={components} rehypePlugins={[rehypeSanitize]}>
{interpolated}
</Markdown>
</div>
);
}
+20 -11
View File
@@ -1,7 +1,9 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { ReactNode } from 'react';
import { MessageCircle, MoreHorizontal, Share2, Zap } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { ReactionButton } from '@/components/ReactionButton';
@@ -30,24 +32,29 @@ interface PostActionBarProps {
hideZap?: boolean;
/** Keep the share button visible at sidebar widths. Defaults to false. */
showShareInSidebar?: boolean;
/** Optional action rendered next to Share, e.g. Translate. */
translateAction?: ReactNode;
/** Extra classes on the outer wrapper div. */
className?: string;
}
export function PostActionBar({
event,
replyLabel = 'Reply',
replyLabel,
onReply,
onMore,
hideZap = false,
showShareInSidebar = false,
translateAction,
className,
}: PostActionBarProps) {
const { t } = useTranslation();
const { toast } = useToast();
const { user } = useCurrentUser();
const shareOrigin = useShareOrigin();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const effectiveReplyLabel = replyLabel ?? t('feed.actions.reply');
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
const canZapAuthor = !hideZap && user && canZap(metadata) && !hasGoalZapSplits(event);
@@ -66,8 +73,8 @@ export function PostActionBar({
}
const url = `${shareOrigin}/${encoded}`;
const result = await shareOrCopy(url);
if (result === 'copied') toast({ title: 'Link copied to clipboard' });
}, [event, shareOrigin, toast]);
if (result === 'copied') toast({ title: t('feed.actions.linkCopied') });
}, [event, shareOrigin, toast, t]);
return (
<div
@@ -83,14 +90,14 @@ export function PostActionBar({
{/* Reply / Comments */}
<button
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title={replyLabel}
title={effectiveReplyLabel}
onClick={onReply}
>
<MessageCircle className="size-[18px]" />
{stats?.replies ? (
<span className="tabular-nums">{formatNumber(stats.replies)}</span>
) : (
<span className="hidden sm:inline">{replyLabel}</span>
<span className="hidden sm:inline">{effectiveReplyLabel}</span>
)}
</button>
@@ -104,13 +111,13 @@ export function PostActionBar({
? 'text-accent hover:text-accent/80 hover:bg-accent/10'
: 'text-muted-foreground hover:text-accent hover:bg-accent/10',
)}
title={isReposted ? 'Undo repost' : 'Repost'}
title={isReposted ? t('feed.actions.undoRepost') : t('feed.actions.repost')}
>
<RepostIcon className="size-[18px]" />
{repostTotal > 0 ? (
<span className="tabular-nums">{formatNumber(repostTotal)}</span>
) : (
<span className="hidden sm:inline">Repost</span>
<span className="hidden sm:inline">{t('feed.actions.repost')}</span>
)}
</button>
)}
@@ -130,13 +137,13 @@ export function PostActionBar({
<ZapDialog target={event}>
<button
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Zap"
title={t('feed.actions.zap')}
>
<Zap className="size-[18px]" />
{stats?.zapAmount ? (
<span className="tabular-nums">{formatNumber(stats.zapAmount)}</span>
) : (
<span className="hidden sm:inline">Zap</span>
<span className="hidden sm:inline">{t('feed.actions.zap')}</span>
)}
</button>
</ZapDialog>
@@ -145,13 +152,15 @@ export function PostActionBar({
{/* Spacer pushes share/more to the right */}
<div className="flex-1" />
{translateAction}
{/* Share */}
<button
className={cn(
'inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors',
!showShareInSidebar && 'sidebar:hidden',
)}
title="Share"
title={t('feed.actions.share')}
onClick={handleShare}
>
<Share2 className="size-[18px]" />
@@ -160,7 +169,7 @@ export function PostActionBar({
{/* More */}
<button
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
title={t('feed.actions.more')}
onClick={onMore}
>
<MoreHorizontal className="size-[18px]" />
+2 -2
View File
@@ -81,12 +81,12 @@ function EditableTextarea({
);
}
export interface ProfileField {
interface ProfileField {
label: string;
value: string;
}
export interface ProfileCardProps {
interface ProfileCardProps {
pubkey?: string;
metadata: Partial<NostrMetadata>;
onChange?: (patch: Partial<NostrMetadata>) => void;
+4 -2
View File
@@ -8,6 +8,7 @@ import { ExternalFavicon } from '@/components/ExternalFavicon';
import { EmojifiedText } from '@/components/CustomEmoji';
import { BioContent } from '@/components/BioContent';
import { useAuthor } from '@/hooks/useAuthor';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useUserStatus } from '@/hooks/useUserStatus';
import { genUserName } from '@/lib/genUserName';
import { formatNip05Display, getNip05Domain } from '@/lib/nip05';
@@ -42,6 +43,7 @@ function ProfileHoverCardBody({ pubkey }: { pubkey: string }) {
const nip05Display = nip05Verified && nip05 ? formatNip05Display(nip05) : undefined;
const { status: userStatus, url: statusUrl } = useUserStatus(pubkey);
const { refs: badgeRefs } = useProfileBadges(pubkey);
const proxy = useImageProxy();
const firstFive = badgeRefs.slice(0, 5);
const { badgeMap } = useBadgeDefinitions(firstFive);
@@ -55,7 +57,7 @@ function ProfileHoverCardBody({ pubkey }: { pubkey: string }) {
<div className="h-16 bg-secondary relative">
{metadata?.banner && (
<img
src={metadata.banner}
src={proxy(metadata.banner, 400)}
alt=""
className="w-full h-full object-cover"
loading="lazy"
@@ -73,7 +75,7 @@ function ProfileHoverCardBody({ pubkey }: { pubkey: string }) {
<div className="-mt-8 mb-2">
<Link to={profileUrl} onClick={(e) => e.stopPropagation()}>
<Avatar className="size-16 border-3 border-background">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarImage src={metadata?.picture} alt={displayName} proxyWidth={128} />
<AvatarFallback className="bg-primary/20 text-primary text-lg">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
+3 -1
View File
@@ -19,6 +19,7 @@ import QRCode from 'qrcode';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import { useImageProxy } from '@/hooks/useImageProxy';
import { getContentWarning } from '@/lib/contentWarning';
import { MiniAudioPlayer } from '@/components/MiniAudioPlayer';
import { isAudioUrl, isImageUrl, isVideoUrl } from '@/lib/mediaTypeDetection';
@@ -193,6 +194,7 @@ function MediaTile({ item }: { item: MediaItem }) {
const [loaded, setLoaded] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const { config } = useAppContext();
const proxy = useImageProxy();
const isVideo = isVideoItem(item);
useEffect(() => {
@@ -233,7 +235,7 @@ function MediaTile({ item }: { item: MediaItem }) {
) : (
<img
ref={imgRef}
src={item.url}
src={proxy(item.url, 300)}
alt=""
className="absolute inset-0 w-full h-full object-cover"
loading="lazy"
+117
View File
@@ -0,0 +1,117 @@
import { type ImgHTMLAttributes, useState } from 'react';
import { MediaPlaceholder } from '@/components/MediaPlaceholder';
import { useAppContext } from '@/hooks/useAppContext';
import { useImageProxy } from '@/hooks/useImageProxy';
import { cn } from '@/lib/utils';
interface ProxiedImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'onError'> {
/** Original image URL. Required. */
src: string;
/** Target width passed to the image proxy. */
width: number;
/** Blurhash from the event's imeta tag, surfaced when a placeholder is shown. */
blurhash?: string;
/**
* When `true` and `lowBandwidthMode` is enabled, render a tap-to-load
* placeholder instead of loading the image immediately. Set this on
* feed/gallery surfaces; leave it off for contexts where the user has
* already opted in to viewing the image (lightbox, avatars, hover cards).
*
* Defaults to `false`.
*/
gated?: boolean;
/** Label rendered on the tap-to-load placeholder. */
placeholderLabel?: string;
/** Optional callback fired after a load error has been fully resolved. */
onLoadError?: () => void;
}
/**
* Image element that routes its `src` through the configured image proxy
* (`config.imageProxy`) at a per-context `width`. Handles the two failure
* modes that matter for low-bandwidth users:
*
* 1. **Proxy fails (5xx, timeout, unreachable)** — falls back via
* `onError`. If `lowBandwidthMode` is on, falls back to the
* tap-to-load placeholder so we don't silently load the original
* megabytes. Otherwise falls back to the original URL.
*
* 2. **Low-bandwidth + `gated`** — renders the placeholder up front.
* The user taps to load the image (proxied if available, original
* otherwise).
*
* Avatars, lightbox images, and other "already-consented" surfaces should
* pass `gated={false}` (the default) so they always load.
*/
export function ProxiedImage({
src,
width,
blurhash,
gated = false,
placeholderLabel,
onLoadError,
className,
...rest
}: ProxiedImageProps) {
const { config } = useAppContext();
const proxy = useImageProxy();
const lowBandwidth = config.lowBandwidthMode;
// Whether to gate behind a tap-to-load placeholder. Applies whenever the
// user is in low-bandwidth mode — the proxy setting is independent.
const shouldGate = gated && lowBandwidth;
const [revealed, setRevealed] = useState(!shouldGate);
const [proxyFailed, setProxyFailed] = useState(false);
// Resolve the final src:
// - proxy succeeded: proxied URL
// - proxy failed, low-bandwidth: bail to placeholder (handled below)
// - proxy failed, normal mode: original URL
const proxiedSrc = proxy(src, width);
const usingProxy = proxiedSrc !== src;
const finalSrc = !usingProxy || proxyFailed ? src : proxiedSrc;
// Show placeholder when:
// - user hasn't revealed (gated + low-bandwidth)
// - proxy failed in a gated context while user is low-bandwidth
// (don't silently load original bytes the user didn't ask for)
//
// The placeholder fills its parent and does NOT receive the <img>'s
// className — img classes like `h-auto block` would collapse the
// placeholder's flex centering. Callers should size the surrounding
// container, not the placeholder.
const fellBackToPlaceholder = gated && proxyFailed && lowBandwidth && usingProxy && !revealed;
if (!revealed || fellBackToPlaceholder) {
return (
<MediaPlaceholder
blurhash={blurhash}
onReveal={() => setRevealed(true)}
label={placeholderLabel}
/>
);
}
return (
<img
{...rest}
src={finalSrc}
className={cn(className)}
onError={() => {
// First failure: swap proxied → original. If we're in a gated +
// low-bandwidth context, fall to placeholder instead so we don't
// silently download the (potentially huge) original.
if (usingProxy && !proxyFailed) {
if (gated && lowBandwidth) {
setRevealed(false);
}
setProxyFailed(true);
return;
}
onLoadError?.();
}}
/>
);
}
+3 -1
View File
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useMemo } from 'react';
import { Heart } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useTranslation } from 'react-i18next';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { QuickReactMenu } from '@/components/QuickReactMenu';
@@ -48,6 +49,7 @@ export function ReactionButton({
filledHeart = false,
variant = 'pill',
}: ReactionButtonProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutate: publishEvent } = useNostrPublish();
@@ -153,7 +155,7 @@ export function ReactionButton({
className,
hasReacted && 'text-pink-500',
)}
title="React"
title={t('feed.actions.react')}
onClick={(e) => {
e.stopPropagation();
if (!user) return;
+19 -5
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -33,6 +34,7 @@ interface ReplyComposeModalProps {
}
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSuccess, initialContent, initialMode, title: titleOverride, placeholder: placeholderOverride }: ReplyComposeModalProps) {
const { t } = useTranslation();
const isUrl = event instanceof URL;
const isExternalId = typeof event === 'string';
const isExternal = isUrl || isExternalId;
@@ -43,8 +45,20 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const isProfileRoot = !isExternal && event instanceof Object && 'kind' in event && event.kind === 0;
const title = titleOverride ?? (initialMode === 'poll' ? 'New poll' : isExternal ? 'New comment' : isProfileRoot ? 'Comment on profile' : isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post');
const placeholder = placeholderOverride ?? (isExternal ? 'Write a comment...' : isReply ? "What's on your mind?" : isQuote ? 'Add a comment...' : "What's happening?");
const title = titleOverride ?? (
initialMode === 'poll' ? t('replyModal.title.newPoll')
: isExternal ? t('replyModal.title.newComment')
: isProfileRoot ? t('replyModal.title.commentOnProfile')
: isReply ? t('replyModal.title.replyToPost')
: isQuote ? t('replyModal.title.quotePost')
: t('replyModal.title.newPost')
);
const placeholder = placeholderOverride ?? (
isExternal ? t('replyModal.placeholder.writeComment')
: isReply ? t('compose.placeholderDefault')
: isQuote ? t('replyModal.placeholder.addComment')
: t('replyModal.placeholder.whatsHappening')
);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
@@ -95,7 +109,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
: "text-muted-foreground hover:text-foreground"
)}
>
Edit
{t('compose.preview.edit')}
</button>
<button
onClick={() => setPreviewMode(true)}
@@ -106,7 +120,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
: "text-muted-foreground hover:text-foreground"
)}
>
Preview
{t('compose.preview.previewMode')}
</button>
</div>
)}
@@ -142,7 +156,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
<div className="mx-4 mb-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 flex items-start gap-2 shrink-0">
<span className="text-sm leading-relaxed shrink-0" aria-hidden>&#x26A0;&#xFE0F;</span>
<p className="text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
People on Bluesky can&apos;t see you because they&apos;re not actually decentralized.
{t('replyModal.blueskyDisclaimer')}
</p>
</div>
)}
+6 -4
View File
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
@@ -25,6 +26,7 @@ interface ReplyContextProps {
* Used consistently across NoteCard and notification views.
*/
export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAuthorHint, className }: ReplyContextProps) {
const { t } = useTranslation();
// Filter out any undefined/empty pubkeys defensively
const validPubkeys = pubkeys.filter(Boolean);
// Show max 2 authors for cleaner UI
@@ -33,7 +35,7 @@ export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAu
const replyingToLabel = parentEventId ? (
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<span className="shrink-0 cursor-pointer hover:underline">Replying to</span>
<span className="shrink-0 cursor-pointer hover:underline">{t('feed.replyContext.replyingTo')}</span>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
@@ -52,7 +54,7 @@ export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAu
</HoverCardContent>
</HoverCard>
) : (
<span className="shrink-0">Replying to</span>
<span className="shrink-0">{t('feed.replyContext.replyingTo')}</span>
);
return (
@@ -61,12 +63,12 @@ export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAu
{displayPubkeys.map((pubkey, index) => (
<span key={pubkey} className="inline-flex items-center gap-1 min-w-0">
<ReplyAuthor pubkey={pubkey} />
{index < displayPubkeys.length - 1 && <span className="shrink-0">and</span>}
{index < displayPubkeys.length - 1 && <span className="shrink-0">{t('feed.replyContext.and')}</span>}
</span>
))}
{validPubkeys.length > 2 && (
<span className="text-muted-foreground shrink-0">
and {validPubkeys.length - 2} other{validPubkeys.length - 2 !== 1 ? 's' : ''}
{t('feed.replyContext.andOthers', { count: validPubkeys.length - 2 })}
</span>
)}
</div>
+3 -434
View File
@@ -12,22 +12,16 @@
*/
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import {
Globe, UserSearch,
ChevronDown, ChevronUp,
Hash, Search as SearchIcon,
X, Check, User,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { useAuthor } from '@/hooks/useAuthor';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
import { cn } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import type { SearchProfile } from '@/hooks/useSearchProfiles';
import type { UserList } from '@/hooks/useUserLists';
import type { FollowPack } from '@/hooks/useFollowPacks';
@@ -44,7 +38,7 @@ type KindOption = {
// ─── Kind options (built once) ───────────────────────────────────────────────
import { buildKindOptions, AGORA_PRESET_KIND_VALUES } from '@/lib/feedFilterUtils';
import { AGORA_PRESET_KIND_VALUES } from '@/lib/feedFilterUtils';
// ─── useScrollCarets ─────────────────────────────────────────────────────────
@@ -96,7 +90,7 @@ function useScrollCarets() {
// ─── KindPicker ──────────────────────────────────────────────────────────────
export function KindScrollCaret({ direction, onMouseEnter, onMouseLeave }: {
function KindScrollCaret({ direction, onMouseEnter, onMouseLeave }: {
direction: 'up' | 'down';
onMouseEnter: () => void;
onMouseLeave: () => void;
@@ -112,7 +106,7 @@ export function KindScrollCaret({ direction, onMouseEnter, onMouseLeave }: {
);
}
export function KindPickerItem({ icon: Icon, label, active, onClick }: {
function KindPickerItem({ icon: Icon, label, active, onClick }: {
icon: React.ComponentType<{ className?: string }> | null;
label: string;
active: boolean;
@@ -262,230 +256,8 @@ export function KindPicker({ value, options, onChange }: {
// ─── MultiKindPicker ─────────────────────────────────────────────────────────
export function MultiKindPicker({ selectedKinds, options, onChange }: {
selectedKinds: string[];
options: KindOption[];
onChange: (kinds: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const { refCallback, canScrollUp, canScrollDown, onScroll, startScroll, stopScroll } = useScrollCarets();
const filtered = useMemo(() => {
const q = search.toLowerCase().trim();
if (!q) return options;
return options.filter(
(o) => o.label.toLowerCase().includes(q) || o.description.toLowerCase().includes(q) || o.value.includes(q),
);
}, [options, search]);
const isAllKinds = selectedKinds.length === 0;
const toggleKind = (value: string) => {
if (selectedKinds.includes(value)) {
onChange(selectedKinds.filter((k) => k !== value));
} else {
onChange([...selectedKinds, value]);
}
};
const selectAll = () => {
onChange([]);
setOpen(false);
setSearch('');
};
const triggerLabel = useMemo(() => {
if (isAllKinds) return 'All kinds';
if (selectedKinds.length === 1) {
const opt = options.find((o) => o.value === selectedKinds[0]);
return opt?.label ?? `Kind ${selectedKinds[0]}`;
}
return `${selectedKinds.length} kinds selected`;
}, [isAllKinds, selectedKinds, options]);
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setSearch(''); }}>
<PopoverTrigger asChild>
<button
className={cn(
'w-full h-9 px-3 rounded-lg border bg-secondary/50 text-sm flex items-center gap-2 text-left transition-colors hover:bg-secondary border-border',
open && 'border-ring ring-1 ring-ring',
)}
>
<Hash className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{triggerLabel}</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className="w-64 p-0 flex flex-col overflow-hidden"
style={{ maxHeight: 'min(320px, var(--radix-popover-content-available-height, 320px))' }}
>
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border shrink-0">
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
<input
className="flex-1 text-base md:text-sm bg-transparent outline-none placeholder:text-muted-foreground"
placeholder="Search kinds..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
{search && (
<button onClick={() => setSearch('')} className="text-muted-foreground hover:text-foreground">
<X className="size-3.5" />
</button>
)}
</div>
{canScrollUp && (
<KindScrollCaret direction="up" onMouseEnter={() => startScroll('up')} onMouseLeave={stopScroll} />
)}
<div ref={refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={onScroll}>
{!search && (
<button
onClick={selectAll}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors text-left',
isAllKinds ? 'bg-primary/10 text-primary' : 'hover:bg-secondary/60 text-foreground',
)}
>
<span className="size-4 shrink-0" />
<span className="flex-1 truncate">All kinds</span>
{isAllKinds && <Check className="size-3.5 shrink-0 ml-auto text-primary" />}
</button>
)}
{filtered.map((opt) => {
const isSelected = selectedKinds.includes(opt.value);
const Icon = opt.icon;
return (
<button
key={opt.value}
onClick={() => toggleKind(opt.value)}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors text-left',
isSelected ? 'bg-primary/10 text-primary' : 'hover:bg-secondary/60 text-foreground',
)}
>
<div className={cn(
'size-4 shrink-0 rounded border flex items-center justify-center transition-colors',
isSelected ? 'bg-primary border-primary' : 'border-border bg-background',
)}>
{isSelected && <Check className="size-3 text-primary-foreground" />}
</div>
{Icon
? <Icon className="size-4 shrink-0 text-muted-foreground" />
: <span className="size-4 shrink-0" />}
<span className="flex-1 truncate">{opt.label}</span>
</button>
);
})}
{filtered.length === 0 && search && (
<p className="text-sm text-muted-foreground text-center py-6">No kinds match</p>
)}
</div>
{canScrollDown && (
<KindScrollCaret direction="down" onMouseEnter={() => startScroll('down')} onMouseLeave={stopScroll} />
)}
{selectedKinds.length > 0 && (
<div className="flex items-center justify-between px-3 py-2 border-t border-border shrink-0">
<span className="text-xs text-muted-foreground">{selectedKinds.length} selected</span>
<button
onClick={() => { setOpen(false); setSearch(''); }}
className="text-xs font-medium text-primary hover:text-primary/80 transition-colors"
>
Done
</button>
</div>
)}
</PopoverContent>
</Popover>
{selectedKinds.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedKinds.map((kindValue) => {
const opt = options.find((o) => o.value === kindValue);
const Icon = opt?.icon;
const label = opt ? opt.label : `Kind ${kindValue}`;
return (
<span
key={kindValue}
className="inline-flex items-center gap-1.5 pl-2 pr-1 py-0.5 rounded-full bg-secondary border border-border text-xs max-w-[180px]"
>
{Icon && <Icon className="size-3 shrink-0 text-muted-foreground" />}
<span className="truncate">{label}</span>
<button
onClick={() => toggleKind(kindValue)}
className="shrink-0 size-4 flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary-foreground/10 transition-colors"
aria-label={`Remove ${label}`}
>
<X className="size-3" />
</button>
</span>
);
})}
</div>
)}
</div>
);
}
// ─── ScopeToggle ─────────────────────────────────────────────────────────────
export interface ScopeOption<T extends string> {
value: T;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
/** Generic segmented toggle for choosing an author scope. */
export function ScopeToggle<T extends string>({
value,
options,
onChange,
size = 'sm',
}: {
value: T;
options: ScopeOption<T>[];
onChange: (scope: T) => void;
/** 'sm' = xs text + py-1.5 (feed modal), 'md' = sm text + py-2 (profile modal) */
size?: 'sm' | 'md';
}) {
const useGrid = options.length > 3;
return (
<div className={cn(
'rounded-lg border border-border overflow-hidden',
useGrid ? 'grid grid-cols-2' : 'flex',
)}>
{options.map(({ value: scope, label, icon: Icon }) => (
<button
key={scope}
onClick={() => onChange(scope)}
className={cn(
'flex items-center justify-center font-medium transition-colors',
size === 'sm' ? 'py-1.5 gap-1 text-xs' : 'py-2 gap-1.5 text-sm',
!useGrid && 'flex-1',
value === scope
? 'bg-primary text-primary-foreground'
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
)}
>
<Icon className="size-3.5 shrink-0" />
{label}
</button>
))}
</div>
);
}
// ─── ListPackPicker ───────────────────────────────────────────────────────────
interface ListPackPickerProps {
@@ -594,208 +366,5 @@ export function AuthorFilterDropdown({ onCommit }: { onCommit: (pubkey: string,
// ─── Helper: parse kinds from filter ──────────────────────────────────────────
/** Get the kindFilter string representation from a TabFilter's kinds array. */
function kindsToKindFilter(filter: TabFilter): string {
const kinds = filter.kinds;
if (!Array.isArray(kinds) || kinds.length === 0) return 'all';
return kinds.map(String).join(',');
}
/** Get the author scope from a TabFilter. */
function getAuthorScope(filter: TabFilter): 'anyone' | 'people' {
const authors = filter.authors;
if (Array.isArray(authors) && authors.length > 0) return 'people';
return 'anyone';
}
// ─── SavedFeedFiltersEditor ───────────────────────────────────────────────────
interface SavedFeedFiltersEditorProps {
/** Current filter values */
value: TabFilter;
/** Called on every field change with the updated filter */
onChange: (filter: TabFilter) => void;
/** When true, the query input is shown at the top (default: true) */
showQuery?: boolean;
/** Hide the From / author scope section (e.g. profile tabs where author is implicit) */
hideFrom?: boolean;
/** Optional: pre-built kind options (pass to avoid rebuilding) */
kindOptions?: KindOption[];
}
export function SavedFeedFiltersEditor({
value,
onChange,
showQuery = true,
hideFrom = false,
kindOptions: kindOptionsProp,
}: SavedFeedFiltersEditorProps) {
const kindOptions = useMemo(() => kindOptionsProp ?? buildKindOptions(), [kindOptionsProp]);
const { lists } = useUserLists();
const { data: followPacks = [] } = useFollowPacks();
const listPickerValue = useMatchedListId(
Array.isArray(value.authors) ? (value.authors as string[]) : [],
);
const search = typeof value.search === 'string' ? value.search : '';
const authorPubkeys = useMemo(() => Array.isArray(value.authors) ? (value.authors as string[]) : [], [value.authors]);
// Local scope state so clicking "People" immediately shows the panel,
// even before any authors have been added. Initialised from the filter value.
const [authorScope, setAuthorScopeState] = useState<'anyone' | 'people'>(
() => getAuthorScope(value),
);
const kindFilter = kindsToKindFilter(value);
const [customKindText, setCustomKindText] = useState('');
const addAuthor = useCallback((pubkey: string, _label: string) => {
const next = authorPubkeys.includes(pubkey) ? authorPubkeys : [...authorPubkeys, pubkey];
setAuthorScopeState('people');
onChange({ ...value, authors: next });
}, [authorPubkeys, onChange, value]);
const removeAuthor = useCallback((pubkey: string) => {
const next = authorPubkeys.filter((p) => p !== pubkey);
const updated = { ...value };
if (next.length > 0) {
updated.authors = next;
} else {
delete updated.authors;
}
onChange(updated);
}, [authorPubkeys, onChange, value]);
const setAuthorScope = useCallback((scope: 'anyone' | 'people') => {
setAuthorScopeState(scope);
if (scope === 'anyone') {
const updated = { ...value };
delete updated.authors;
onChange(updated);
}
}, [onChange, value]);
const handleKindChange = useCallback((v: string) => {
const updated = { ...value };
if (v === 'all') {
delete updated.kinds;
setCustomKindText('');
} else if (v === 'custom') {
setCustomKindText(Array.isArray(value.kinds) ? (value.kinds as number[]).join(', ') : '');
} else {
updated.kinds = [parseInt(v, 10)];
setCustomKindText('');
}
onChange(updated);
}, [onChange, value]);
const handleCustomKindChange = useCallback((text: string) => {
setCustomKindText(text);
const kinds = text.split(/[\s,]+/).map(Number).filter((n) => !isNaN(n) && n > 0);
if (kinds.length > 0) {
onChange({ ...value, kinds });
}
}, [onChange, value]);
const handleSearchChange = useCallback((newSearch: string) => {
const updated = { ...value };
if (newSearch.trim()) {
updated.search = newSearch;
} else {
delete updated.search;
}
onChange(updated);
}, [onChange, value]);
return (
<div className="space-y-3">
{/* Query */}
{showQuery && (
<>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Search query</span>
<Input
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="e.g. bitcoin"
className="bg-secondary/50 border-border focus-visible:ring-1 h-8 text-base md:text-sm"
/>
</div>
<Separator />
</>
)}
{/* Author scope */}
{!hideFrom && (
<>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">From</span>
<div className="flex rounded-lg border border-border overflow-hidden">
{([
['anyone', 'Anyone', Globe],
['people', 'People', UserSearch],
] as const).map(([scope, label, Icon]) => (
<button
key={scope}
onClick={() => setAuthorScope(scope)}
className={cn(
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
authorScope === scope
? 'bg-primary text-primary-foreground'
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
)}
>
<Icon className="size-3.5 shrink-0" />
{label}
</button>
))}
</div>
{authorScope === 'people' && (
<div className="space-y-1.5">
{authorPubkeys.length > 0 && (
<div className="flex flex-wrap gap-1">
{authorPubkeys.map((pk) => (
<AuthorChip key={pk} pubkey={pk} onRemove={() => removeAuthor(pk)} />
))}
</div>
)}
<AuthorFilterDropdown onCommit={addAuthor} />
<ListPackPicker
lists={lists}
followPacks={followPacks}
value={listPickerValue}
onSelectPubkeys={(pubkeys) => {
const updated = { ...value };
if (pubkeys.length > 0) {
updated.authors = pubkeys;
} else {
delete updated.authors;
}
onChange(updated);
}}
/>
</div>
)}
</div>
<Separator />
</>
)}
{/* Kind */}
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Kind</span>
<KindPicker value={kindFilter} options={kindOptions} onChange={handleKindChange} />
</div>
{kindFilter === 'custom' && (
<Input
type="text"
inputMode="numeric"
placeholder="e.g. 1, 30023"
value={customKindText}
onChange={(e) => handleCustomKindChange(e.target.value)}
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
/>
)}
</div>
);
}
-21
View File
@@ -33,27 +33,6 @@ export function NudgeToastContent({
);
}
/**
* Toast description for the phase-transition toast (encrypt done, now sign).
* On Android includes the "Approve in signer" link.
*/
export function PhaseToastContent({
message,
android,
}: {
message: string;
android: boolean;
}): ReactNode {
return (
<span>
{message}
{android && (
<AndroidApproveRow onCancel={() => { /* phase toast auto-expires */ }} />
)}
</span>
);
}
// ---------------------------------------------------------------------------
// Internal components
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils';
// ── Generic sortable list container ──────────────────────────────────────────
export interface SortableListProps<T> {
interface SortableListProps<T> {
/** Items in current order. */
items: T[];
/** Extract a unique stable string id from each item. */
@@ -62,7 +62,7 @@ export function SortableList<T>({ items, getItemId, onReorder, renderItem, class
// ── Generic sortable item wrapper ────────────────────────────────────────────
export interface SortableItemProps {
interface SortableItemProps {
/** Must match the id returned by `getItemId` for this item. */
id: string;
/** When false the grip handle is hidden and dragging is disabled. */
-221
View File
@@ -1,221 +0,0 @@
import { useMemo, useState, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Users, UserPlus, Check, Loader2, Heart } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useNostr } from '@nostrify/react';
import { useAuthors } from '@/hooks/useAuthors';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowActions, useFollowList } from '@/hooks/useFollowActions';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { TEAM_SOAPBOX_PACK } from '@/lib/helpContent';
/**
* A card that displays the "Team Soapbox" follow pack with a help-oriented CTA.
* Fetches the pack event from relays, shows member avatars, and provides
* a "Follow All" action. Designed for the Help page but reusable anywhere.
*/
export function TeamSoapboxCard({ className }: { className?: string }) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { data: followList } = useFollowList();
const { followMany } = useFollowActions();
const { toast } = useToast();
const [event, setEvent] = useState<NostrEvent | null>(null);
const [loading, setLoading] = useState(true);
const [isFollowingAll, setIsFollowingAll] = useState(false);
// Fetch the pack event
useEffect(() => {
let cancelled = false;
const fetchPack = async () => {
try {
const events = await nostr.query(
[{
kinds: [TEAM_SOAPBOX_PACK.kind],
authors: [TEAM_SOAPBOX_PACK.pubkey],
'#d': [TEAM_SOAPBOX_PACK.identifier],
limit: 1,
}],
{ signal: AbortSignal.timeout(8000) },
);
if (!cancelled && events.length > 0) {
setEvent(events[0]);
}
} catch (error) {
console.warn('Failed to fetch Team Soapbox pack:', error);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchPack();
return () => { cancelled = true; };
}, [nostr]);
const pubkeys = useMemo(
() => event?.tags.filter(([n]) => n === 'p').map(([, pk]) => pk) ?? [],
[event],
);
const previewPubkeys = useMemo(() => pubkeys.slice(0, 8), [pubkeys]);
const { data: membersMap } = useAuthors(previewPubkeys);
const followedPubkeys = useMemo(() => new Set(followList?.pubkeys ?? []), [followList]);
const newPubkeys = useMemo(
() => pubkeys.filter((pk) => !followedPubkeys.has(pk)),
[pubkeys, followedPubkeys],
);
const naddrLink = useMemo(() => {
if (!event) return undefined;
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
return `/${nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag })}`;
}, [event]);
const handleFollowAll = useCallback(async () => {
if (!user || !event) return;
setIsFollowingAll(true);
try {
const added = await followMany(pubkeys);
toast({
title: 'Following Team Soapbox!',
description: added > 0
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
: 'You were already following everyone on the team.',
});
} catch (error) {
console.error('Failed to follow all:', error);
toast({
title: 'Failed to follow',
description: 'There was an error updating your follow list.',
variant: 'destructive',
});
} finally {
setIsFollowingAll(false);
}
}, [user, event, pubkeys, followMany, toast]);
if (loading) {
return <TeamSoapboxCardSkeleton className={className} />;
}
if (!event) return null;
return (
<div className={className}>
<div className="rounded-2xl border border-primary/20 bg-primary/5 overflow-hidden">
<div className="px-5 pt-5 pb-4 space-y-4">
{/* Header */}
<div className="flex items-start gap-3">
<div className="flex items-center justify-center size-10 shrink-0 rounded-full bg-primary/15">
<Heart className="size-5 text-primary" />
</div>
<div className="min-w-0">
<h3 className="text-base font-bold leading-snug">Need help? Meet Team Soapbox</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
These are real people who built this platform and are happy to help. Follow them and don't be shy ask anything!
</p>
</div>
</div>
{/* Avatar stack + member count */}
{pubkeys.length > 0 && (
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{previewPubkeys.map((pk) => {
const member = membersMap?.get(pk);
const name = member?.metadata?.name || genUserName(pk);
return (
<Avatar key={pk} className="size-8 ring-2 ring-background">
<AvatarImage src={member?.metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
</div>
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
<Users className="size-3.5" />
{pubkeys.length} member{pubkeys.length !== 1 ? 's' : ''}
</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
className="gap-2 flex-1"
onClick={handleFollowAll}
disabled={isFollowingAll || !user}
>
{isFollowingAll ? (
<>
<Loader2 className="size-4 animate-spin" />
Following...
</>
) : newPubkeys.length === 0 && user ? (
<>
<Check className="size-4" />
Already following all
</>
) : (
<>
<UserPlus className="size-4" />
Follow All ({pubkeys.length})
</>
)}
</Button>
{naddrLink && (
<Button variant="outline" asChild>
<Link to={naddrLink}>View Pack</Link>
</Button>
)}
</div>
</div>
</div>
</div>
);
}
function TeamSoapboxCardSkeleton({ className }: { className?: string }) {
return (
<div className={className}>
<div className="rounded-2xl border border-primary/20 bg-primary/5 overflow-hidden">
<div className="px-5 pt-5 pb-4 space-y-4">
<div className="flex items-start gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="size-8 rounded-full ring-2 ring-background" />
))}
</div>
<Skeleton className="h-4 w-20" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-md" />
<Skeleton className="h-10 w-24 rounded-md" />
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -115,7 +115,7 @@ function ExpandThreadButton({ count, onClick, isLast }: { count: number; onClick
// ── Flat interface (for pages that don't need full threading) ──
export interface ThreadedReply {
interface ThreadedReply {
reply: NostrEvent;
firstSubReply?: NostrEvent;
}
+30 -24
View File
@@ -1,5 +1,6 @@
import { useState, type ComponentType } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Activity,
Bell,
@@ -26,7 +27,8 @@ import { useFeedSettings } from '@/hooks/useFeedSettings';
import { cn } from '@/lib/utils';
interface NavItem {
label: string;
/** i18n key under `nav.*` for the label. */
labelKey: string;
to: string;
icon: ComponentType<{ className?: string }>;
/** If true, this link is treated as active only on an exact match. */
@@ -34,10 +36,10 @@ interface NavItem {
}
const NAV_ITEMS: NavItem[] = [
{ label: 'Activity', to: '/feed', icon: Activity },
{ label: 'Campaigns', to: '/campaigns/all', icon: HandHeart },
{ label: 'Groups', to: '/groups', icon: Users },
{ label: 'Pledge', to: '/pledges', icon: Megaphone },
{ labelKey: 'nav.activity', to: '/feed', icon: Activity },
{ labelKey: 'nav.campaigns', to: '/campaigns/all', icon: HandHeart },
{ labelKey: 'nav.groups', to: '/groups', icon: Users },
{ labelKey: 'nav.pledge', to: '/pledges', icon: Megaphone },
];
interface MobileLinkItem extends NavItem {
@@ -51,6 +53,7 @@ interface MobileLinkItem extends NavItem {
* menu below the `md` breakpoint.
*/
export function TopNav() {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
const { orderedItems } = useFeedSettings();
@@ -67,7 +70,7 @@ export function TopNav() {
type="button"
onClick={() => setMobileOpen(true)}
className="md:hidden -ml-2 p-2 rounded-full hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Open menu"
aria-label={t('nav.openMenu')}
>
<Menu className="size-5" />
</button>
@@ -76,7 +79,7 @@ export function TopNav() {
<Link
to="/"
className="flex items-center gap-2 font-bold text-lg tracking-tight text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md px-1"
aria-label={`${config.appName} home`}
aria-label={t('nav.brandHome', { appName: config.appName })}
>
<LogoIcon className="size-6" />
<span>{config.appName}</span>
@@ -99,8 +102,8 @@ export function TopNav() {
type="button"
onClick={goToSearch}
className="shrink-0 size-9 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Search"
title="Search"
aria-label={t('nav.search')}
title={t('nav.search')}
>
<Search className="size-5" />
</button>
@@ -127,7 +130,7 @@ export function TopNav() {
<button
onClick={() => setMobileOpen(false)}
className="p-1.5 -mr-1.5 rounded-full hover:bg-secondary"
aria-label="Close menu"
aria-label={t('nav.closeMenu')}
>
<X className="size-5" />
</button>
@@ -152,6 +155,7 @@ export function TopNav() {
}
function NavLinkButton({ item }: { item: NavItem }) {
const { t } = useTranslation();
return (
<NavLink
to={item.to}
@@ -165,16 +169,17 @@ function NavLinkButton({ item }: { item: NavItem }) {
)
}
>
{item.label}
{t(item.labelKey)}
</NavLink>
);
}
function MobileFooterLinks({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const items = [
{ label: 'Privacy', to: '/privacy' },
{ label: 'Safety', to: '/safety' },
{ label: 'Changelog', to: '/changelog' },
{ labelKey: 'nav.privacy', to: '/privacy' },
{ labelKey: 'nav.safety', to: '/safety' },
{ labelKey: 'nav.changelog', to: '/changelog' },
];
return (
@@ -186,7 +191,7 @@ function MobileFooterLinks({ onClose }: { onClose: () => void }) {
onClick={onClose}
className="hover:text-foreground motion-safe:transition-colors"
>
{item.label}
{t(item.labelKey)}
</Link>
))}
</nav>
@@ -203,18 +208,19 @@ function getProfileMenuItems({
if (!userPubkey) return [];
return [
...(showDashboard ? [{ label: 'Dashboard', to: '/dashboard', icon: Activity }] : []),
{ label: 'My Dashboard', to: '/my-dashboard', icon: LayoutDashboard },
{ label: 'Wallet', to: '/wallet', icon: Wallet },
{ label: 'Notifications', to: '/notifications', icon: Bell },
{ label: 'Profile', to: `/${nip19.npubEncode(userPubkey)}`, icon: User },
{ label: 'Search', to: '/search', icon: Search },
{ label: 'Settings', to: '/settings', icon: Settings },
{ label: 'About', to: '/about', icon: Info },
...(showDashboard ? [{ labelKey: 'nav.dashboard', to: '/dashboard', icon: Activity }] : []),
{ labelKey: 'nav.myDashboard', to: '/my-dashboard', icon: LayoutDashboard },
{ labelKey: 'nav.wallet', to: '/wallet', icon: Wallet },
{ labelKey: 'nav.notifications', to: '/notifications', icon: Bell },
{ labelKey: 'nav.profile', to: `/${nip19.npubEncode(userPubkey)}`, icon: User },
{ labelKey: 'nav.search', to: '/search', icon: Search },
{ labelKey: 'nav.settings', to: '/settings', icon: Settings },
{ labelKey: 'nav.about', to: '/about', icon: Info },
];
}
function MobileLinkList({ items, onClose }: { items: MobileLinkItem[]; onClose: () => void }) {
const { t } = useTranslation();
if (items.length === 0) return null;
return (
@@ -235,7 +241,7 @@ function MobileLinkList({ items, onClose }: { items: MobileLinkItem[]; onClose:
}
>
<item.icon className="size-4 shrink-0" />
{item.label}
{t(item.labelKey)}
</NavLink>
</li>
))}
+154
View File
@@ -0,0 +1,154 @@
import { Languages, Loader2, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { prepareForTranslation, restoreTokens } from "@/lib/prepareTranslation";
import { cn } from "@/lib/utils";
const DEFAULT_TRANSLATE_WORKER_URL = "https://agora-translate.mk-cc1.workers.dev";
const LANG_MAP: Record<string, string> = {
en: "EN-US",
es: "ES",
ar: "AR",
zh: "ZH",
fa: "FA",
ps: "PS",
};
interface TranslationResponse {
translations?: Array<{
text?: string;
detected_source_language?: string;
}>;
error?: string;
}
interface TranslateButtonProps {
/** Original plaintext content to translate. */
text: string;
/** Optional list of plaintext fields to translate in one request. */
texts?: string[];
/** Called with translated text on success. */
onTranslated: (translated: string, translatedTexts: string[]) => void;
/** Called when the user wants to show the original content again. */
onReset: () => void;
/** Whether translated content is currently visible. */
isTranslated: boolean;
/** Hide label on narrow screens so the action row can collapse to an icon. */
responsiveLabel?: boolean;
/** Always hide the visible label. */
iconOnly?: boolean;
className?: string;
}
export function TranslateButton({
text,
texts,
onTranslated,
onReset,
isTranslated,
responsiveLabel,
iconOnly,
className,
}: TranslateButtonProps) {
const { t, i18n } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const handleClick = async () => {
if (isTranslated) {
onReset();
setError(false);
return;
}
if (!text.trim()) return;
setLoading(true);
setError(false);
try {
const languagePrefix = i18n.language.split("-")[0].toLowerCase();
const targetLang = LANG_MAP[languagePrefix] ?? "EN-US";
const prepared = (texts && texts.length > 0 ? texts : [text]).map(prepareForTranslation);
const translateUrl = import.meta.env.VITE_TRANSLATE_WORKER_URL ?? DEFAULT_TRANSLATE_WORKER_URL;
const response = await fetch(translateUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: prepared.map(({ textToTranslate }) => textToTranslate),
target_lang: targetLang,
}),
});
if (!response.ok) throw new Error(`Translation proxy error ${response.status}`);
const data = await response.json() as TranslationResponse;
const translated = data.translations?.map(({ text }) => text ?? "") ?? [];
if (!translated[0]) throw new Error(data.error ?? "Empty translation response");
const restored = translated.map((value, index) => restoreTokens(value, prepared[index]?.tokens ?? []));
onTranslated(restored[0], restored);
} catch (err) {
console.error("Translation failed:", err);
setError(true);
} finally {
setLoading(false);
}
};
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleClick();
}}
disabled={loading || !text.trim()}
aria-label={
error
? t("translate.error")
: isTranslated
? t("translate.showOriginal")
: t("translate.translate")
}
className={cn(
"h-7 gap-1.5 rounded-full px-2 text-xs transition-colors",
isTranslated || error
? "text-primary hover:text-primary/80"
: "text-muted-foreground hover:text-primary",
className,
)}
title={
error
? t("translate.error")
: isTranslated
? t("translate.showOriginal")
: t("translate.translate")
}
>
{loading ? (
<Loader2 className="size-[18px] animate-spin" />
) : isTranslated ? (
<RotateCcw className="size-[18px]" />
) : (
<Languages className="size-[18px] shrink-0" />
)}
<span className={cn(iconOnly && "sr-only", responsiveLabel && !iconOnly && "hidden sm:inline")}>
{loading
? t("translate.translating")
: error
? t("translate.error")
: isTranslated
? t("translate.showOriginal")
: t("translate.translate")}
</span>
</Button>
);
}
+3 -1
View File
@@ -82,7 +82,9 @@ export function VideoPlayer({ src: originalSrc, poster, className, dim, blurhash
const { src, onError: onBlossomError } = useBlossomFallback(originalSrc);
const { isHls } = useHls(videoRef, src);
const { config } = useAppContext();
const shouldAutoPlay = autoPlay ?? config.autoplayVideos;
// Low-bandwidth mode forces autoplay off, regardless of the per-call or
// per-config preference.
const shouldAutoPlay = config.lowBandwidthMode ? false : (autoPlay ?? config.autoplayVideos);
const generatedPoster = useVideoThumbnail(src, poster);
+27 -25
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, Zap, Globe, WalletMinimal, CheckCircle, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -16,6 +17,7 @@ import { useWallet } from '@/hooks/useWallet';
import { useToast } from '@/hooks/useToast';
export function WalletSettings() {
const { t } = useTranslation();
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [connectionUri, setConnectionUri] = useState('');
const [alias, setAlias] = useState('');
@@ -36,8 +38,8 @@ export function WalletSettings() {
const handleAddConnection = async () => {
if (!connectionUri.trim()) {
toast({
title: 'Connection URI required',
description: 'Please enter a valid NWC connection URI.',
title: t('walletConnect.toast.uriRequiredTitle'),
description: t('walletConnect.toast.uriRequiredDesc'),
variant: 'destructive',
});
return;
@@ -63,8 +65,8 @@ export function WalletSettings() {
const handleSetActive = (connectionString: string) => {
setActiveConnection(connectionString);
toast({
title: 'Active wallet changed',
description: 'The selected wallet is now active for zaps.',
title: t('walletConnect.toast.activeChangedTitle'),
description: t('walletConnect.toast.activeChangedDesc'),
});
};
@@ -73,7 +75,7 @@ export function WalletSettings() {
<div className="space-y-6">
{/* Connection status cards */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-1">Status</h2>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-1">{t('walletConnect.status')}</h2>
<div className="grid gap-3">
{/* WebLN */}
<Card className="overflow-hidden">
@@ -83,14 +85,14 @@ export function WalletSettings() {
<Globe className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">WebLN</p>
<p className="text-xs text-muted-foreground">Browser extension</p>
<p className="text-sm font-medium">{t('walletConnect.webln.name')}</p>
<p className="text-xs text-muted-foreground">{t('walletConnect.webln.description')}</p>
</div>
</div>
<div className="flex items-center gap-2">
{webln && <CheckCircle className="size-4 text-green-500" />}
<Badge variant={webln ? 'default' : 'secondary'} className="text-xs">
{webln ? 'Ready' : 'Not Found'}
{webln ? t('walletConnect.ready') : t('walletConnect.notFound')}
</Badge>
</div>
</CardContent>
@@ -104,18 +106,18 @@ export function WalletSettings() {
<WalletMinimal className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Nostr Wallet Connect</p>
<p className="text-sm font-medium">{t('walletConnect.nwc.name')}</p>
<p className="text-xs text-muted-foreground">
{connections.length > 0
? `${connections.length} wallet${connections.length !== 1 ? 's' : ''} connected`
: 'Remote wallet connection'}
? t('walletConnect.nwc.connectedCount', { count: connections.length })
: t('walletConnect.nwc.description')}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{hasNWC && <CheckCircle className="size-4 text-green-500" />}
<Badge variant={hasNWC ? 'default' : 'secondary'} className="text-xs">
{hasNWC ? 'Ready' : 'None'}
{hasNWC ? t('walletConnect.ready') : t('walletConnect.none')}
</Badge>
</div>
</CardContent>
@@ -128,10 +130,10 @@ export function WalletSettings() {
{/* NWC Wallets */}
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Nostr Wallet Connect</h2>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{t('walletConnect.nwc.name')}</h2>
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)} className="rounded-full">
<Plus className="size-4 mr-1" />
Add
{t('walletConnect.add')}
</Button>
</div>
@@ -139,8 +141,8 @@ export function WalletSettings() {
<Card className="border-dashed">
<CardContent className="py-10 text-center">
<WalletMinimal className="size-8 mx-auto mb-3 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground mb-1">No wallets connected</p>
<p className="text-xs text-muted-foreground/70">Add an NWC connection to enable instant zaps.</p>
<p className="text-sm text-muted-foreground mb-1">{t('walletConnect.empty.title')}</p>
<p className="text-xs text-muted-foreground/70">{t('walletConnect.empty.description')}</p>
</CardContent>
</Card>
) : (
@@ -157,10 +159,10 @@ export function WalletSettings() {
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{connection.alias || info?.alias || 'Lightning Wallet'}
{connection.alias || info?.alias || t('walletConnect.defaultWalletName')}
</p>
<p className="text-xs text-muted-foreground">
{isActive ? 'Active' : 'NWC Connection'}
{isActive ? t('walletConnect.active') : t('walletConnect.connectionLabel')}
</p>
</div>
</div>
@@ -172,7 +174,7 @@ export function WalletSettings() {
variant="ghost"
onClick={() => handleSetActive(connection.connectionString)}
className="rounded-full"
title="Set as active"
title={t('walletConnect.setActiveTitle')}
>
<Zap className="size-3.5" />
</Button>
@@ -182,7 +184,7 @@ export function WalletSettings() {
variant="ghost"
onClick={() => handleRemoveConnection(connection.connectionString)}
className="rounded-full text-muted-foreground hover:text-destructive"
title="Remove wallet"
title={t('walletConnect.removeTitle')}
>
<Trash2 className="size-3.5" />
</Button>
@@ -201,7 +203,7 @@ export function WalletSettings() {
<Separator />
<div className="text-center py-4 space-y-2 px-4">
<p className="text-sm text-muted-foreground">
Install a WebLN browser extension or connect a NWC wallet to send zaps.
{t('walletConnect.helpText')}
</p>
</div>
</>
@@ -214,7 +216,7 @@ export function WalletSettings() {
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold">
Connect NWC Wallet
{t('walletConnect.dialog.title')}
</DialogTitle>
<button
onClick={() => setAddDialogOpen(false)}
@@ -226,13 +228,13 @@ export function WalletSettings() {
{/* Description */}
<p className="px-4 -mt-1 mb-2 text-sm text-muted-foreground">
Paste a connection string from your NWC-compatible wallet.
{t('walletConnect.dialog.description')}
</p>
{/* Form fields */}
<div className="px-4 space-y-4">
<Input
placeholder="Wallet name (optional)"
placeholder={t('walletConnect.dialog.aliasPlaceholder')}
value={alias}
onChange={(e) => setAlias(e.target.value)}
className="bg-transparent"
@@ -254,7 +256,7 @@ export function WalletSettings() {
className="rounded-full px-5 font-bold"
size="sm"
>
{isConnecting ? 'Connecting...' : 'Connect'}
{isConnecting ? t('walletConnect.dialog.connecting') : t('walletConnect.dialog.connect')}
</Button>
</div>
</DialogContent>
-28
View File
@@ -10,7 +10,6 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Skeleton } from '@/components/ui/skeleton';
/** Get a tag value by name. */
function getTag(tags: string[][], name: string): string | undefined {
@@ -451,30 +450,3 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
);
}
/** Skeleton loading state for ZapstoreAppContent. */
export function ZapstoreAppSkeleton() {
return (
<div className="mt-3 space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-16 rounded-2xl shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-14 rounded-full" />
</div>
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-8 w-24 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
</div>
);
}
@@ -21,7 +21,6 @@ import { Link } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { ZAPSTORE_RELAY } from '@/lib/appRelays';
import { openUrl } from '@/lib/downloadFile';
@@ -747,6 +746,3 @@ export function ZapstoreAssetSkeleton() {
</div>
);
}
// Re-export Separator so it's available if needed
export { Separator };
+15 -10
View File
@@ -1,8 +1,12 @@
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Activity, Bell, ChevronDown, CircleHelp, LayoutDashboard, LogOut, Search, Settings, User, UserIcon, UserPlus, Wallet } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import {
@@ -23,6 +27,7 @@ interface AccountSwitcherProps {
}
export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
const { t } = useTranslation();
const { currentUser, otherUsers, isLoading, setLogin, removeLogin } = useLoggedInAccounts();
const { orderedItems } = useFeedSettings();
const [isOpen, setIsOpen] = useState(false);
@@ -78,50 +83,50 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
<Link to="/dashboard">
<Activity className='w-4 h-4' />
<span>Dashboard</span>
<span>{t('nav.dashboard')}</span>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
<Link to="/my-dashboard">
<LayoutDashboard className='w-4 h-4' />
<span>My Dashboard</span>
<span>{t('nav.myDashboard')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
<Link to="/wallet">
<Wallet className='w-4 h-4' />
<span>Wallet</span>
<span>{t('nav.wallet')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
<Link to="/notifications">
<Bell className='w-4 h-4' />
<span>Notifications</span>
<span>{t('nav.notifications')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
<Link to={`/${nip19.npubEncode(currentUser.pubkey)}`}>
<User className='w-4 h-4' />
<span>Profile</span>
<span>{t('nav.profile')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
<Link to="/search">
<Search className='w-4 h-4' />
<span>Search</span>
<span>{t('nav.search')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
<Link to="/settings">
<Settings className='w-4 h-4' />
<span>Settings</span>
<span>{t('nav.settings')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
<Link to="/about">
<CircleHelp className='w-4 h-4' />
<span>About</span>
<span>{t('nav.about')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -130,14 +135,14 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
>
<UserPlus className='w-4 h-4' />
<span>Add another account</span>
<span>{t('auth.addAccount')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleLogout}
className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500'
>
<LogOut className='w-4 h-4' />
<span>Log out</span>
<span>{t('auth.logout')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+4 -2
View File
@@ -1,15 +1,17 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button.tsx';
import AuthDialog from './AuthDialog';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { AccountSwitcher } from './AccountSwitcher';
import { cn } from '@/lib/utils';
export interface LoginAreaProps {
interface LoginAreaProps {
className?: string;
}
export function LoginArea({ className }: LoginAreaProps) {
const { t } = useTranslation();
const { currentUser } = useLoggedInAccounts();
const [authDialogOpen, setAuthDialogOpen] = useState(false);
@@ -22,7 +24,7 @@ export function LoginArea({ className }: LoginAreaProps) {
onClick={() => setAuthDialogOpen(true)}
className="flex items-center gap-2 px-5 py-2 rounded-full bg-primary text-white font-medium transition-all hover:bg-primary/90 animate-scale-in"
>
<span className="truncate">Join</span>
<span className="truncate">{t('auth.join')}</span>
</Button>
)}
+37 -18
View File
@@ -6,11 +6,13 @@ import { CommunityModerationOverlay } from '@/components/CommunityModerationMenu
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
type ParsedCommunity,
} from '@/lib/communityUtils';
@@ -39,8 +41,22 @@ interface CommunityMiniCardProps {
* a per-card cache subscription on every viewer.
*/
export function CommunityMiniCard({ community, className }: CommunityMiniCardProps) {
const communityEvent = {
id: community.aTag,
pubkey: community.founderPubkey,
kind: COMMUNITY_DEFINITION_KIND,
tags: community.tags,
content: '',
created_at: 0,
sig: '',
};
const { translatedEvent, translateAction } = useEventTranslation(communityEvent, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayCommunity = parseCommunityEvent(translatedEvent) ?? community;
const founder = useAuthor(community.founderPubkey);
const banner = sanitizeUrl(community.image);
const banner = sanitizeUrl(displayCommunity.image);
const founderName =
founder.data?.metadata?.display_name ||
founder.data?.metadata?.name ||
@@ -83,27 +99,30 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
</div>
<div className="flex flex-col gap-2 p-3.5 flex-1">
<h3 className="font-semibold leading-tight text-sm tracking-tight line-clamp-1">
{community.name}
{displayCommunity.name}
</h3>
{community.description && (
{displayCommunity.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-snug">
{community.description}
{displayCommunity.description}
</p>
)}
<div className="flex items-center gap-2 mt-auto pt-1.5">
{founderAvatar ? (
<img
src={founderAvatar}
alt=""
loading="lazy"
className="size-5 rounded-full object-cover"
/>
) : (
<div className="size-5 rounded-full bg-secondary" />
)}
<span className="text-[11px] text-muted-foreground truncate">
by {founderName}
</span>
<div className="mt-auto flex items-center justify-between gap-2 pt-1.5">
<div className="flex min-w-0 items-center gap-2">
{founderAvatar ? (
<img
src={founderAvatar}
alt=""
loading="lazy"
className="size-5 rounded-full object-cover"
/>
) : (
<div className="size-5 rounded-full bg-secondary" />
)}
<span className="truncate text-[11px] text-muted-foreground">
by {founderName}
</span>
</div>
{translateAction}
</div>
</div>
</Card>
-66
View File
@@ -1,66 +0,0 @@
import { Link } from 'react-router-dom';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { getAvatarShape } from '@/lib/avatarShape';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getDisplayName } from '@/lib/getDisplayName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface ProfileCardProps {
/** Nostr pubkey of the profile. */
pubkey: string;
/** Subtitle line below the name (e.g. "5 tracks"). */
subtitle?: string;
/** Extra classes on the outer container. */
className?: string;
}
/**
* Circular avatar profile card for discovery page grids and horizontal scrolls.
* Displays avatar, display name, and an optional subtitle (e.g. track count).
*
* Clicking navigates to the user's profile page.
*
* Used by music artist cards, podcast host cards, and other
* profile-centric discovery sections.
*/
export function ProfileCard({ pubkey, subtitle, className }: ProfileCardProps) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = getDisplayName(metadata, pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
return (
<Link
to={profileUrl}
className={cn('w-[110px] shrink-0 flex flex-col items-center cursor-pointer group', className)}
>
<Avatar shape={avatarShape} className="size-20 border-2 border-border group-hover:border-primary/40 transition-colors">
<AvatarImage src={sanitizeUrl(metadata?.picture)} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-lg font-semibold">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="text-sm font-medium truncate mt-2 text-center w-full group-hover:text-primary transition-colors">
{displayName}
</p>
{subtitle && (
<p className="text-xs text-muted-foreground">{subtitle}</p>
)}
</Link>
);
}
/** Loading skeleton matching ProfileCard dimensions. */
export function ProfileCardSkeleton() {
return (
<div className="w-[110px] shrink-0 flex flex-col items-center">
<Skeleton className="size-20 rounded-full" />
<Skeleton className="h-4 w-16 mt-2" />
<Skeleton className="h-3 w-12 mt-1" />
</div>
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/card';
import { renderInlineMarkup } from '@/lib/helpMarkup';
@@ -10,12 +11,13 @@ import type { GuideTldrBlock } from '@/lib/helpContent';
* page's promise in a single screen.
*/
export function GuideTLDR({ block }: { block: GuideTldrBlock }) {
const { t } = useTranslation();
return (
<Card className="overflow-hidden border-primary/20 bg-gradient-to-br from-primary/5 via-card to-card">
<div className="grid gap-5 p-5 sm:p-6 sm:grid-cols-[1fr_auto] sm:gap-8 sm:items-center">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary/80 mb-2">
The short version
{t('guides.shared.tldrEyebrow')}
</p>
<p className="text-lg sm:text-xl font-medium leading-snug text-foreground">
{renderInlineMarkup(block.lede)}
+5 -2
View File
@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import type { PaymentMode } from '@/lib/helpContent';
@@ -16,7 +18,8 @@ interface InlinePaymentBadgeProps {
* either looking like a warning state.
*/
export function InlinePaymentBadge({ mode, className }: InlinePaymentBadgeProps) {
const label = mode === 'public' ? 'Public' : 'Silent';
const { t } = useTranslation();
const label = t(`guides.shared.paymentBadge.${mode}`);
return (
<span
className={cn(
@@ -27,7 +30,7 @@ export function InlinePaymentBadge({ mode, className }: InlinePaymentBadgeProps)
className,
)}
>
{label} Payments
{label}
</span>
);
}
+39 -86
View File
@@ -1,5 +1,6 @@
import { Bell, Bug, Eye, EyeOff, Gauge, ShieldCheck, Sparkles, Wallet } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { renderInlineMarkup } from '@/lib/helpMarkup';
@@ -13,91 +14,26 @@ interface Row {
silent: string;
}
const DONOR_ROWS: Row[] = [
{
label: 'What you see',
Icon: Eye,
public: 'A regular Bitcoin address you can pay from anywhere.',
silent:
'The same QR. Your wallet picks the silent-payment endpoint if it supports BIP-352.',
},
{
label: 'Wallet support',
Icon: Wallet,
public:
'Every Bitcoin wallet. Cash App, Coinbase, Strike, hardware, anything.',
silent:
'Few wallets today. Most fall back to a regular Bitcoin transaction.',
},
{
label: 'Privacy of the donation',
Icon: ShieldCheck,
public:
'Public on-chain. Your sending address is permanently linked to the campaign.',
silent:
"Receiving side is unlinkable on-chain. Your sending wallet's trail is still public.",
},
{
label: 'Settlement',
Icon: Gauge,
public: 'Normal Bitcoin confirmations.',
silent:
'Same on-chain confirmations, but the activist has to scan their wallet to find it.',
},
/**
* Row IDs and icons. Strings live in `guides.shared.paymentComparison.*`
* in the locale files, keyed by the `id` below. Keeping icons in code
* lets translators ignore the visual layer entirely.
*/
const DONOR_ROW_IDS: { id: string; Icon: LucideIcon }[] = [
{ id: 'whatYouSee', Icon: Eye },
{ id: 'walletSupport', Icon: Wallet },
{ id: 'privacy', Icon: ShieldCheck },
{ id: 'settlement', Icon: Gauge },
];
const ACTIVIST_ROWS: Row[] = [
{
label: 'What donors see',
Icon: Sparkles,
public:
'A regular Bitcoin address. Works with every wallet on earth.',
silent:
"A BIP-352 endpoint. Donors' wallets need silent-payments support; otherwise the donation falls back to a regular Bitcoin transaction.",
},
{
label: 'Receiving speed',
Icon: Gauge,
public: 'Push-style. Donations show up immediately on the campaign page.',
silent:
'Manual scanning. Your wallet has to walk the blockchain looking for them. Minutes to hours, depending on the wallet.',
},
{
label: 'Push notifications',
Icon: Bell,
public: 'Yes. You see new donations the moment they arrive.',
silent: 'No. Open the wallet and trigger a scan to discover them.',
},
{
label: 'Donor list / campaign totals',
Icon: Eye,
public:
'Public forever. Amounts and sending addresses are visible to anyone.',
silent:
"Private. The campaign page can't show silent-payments donation counts or totals.",
},
{
label: 'Ecosystem maturity',
Icon: Bug,
public: 'Mature. Settled tooling.',
silent:
'Bleeding-edge. Wallets are still buggy; expect missed payments that show up on a later scan.',
},
{
label: 'Best for',
Icon: ShieldCheck,
public:
'Above-ground fundraisers where social proof and visibility help.',
silent:
'Campaigns where donor or activist privacy matters more than the visible total.',
},
{
label: 'Watch out for',
Icon: EyeOff,
public: 'Permanent public record of every donor.',
silent:
"Bumpy UX today. Some donations won't show until the activist scans.",
},
const ACTIVIST_ROW_IDS: { id: string; Icon: LucideIcon }[] = [
{ id: 'whatDonorsSee', Icon: Sparkles },
{ id: 'receivingSpeed', Icon: Gauge },
{ id: 'pushNotifications', Icon: Bell },
{ id: 'donorList', Icon: Eye },
{ id: 'ecosystem', Icon: Bug },
{ id: 'bestFor', Icon: ShieldCheck },
{ id: 'watchOutFor', Icon: EyeOff },
];
/**
@@ -109,14 +45,31 @@ const ACTIVIST_ROWS: Row[] = [
* the same row labels inside each card. No sideways scrolling.
*
* Row content is driven by the `audience` flag so donors and activists
* get row copy tuned to what they care about.
* get row copy tuned to what they care about. All strings are read from
* i18n keyed by audience-specific row IDs in `helpContent.ts`'s
* structural template.
*/
export function PaymentComparisonTable({
block,
}: {
block: GuidePaymentComparisonBlock;
}) {
const rows = block.audience === 'donor' ? DONOR_ROWS : ACTIVIST_ROWS;
const { t } = useTranslation();
const rowIds = block.audience === 'donor' ? DONOR_ROW_IDS : ACTIVIST_ROW_IDS;
const audienceKey = block.audience === 'donor' ? 'donorRows' : 'activistRows';
const rows: Row[] = rowIds.map(({ id, Icon }) => ({
label: t(`guides.shared.paymentComparison.${audienceKey}.${id}.label`),
Icon,
public: t(`guides.shared.paymentComparison.${audienceKey}.${id}.public`),
silent: t(`guides.shared.paymentComparison.${audienceKey}.${id}.silent`),
}));
const headerText = t(
block.audience === 'donor'
? 'guides.shared.paymentComparison.donorHeader'
: 'guides.shared.paymentComparison.activistHeader',
);
return (
<section>
@@ -126,7 +79,7 @@ export function PaymentComparisonTable({
{/* Header row */}
<div className="grid grid-cols-[1.1fr_1fr_1fr] bg-secondary/40 border-b">
<div className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{block.audience === 'donor' ? 'When you donate' : 'When you create'}
{headerText}
</div>
<div className="px-4 py-3 border-l">
<InlinePaymentBadge mode="public" />
-1
View File
@@ -8,6 +8,5 @@ export { CalloutCard } from './CalloutCard';
export { GuideProse } from './GuideProse';
export { GuideSteps } from './GuideSteps';
export { GuideTLDR } from './GuideTLDR';
export { InlinePaymentBadge } from './InlinePaymentBadge';
export { OptionGrid } from './OptionGrid';
export { PaymentComparisonTable } from './PaymentComparisonTable';
+3 -143
View File
@@ -3,23 +3,16 @@
* Tap backdrop to dismiss.
*/
import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react';
import { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { Loader2, Lock } from 'lucide-react';
import { InkPenIcon } from '@/components/icons/InkPenIcon';
import type { NostrEvent } from '@nostrify/nostrify';
import { useDecryptLetter } from '@/hooks/useLetters';
import { FONT_OPTIONS, LINE_HEIGHT_RATIO, COLOR_MOMENT_KIND, resolveStationery, colorMomentToStationery, type Letter } from '@/lib/letterTypes';
import { hexLuminance, backgroundTextColor } from '@/lib/colorUtils';
import { ColorPaletteDisplay, type PaletteLayout } from './ColorPaletteDisplay';
import { FONT_OPTIONS, LINE_HEIGHT_RATIO, type Letter } from '@/lib/letterTypes';
import { ensureLetterFonts } from '@/lib/letterUtils';
import { sanitizeCssString } from '@/lib/fontLoader';
import { sanitizeCssString } from '@/lib/cssSanitize';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { LetterStickers } from './LetterStickers';
import { useTheme } from '@/hooks/useTheme';
import { toast } from '@/hooks/useToast';
import { paletteToTheme, getColors } from '@/lib/colorMomentUtils';
import type { ThemeConfig } from '@/themes';
import {
Dialog,
DialogContent,
@@ -29,132 +22,6 @@ import { Button } from '@/components/ui/button';
import { nip19 } from 'nostr-tools';
import { useCurrentUser } from '@/hooks/useCurrentUser';
// ---------------------------------------------------------------------------
// Attached gift — color moment that can be applied on the spot
// ---------------------------------------------------------------------------
/** Renders an attached gift — present box overlapping a themed bubble. */
function LetterAttachment({ event }: { event: NostrEvent }) {
const { applyCustomTheme, theme, customTheme, setTheme } = useTheme();
const [applied, setApplied] = useState(false);
const prevRef = useRef<{ mode: typeof theme; config?: ThemeConfig } | undefined>(undefined);
const attachment = useMemo(() => {
if (event.kind === COLOR_MOMENT_KIND) {
const colors = getColors(event.tags);
if (colors.length < 2) return null;
const core = paletteToTheme(colors);
const stationery = colorMomentToStationery(event);
const resolved = resolveStationery(stationery);
return { type: 'color-moment' as const, label: 'Color Moment', colors, core, resolved };
}
return null;
}, [event]);
const handleApply = useCallback(() => {
if (!attachment) return;
prevRef.current = { mode: theme, config: customTheme };
applyCustomTheme(attachment.core);
setApplied(true);
toast({
title: 'Theme applied',
description: `"${attachment.label}" is now your active theme.`,
action: (
<button
className="text-sm font-medium underline underline-offset-2"
onClick={() => {
const prev = prevRef.current;
if (!prev) return;
if (prev.mode === 'custom' && prev.config) applyCustomTheme(prev.config);
else setTheme(prev.mode);
setApplied(false);
}}
>
Undo
</button>
),
});
}, [attachment, theme, customTheme, applyCustomTheme, setTheme]);
if (!attachment) return null;
const { resolved } = attachment;
const bg = resolved.color;
const primary = `hsl(${attachment.core.primary})`;
// Use a visible border when the bg is very light to avoid white-on-white
const needsBorder = hexLuminance(bg) > 0.85;
const ribbonColor = attachment.colors[Math.floor(attachment.colors.length / 2)] ?? primary;
// Derive readable text color from the scrimmed background
const textColor = backgroundTextColor(bg);
return (
<div className="relative max-w-[220px] mx-auto mb-12 pointer-events-none">
{/* Present box — overlaps the bubble top */}
<div className="flex justify-center relative z-10 mb-[-20px]">
<svg width="56" height="60" viewBox="0 0 56 60" fill="none" className="drop-shadow-sm">
{/* Box body */}
<rect x="4" y="26" width="48" height="32" rx="3" fill={bg} stroke={needsBorder ? '#0001' : 'none'} strokeWidth="1" />
<rect x="4" y="26" width="48" height="8" rx="3" fill="white" opacity="0.07" />
{/* Ribbon vertical */}
<rect x="24" y="26" width="8" height="32" fill={ribbonColor} opacity="0.8" />
{/* Ribbon horizontal */}
<rect x="4" y="38" width="48" height="6" fill={ribbonColor} opacity="0.8" />
{/* Lid */}
<rect x="2" y="18" width="52" height="10" rx="2.5" fill={bg} stroke={needsBorder ? '#0001' : ribbonColor} strokeWidth={needsBorder ? 1 : 0.5} strokeOpacity={needsBorder ? 1 : 0.2} />
<rect x="2" y="18" width="52" height="4" rx="2.5" fill="white" opacity="0.1" />
{/* Lid ribbon */}
<rect x="24" y="18" width="8" height="10" fill={ribbonColor} opacity="0.8" />
{/* Bow — left loop */}
<ellipse cx="20" cy="14" rx="8" ry="6" fill={ribbonColor} />
<ellipse cx="19.5" cy="12.5" rx="4.5" ry="3" fill="white" opacity="0.2" />
{/* Bow — right loop */}
<ellipse cx="36" cy="14" rx="8" ry="6" fill={ribbonColor} />
<ellipse cx="36.5" cy="12.5" rx="4.5" ry="3" fill="white" opacity="0.2" />
{/* Bow — knot */}
<ellipse cx="28" cy="16" rx="5" ry="4" fill={ribbonColor} />
<ellipse cx="28" cy="15" rx="2.5" ry="2" fill="white" opacity="0.15" />
</svg>
</div>
{/* Themed bubble — clickable */}
<div
onClick={(e) => { e.stopPropagation(); handleApply(); }}
role="button"
tabIndex={0}
title={applied ? 'Theme applied!' : `Tap to use "${attachment.label}" as your theme`}
className="relative rounded-2xl overflow-hidden pointer-events-auto cursor-pointer transition-transform duration-200 active:scale-95 hover:scale-[1.02]"
style={{
background: bg,
border: needsBorder ? '1px solid hsl(var(--border))' : `1px solid ${ribbonColor}22`,
}}
>
{/* Background: actual color moment pattern */}
{attachment.type === 'color-moment' && attachment.colors.length > 0 && (
<ColorPaletteDisplay
colors={attachment.colors}
layout={(resolved.layout as PaletteLayout) || 'horizontal'}
className="absolute inset-0"
/>
)}
{/* Scrim for text readability */}
<div className="absolute inset-0" style={{ background: `${bg}bb` }} />
{/* Content */}
<div className="relative px-4 pt-7 pb-3.5 text-center">
<p className="text-xs font-semibold truncate" style={{ color: textColor }}>
{attachment.label}
</p>
<p className="text-[11px] mt-0.5" style={{ color: textColor, opacity: 0.6 }}>
{applied ? 'Applied as your theme' : 'Tap to use as theme'}
</p>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
interface LetterDetailSheetProps {
letter: Letter | null;
onClose: () => void;
@@ -222,13 +89,6 @@ export function LetterDetailSheet({ letter, onClose, onReply }: LetterDetailShee
{/* Outer click-to-close layer — zero height so dialog centers on the card only */}
<div className="relative" onClick={onClose}>
{/* Attached gift — absolutely above the card */}
{effectiveStationery?.event && (
<div className="absolute bottom-full left-0 right-0 pointer-events-none">
<LetterAttachment event={effectiveStationery.event} />
</div>
)}
<div
ref={letterRef}
className="relative"
+1 -105
View File
@@ -20,9 +20,8 @@ import {
resolveStationery,
} from '@/lib/letterTypes';
import { ColorPaletteDisplay, type PaletteLayout } from './ColorPaletteDisplay';
import { hexLuminance, darkenHex } from '@/lib/colorUtils';
import { darkenHex } from '@/lib/colorUtils';
export type { PaletteLayout } from './ColorPaletteDisplay';
const DEFAULT_STATIONERY: Stationery = { color: DEFAULT_STATIONERY_COLOR };
@@ -223,106 +222,3 @@ export function StationeryBackground({
// StationeryPreview — picker grid swatch
// ---------------------------------------------------------------------------
interface StationeryPreviewProps {
stationery: Stationery;
selected?: boolean;
className?: string;
}
function ThemeMockup({ stationery }: { stationery: Stationery }) {
const s = resolveStationery(stationery);
const bg = s.color;
const text = s.textColor ?? '#333333';
const primary = s.primaryColor ?? '#4CAF50';
const isDark = hexLuminance(bg) < 0.3;
const cardBg = isDark ? `${bg}dd` : '#ffffffcc';
return (
<div className="absolute inset-0 p-1.5 flex flex-col gap-1" style={{ backgroundColor: bg }}>
{s.imageUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url("${s.imageUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
opacity: 0.35,
}}
/>
)}
<div className="relative z-10 h-2 rounded-sm" style={{ backgroundColor: cardBg }} />
<div
className="relative z-10 flex-1 rounded-sm p-1 flex flex-col justify-between overflow-hidden"
style={{ backgroundColor: cardBg }}
>
<div className="space-y-0.5">
<div className="h-1 w-3/4 rounded-full opacity-60" style={{ backgroundColor: text }} />
<div className="h-1 w-1/2 rounded-full opacity-40" style={{ backgroundColor: text }} />
</div>
<div className="h-2 w-8 rounded-sm" style={{ backgroundColor: primary }} />
</div>
<div
className="absolute right-0 top-0 bottom-0 w-3 z-10"
style={{ backgroundColor: primary, opacity: 0.6 }}
/>
</div>
);
}
export function StationeryPreview({
stationery,
selected,
className = '',
}: StationeryPreviewProps) {
const s = resolveStationery(stationery);
const ringClass = selected
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: '';
if (s.colors && s.colors.length >= 2) {
return (
<div className={`relative overflow-hidden rounded-2xl ${ringClass} ${className}`}>
<ColorPaletteDisplay
colors={s.colors}
layout={(s.layout as PaletteLayout) ?? 'horizontal'}
className="w-full h-full"
>
{s.emoji && (
<span className="text-xl drop-shadow select-none">{s.emoji}</span>
)}
</ColorPaletteDisplay>
</div>
);
}
if (s.textColor || s.primaryColor) {
return (
<div className={`relative overflow-hidden rounded-2xl ${ringClass} ${className}`}>
<ThemeMockup stationery={stationery} />
</div>
);
}
return (
<div
className={`relative overflow-hidden rounded-2xl ${ringClass} ${className}`}
style={{ backgroundColor: s.color }}
>
{s.imageUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url("${s.imageUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
{s.emoji && !s.imageUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
<span className="text-3xl opacity-70">{s.emoji}</span>
</div>
)}
</div>
);
}
-98
View File
@@ -1,98 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Sticker, Info } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
interface StickerPickerProps {
onSelect: (emoji: CustomEmoji) => void;
}
export function StickerPicker({ onSelect }: StickerPickerProps) {
const { emojis, isLoading } = useCustomEmojis();
const [infoOpen, setInfoOpen] = useState(false);
return (
<div className="space-y-2">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-muted-foreground px-1">my stickers</span>
<button
onClick={() => setInfoOpen(true)}
className="ml-auto opacity-60 hover:opacity-100 transition-opacity"
aria-label="About stickers"
>
<Info className="w-5 h-5" strokeWidth={2.5} />
</button>
</div>
{isLoading && (
<div className="grid grid-cols-4 gap-1.5">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-xl" />
))}
</div>
)}
{!isLoading && emojis.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
<Sticker className="size-8 opacity-40" />
<p className="text-sm">no sticker packs yet</p>
<p className="text-xs opacity-70">add emoji packs to your profile to use stickers</p>
</div>
)}
{!isLoading && emojis.length > 0 && (
<ScrollArea className="h-[200px]">
<div className="grid grid-cols-4 gap-1.5 p-1">
{emojis.map((emoji) => (
<button
key={emoji.shortcode}
type="button"
title={emoji.shortcode}
onClick={() => onSelect(emoji)}
className="aspect-square rounded-xl overflow-hidden hover:bg-muted/80 transition-all p-1.5 group active:scale-90"
>
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
/>
</button>
))}
</div>
</ScrollArea>
)}
<Dialog open={infoOpen} onOpenChange={setInfoOpen}>
<DialogContent className="max-w-xs rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sticker className="w-5 h-5 shrink-0" />
Stickers
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Stickers are custom emoji packs (NIP-30) published on Nostr. They get placed on top of your letter as decorations.
</p>
<p>
Add emoji packs to your profile to make them available as stickers.
</p>
<p>
<Link
to="/emojis"
onClick={() => setInfoOpen(false)}
className="text-foreground font-medium underline underline-offset-2 hover:no-underline"
>
Browse emoji packs
</Link>
</p>
</div>
</DialogContent>
</Dialog>
</div>
);
}
-115
View File
@@ -1,119 +1,4 @@
import { useMemo, useState } from 'react';
import { Play, Pause, Music } from 'lucide-react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { useAuthor } from '@/hooks/useAuthor';
import { parseMusicTrack, toAudioTrack } from '@/lib/musicHelpers';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { formatTime } from '@/lib/formatTime';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface MusicTrackRowProps {
/** The music track event. */
event: NostrEvent;
/** Optional row index (displayed when not hovering/playing). */
index?: number;
}
/**
* Compact track row for "Recently Added" sections and the Tracks tab.
*
* Layout: [index/play] [artwork] [title + artist] [duration]
*
* **States**:
* - Default: Shows row index, muted text
* - Hover: Index replaced with play icon
* - Now playing: Primary-colored title, pause icon, subtle bg tint
* - No artwork: Small Music icon placeholder
*/
export function MusicTrackRow({ event, index }: MusicTrackRowProps) {
const player = useAudioPlayer();
const parsed = useMemo(() => parseMusicTrack(event), [event]);
const author = useAuthor(event.pubkey);
const [imgError, setImgError] = useState(false);
const naddrPath = useMemo(() => {
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
return '/' + nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: d });
}, [event]);
if (!parsed) return null;
const isNowPlaying = player.currentTrack?.id === event.id;
const dur = parsed.duration ? formatTime(parsed.duration) : undefined;
const handlePlay = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isNowPlaying && player.isPlaying) {
player.pause();
} else if (isNowPlaying) {
player.resume();
} else {
const track = toAudioTrack(event, parsed);
track.artwork ??= sanitizeUrl(author.data?.metadata?.picture);
player.playTrack(track);
}
};
return (
<Link
to={naddrPath}
className={cn(
'flex items-center gap-3 px-4 py-2.5 transition-colors cursor-pointer group',
isNowPlaying ? 'bg-primary/5' : 'hover:bg-secondary/30',
)}
>
{/* Index / Play button */}
<button
onClick={handlePlay}
className="size-8 flex items-center justify-center shrink-0"
aria-label={isNowPlaying && player.isPlaying ? 'Pause' : 'Play'}
>
{isNowPlaying && player.isPlaying ? (
<Pause className="size-4 text-primary" fill="currentColor" />
) : (
<>
<span className="text-sm text-muted-foreground group-hover:hidden tabular-nums">
{index !== undefined ? index + 1 : ''}
</span>
<Play className="size-4 text-muted-foreground hidden group-hover:block" fill="currentColor" />
</>
)}
</button>
{/* Artwork */}
<div className="size-12 rounded-lg overflow-hidden shrink-0 bg-muted">
{parsed.artwork && !imgError ? (
<img src={parsed.artwork} alt={parsed.title} className="size-full object-cover" loading="lazy" onError={() => setImgError(true)} />
) : (
<div className="size-full flex items-center justify-center bg-primary/10">
<Music className="size-5 text-primary/30" />
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm font-medium truncate',
isNowPlaying && 'text-primary',
)}>
{parsed.title}
</p>
<p className="text-xs text-muted-foreground truncate">{parsed.artist}</p>
</div>
{/* Duration */}
{dur && (
<span className="text-xs text-muted-foreground tabular-nums shrink-0">{dur}</span>
)}
</Link>
);
}
/** Loading skeleton matching MusicTrackRow dimensions. */
export function MusicTrackRowSkeleton() {
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Users } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@@ -31,6 +32,7 @@ interface OrganizationsAllDialogProps {
* directly without pulling in the strip's full file.
*/
export function OrganizationsAllDialog({ orgs, children }: OrganizationsAllDialogProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
@@ -40,7 +42,7 @@ export function OrganizationsAllDialog({ orgs, children }: OrganizationsAllDialo
<DialogHeader className="px-6 pt-6">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
All groups
{t('profile.groupsDialog.title')}
<span className="text-sm font-normal text-muted-foreground">({orgs.length})</span>
</DialogTitle>
</DialogHeader>
@@ -56,7 +58,7 @@ export function OrganizationsAllDialog({ orgs, children }: OrganizationsAllDialo
entry.isFounder ? 'text-primary' : 'text-foreground',
)}
>
{entry.isFounder ? 'Founder' : 'Moderator'}
{entry.isFounder ? t('profile.badges.founder') : t('profile.badges.moderator')}
</Badge>
</div>
))}
@@ -64,7 +66,7 @@ export function OrganizationsAllDialog({ orgs, children }: OrganizationsAllDialo
</ScrollArea>
<div className="px-6 pb-6 pt-2 flex justify-end">
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
Close
{t('common.close')}
</Button>
</div>
</DialogContent>
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useInView } from 'react-intersection-observer';
import { Loader2, Sparkles } from 'lucide-react';
@@ -31,6 +32,7 @@ interface ProfileActivityTabProps {
* and benefits from full-width cards.
*/
export function ProfileActivityTab({ pubkey, displayName }: ProfileActivityTabProps) {
const { t } = useTranslation();
const {
events,
items,
@@ -72,8 +74,7 @@ export function ProfileActivityTab({ pubkey, displayName }: ProfileActivityTabPr
<div className="py-12 px-8 text-center">
<Sparkles className="size-10 mx-auto mb-3 text-muted-foreground/40" />
<p className="text-muted-foreground max-w-sm mx-auto">
No activity from {displayName} yet. Posts, campaigns, pledges,
and donations all show up here.
{t('profile.activity.empty', { name: displayName })}
</p>
</div>
</Card>
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Megaphone } from 'lucide-react';
import { useQueries } from '@tanstack/react-query';
@@ -40,6 +41,7 @@ export function ProfileCampaignsTab({
campaigns,
isLoading,
}: ProfileCampaignsTabProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: moderation } = useCampaignModeration();
const { data: moderators } = useCampaignModerators();
@@ -75,15 +77,15 @@ export function ProfileCampaignsTab({
<Megaphone className="size-10 mx-auto mb-3 text-muted-foreground/40" />
<p className="text-muted-foreground max-w-sm mx-auto">
{isOwnProfile
? "You haven't launched a campaign yet."
: `${displayName} hasn't launched a campaign yet.`}
? t('profile.campaigns.emptySelf')
: t('profile.campaigns.emptyOther', { name: displayName })}
</p>
{isOwnProfile && (
<Link
to="/campaigns/new"
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
>
Start a campaign
{t('profile.campaigns.startLink')}
</Link>
)}
</div>
@@ -96,7 +98,7 @@ export function ProfileCampaignsTab({
<div className="px-4 sm:px-6 py-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{filtered.length} {filtered.length === 1 ? 'campaign' : 'campaigns'}
{t('profile.campaigns.count', { count: filtered.length })}
</p>
<div className="flex items-center gap-2">
<Button
@@ -104,14 +106,14 @@ export function ProfileCampaignsTab({
size="sm"
onClick={() => setSortMode('new')}
>
New
{t('profile.campaigns.sortNew')}
</Button>
<Button
variant={sortMode === 'top' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setSortMode('top')}
>
Top
{t('profile.campaigns.sortTop')}
</Button>
{canShowHidden && (
<Button
@@ -119,7 +121,7 @@ export function ProfileCampaignsTab({
size="sm"
onClick={() => setShowHidden((v) => !v)}
>
{showHidden ? 'Hide hidden' : 'Show hidden'}
{showHidden ? t('profile.campaigns.hideHidden') : t('profile.campaigns.showHidden')}
</Button>
)}
</div>
+38 -29
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, Link as RouterLink } from 'react-router-dom';
import {
Bitcoin,
@@ -45,7 +46,7 @@ import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { getGeoDisplayName } from '@/lib/countries';
export interface ProfileIdentityRailProps {
interface ProfileIdentityRailProps {
pubkey: string;
/** Whether the logged-in user is viewing their own profile. */
isOwnProfile: boolean;
@@ -229,7 +230,7 @@ export function ProfileIdentityRail({
// ─── Identity header (name / bio / actions / stats) ─────────────────────────
export interface ProfileIdentityHeaderProps {
interface ProfileIdentityHeaderProps {
pubkey: string;
isOwnProfile: boolean;
metadata: NostrMetadata | undefined;
@@ -351,7 +352,7 @@ export function ProfileIdentityHeader({
// ─── Overview sections (campaigns / latest pledge / orgs / fields) ──────────
export interface ProfileOverviewSectionsProps {
interface ProfileOverviewSectionsProps {
pubkey: string;
isOwnProfile: boolean;
campaigns: ParsedCampaign[];
@@ -390,6 +391,7 @@ export function ProfileOverviewSections({
showOrganizations = true,
className,
}: ProfileOverviewSectionsProps) {
const { t } = useTranslation();
return (
<div className={cn('flex flex-col gap-5', className)}>
{/* Profile fields (rendered upstream) placed first so the
@@ -397,7 +399,7 @@ export function ProfileOverviewSections({
the first thing visitors read, ahead of campaigns/orgs. */}
{fields.length > 0 && (
<section className="space-y-3">
<RailSectionHeader icon={null} title="Profile" />
<RailSectionHeader icon={null} title={t('profile.sections.profile')} />
<div className="space-y-3">{fieldsContent}</div>
</section>
)}
@@ -448,7 +450,7 @@ export function ProfileOrganizationsSection({ pubkey, className }: { pubkey: str
// ─── Avatar block ────────────────────────────────────────────────────────────
export interface ProfileAvatarBlockProps {
interface ProfileAvatarBlockProps {
metadata: NostrMetadata | undefined;
displayName: string;
status: { text: string | undefined; url: string | undefined } | undefined;
@@ -479,7 +481,7 @@ export function ProfileAvatarBlock({
'size-28 md:size-32 border-4 border-background shadow-lg',
picture && 'cursor-pointer',
)}>
<AvatarImage src={picture} alt={displayName} />
<AvatarImage src={picture} alt={displayName} proxyWidth={256} />
<AvatarFallback className="bg-primary/20 text-primary text-3xl">
{displayName[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
@@ -534,20 +536,21 @@ function ActionBar({
onchainCampaigns: ParsedCampaign[];
onDonate: (campaign: ParsedCampaign) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2">
{isOwnProfile ? (
<>
<Link to="/settings/profile" className="flex-1 min-w-[140px]">
<Button variant="outline" className="rounded-full font-bold w-full">
Edit profile
{t('profile.header.editProfile')}
</Button>
</Link>
<Button
variant="outline"
size="icon"
className="rounded-full size-10"
title="Share follow link"
title={t('profile.header.shareFollowLink')}
onClick={onFollowQROpen}
>
<QrCode className="size-5" />
@@ -557,7 +560,7 @@ function ActionBar({
size="icon"
className="rounded-full size-10"
onClick={onMoreMenuOpen}
title="More options"
title={t('profile.header.moreOptions')}
>
<MoreHorizontal className="size-5" />
</Button>
@@ -576,14 +579,14 @@ function ActionBar({
className="rounded-full font-bold gap-1.5"
>
<HandHeart className="size-4" />
Donate
{t('profile.header.donate')}
</Button>
) : onchainCampaigns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="rounded-full font-bold gap-1.5">
<HandHeart className="size-4" />
Donate
{t('profile.header.donate')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
@@ -596,7 +599,7 @@ function ActionBar({
<span className="font-medium truncate w-full">{c.title}</span>
{c.goalUsd ? (
<span className="text-xs text-muted-foreground">
Goal ${c.goalUsd.toLocaleString()}
{t('profile.header.campaignGoal', { amount: c.goalUsd.toLocaleString() })}
</span>
) : null}
</DropdownMenuItem>
@@ -610,7 +613,7 @@ function ActionBar({
size="icon"
className="rounded-full size-10"
onClick={onMoreMenuOpen}
title="More options"
title={t('profile.header.moreOptions')}
>
<MoreHorizontal className="size-5" />
</Button>
@@ -639,6 +642,7 @@ function StatList({
onFollowingOpen: () => void;
onTabChange: (id: string) => void;
}) {
const { t } = useTranslation();
// Secondary stat rows (one per row). Followers / Following live inline
// at the top. Campaigns and Pledges are intentionally not surfaced as
// counts here — the rail's Campaigns and (when relevant) Pledges
@@ -652,7 +656,7 @@ function StatList({
}> = [
{
icon: <Bitcoin className="size-3.5 text-primary" />,
label: 'Raised',
label: t('profile.stats.raised'),
value: formatCampaignAmount(totalRaisedSats, btcPrice),
onClick: () => onTabChange('campaigns'),
show: totalRaisedSats > 0,
@@ -671,20 +675,20 @@ function StatList({
<button
onClick={onFollowersOpen}
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
title={`${followersCount} followers`}
title={t('profile.stats.followersTitle', { count: followersCount })}
>
<span className="font-bold tabular-nums text-foreground">{formatNumber(followersCount)}</span>
<span className="text-muted-foreground">Followers</span>
<span className="text-muted-foreground">{t('profile.stats.followers')}</span>
</button>
)}
{followingCount > 0 && (
<button
onClick={onFollowingOpen}
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
title={`${followingCount} following`}
title={t('profile.stats.followingTitle', { count: followingCount })}
>
<span className="font-bold tabular-nums text-foreground">{formatNumber(followingCount)}</span>
<span className="text-muted-foreground">Following</span>
<span className="text-muted-foreground">{t('profile.stats.following')}</span>
</button>
)}
</div>
@@ -725,6 +729,7 @@ function RailCampaignsSection({
isLoading: boolean;
onSeeAll: () => void;
}) {
const { t } = useTranslation();
const { data: moderation } = useCampaignModeration();
const visible = isOwnProfile
? campaigns
@@ -733,7 +738,7 @@ function RailCampaignsSection({
if (isLoading && visible.length === 0) {
return (
<section className="space-y-3">
<RailSectionHeader icon={<Megaphone className="size-4 text-primary" />} title="Campaigns" />
<RailSectionHeader icon={<Megaphone className="size-4 text-primary" />} title={t('profile.sections.campaigns')} />
<CampaignCardSkeleton />
</section>
);
@@ -748,7 +753,7 @@ function RailCampaignsSection({
<section className="space-y-3">
<RailSectionHeader
icon={<Megaphone className="size-4 text-primary" />}
title="Campaigns"
title={t('profile.sections.campaigns')}
count={visible.length}
/>
<div className="space-y-3">
@@ -762,7 +767,7 @@ function RailCampaignsSection({
onClick={onSeeAll}
className="text-sm text-primary hover:underline font-medium"
>
{more > 0 ? `See all ${visible.length} campaigns →` : 'View campaigns tab →'}
{more > 0 ? t('profile.sections.seeAllCampaigns', { count: visible.length }) : t('profile.sections.viewCampaignsTab')}
</button>
)}
</section>
@@ -788,6 +793,7 @@ function RailLatestPledgeSection({
showSeeAll: boolean;
onSeeAll: () => void;
}) {
const { t } = useTranslation();
// Pick the newest pledge by created_at. The page query is roughly
// newest-first already, but sorting here keeps the rail correct
// regardless of upstream order.
@@ -798,7 +804,7 @@ function RailLatestPledgeSection({
<section className="space-y-3">
<RailSectionHeader
icon={<HandHeart className="size-4 text-primary" />}
title="Latest pledge"
title={t('profile.sections.latestPledge')}
/>
<RailPledgeCard action={latest} btcPrice={btcPrice} />
{showSeeAll && (
@@ -807,7 +813,7 @@ function RailLatestPledgeSection({
onClick={onSeeAll}
className="text-sm text-primary hover:underline font-medium"
>
See all {pledges.length} pledges
{t('profile.sections.seeAllPledges', { count: pledges.length })}
</button>
)}
</section>
@@ -826,6 +832,7 @@ function RailPledgeCard({
action: Action;
btcPrice: number | undefined;
}) {
const { t } = useTranslation();
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
@@ -854,14 +861,14 @@ function RailPledgeCard({
variant="secondary"
className="absolute top-2 right-2 backdrop-blur bg-background/85 border-border/40 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Ended
{t('profile.badges.ended')}
</Badge>
)}
</div>
<div className="p-3 space-y-1.5">
<h3 className="font-semibold text-sm leading-snug line-clamp-2">{action.title}</h3>
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="text-muted-foreground uppercase tracking-wide font-semibold">Pledged</span>
<span className="text-muted-foreground uppercase tracking-wide font-semibold">{t('profile.badges.pledged')}</span>
<span className="text-foreground font-bold tabular-nums">
{formatPledgeAmount(action.bounty, btcPrice)}
</span>
@@ -889,12 +896,13 @@ function RailPledgeCard({
// ─── Rail Organizations Section ─────────────────────────────────────────────
function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
const { t } = useTranslation();
const { data: orgs, isLoading } = useProfileOrganizations(pubkey);
if (isLoading && orgs.length === 0) {
return (
<section className="space-y-3">
<RailSectionHeader icon={<Users className="size-4 text-primary" />} title="Groups" />
<RailSectionHeader icon={<Users className="size-4 text-primary" />} title={t('profile.sections.groups')} />
<div className="grid grid-cols-2 gap-3">
{Array.from({ length: 2 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
@@ -913,7 +921,7 @@ function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
<section className="space-y-3">
<RailSectionHeader
icon={<Users className="size-4 text-primary" />}
title="Groups"
title={t('profile.sections.groups')}
count={orgs.length}
/>
<div className="grid grid-cols-2 gap-3">
@@ -927,7 +935,7 @@ function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
type="button"
className="text-sm text-primary hover:underline font-medium"
>
See all {orgs.length}
{t('profile.sections.seeAllGroups', { count: orgs.length })}
</button>
</OrganizationsAllDialog>
)}
@@ -936,6 +944,7 @@ function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
}
function RailOrgCell({ entry }: { entry: ProfileOrganization }) {
const { t } = useTranslation();
return (
<div className="relative">
<CommunityMiniCard community={entry.community} className="w-full" />
@@ -946,7 +955,7 @@ function RailOrgCell({ entry }: { entry: ProfileOrganization }) {
entry.isFounder ? 'text-primary' : 'text-foreground',
)}
>
{entry.isFounder ? 'Founder' : 'Mod'}
{entry.isFounder ? t('profile.badges.founder') : t('profile.badges.mod')}
</Badge>
</div>
);
+10 -7
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link as RouterLink } from 'react-router-dom';
import { CalendarClock, HandHeart, MapPin } from 'lucide-react';
import { nip19 } from 'nostr-tools';
@@ -39,6 +40,7 @@ export function ProfilePledgesTab({
btcPrice,
isLoading,
}: ProfilePledgesTabProps) {
const { t } = useTranslation();
const now = Math.floor(Date.now() / 1000);
// Loading skeleton until the first list resolves.
@@ -58,15 +60,15 @@ export function ProfilePledgesTab({
<HandHeart className="size-10 mx-auto mb-3 text-muted-foreground/40" />
<p className="text-muted-foreground max-w-sm mx-auto">
{isOwnProfile
? "You haven't created a pledge yet."
: `${displayName} hasn't created a pledge yet.`}
? t('profile.pledgesTab.emptySelf')
: t('profile.pledgesTab.emptyOther', { name: displayName })}
</p>
{isOwnProfile && (
<RouterLink
to="/pledges/new"
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
>
Create a pledge
{t('profile.pledgesTab.createLink')}
</RouterLink>
)}
</div>
@@ -89,7 +91,7 @@ export function ProfilePledgesTab({
<section>
{ended.length > 0 && (
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Active
{t('profile.pledgesTab.active')}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
@@ -103,7 +105,7 @@ export function ProfilePledgesTab({
{ended.length > 0 && (
<section>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Ended
{t('profile.pledgesTab.ended')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{ended.map((pledge) => (
@@ -125,6 +127,7 @@ function ProfilePledgeCard({
isExpired?: boolean;
btcPrice: number | undefined;
}) {
const { t } = useTranslation();
const [imageLoadFailed, setImageLoadFailed] = useState(false);
const naddr = nip19.naddrEncode({
@@ -156,7 +159,7 @@ function ProfilePledgeCard({
variant="secondary"
className="absolute top-3 right-3 backdrop-blur bg-background/85 border-border/40 text-muted-foreground"
>
Ended
{t('profile.badges.ended')}
</Badge>
)}
</div>
@@ -172,7 +175,7 @@ function ProfilePledgeCard({
<div className="flex-1" />
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">Pledged</p>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">{t('profile.badges.pledged')}</p>
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
{formatPledgeAmount(action.bounty, btcPrice)}
</p>
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
export interface ProfileTabsProps {
interface ProfileTabsProps {
tabs: Array<{ id: string; label: string }>;
activeTab: string;
onChange: (id: string) => void;
+28 -4
View File
@@ -1,5 +1,6 @@
import * as React from "react"
import { useImageProxy } from "@/hooks/useImageProxy"
import { cn } from "@/lib/utils"
import { type AvatarShape, isEmoji, getAvatarMaskUrl, isValidAvatarShape } from "@/lib/avatarShape"
@@ -77,23 +78,40 @@ Avatar.displayName = "Avatar"
* Renders the <img> immediately with absolute positioning so it covers
* the fallback. No hidden Image() verification the browser renders
* the image progressively as it downloads.
*
* The `src` is routed through the configured image proxy at `proxyWidth`
* (default 96 enough for retina up to 48px display). Pass a larger value
* on the profile-header avatar (e.g. 256). On proxy failure the component
* transparently falls back to the original URL.
*/
export interface AvatarImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
/** Target width passed to the image proxy. Defaults to 96. */
proxyWidth?: number;
}
const AvatarImage = React.forwardRef<
HTMLImageElement,
React.ImgHTMLAttributes<HTMLImageElement>
>(({ className, onError, ...props }, ref) => {
AvatarImageProps
>(({ className, onError, proxyWidth = 96, ...props }, ref) => {
const [hasError, setHasError] = React.useState(false)
const [proxyFailed, setProxyFailed] = React.useState(false)
const hasSrcRef = React.useContext(AvatarHasSrcContext)
const proxy = useImageProxy()
const src = props.src
// Reset error when src changes
// Reset error and proxy-failed state when src changes
const prevSrc = React.useRef(src)
if (src !== prevSrc.current) {
prevSrc.current = src
if (hasError) setHasError(false)
if (proxyFailed) setProxyFailed(false)
}
const showImage = !hasError && !!src
const proxied = src ? proxy(src, proxyWidth) : src
const usingProxy = proxied !== src
const finalSrc = proxyFailed || !usingProxy ? src : proxied
const showImage = !hasError && !!finalSrc
// Signal to AvatarFallback synchronously during this render frame
if (showImage) {
@@ -106,9 +124,15 @@ const AvatarImage = React.forwardRef<
<img
{...props}
ref={ref}
src={finalSrc}
alt=""
className={cn("absolute inset-0 h-full w-full object-cover", className)}
onError={(e) => {
// First failure with the proxy: swap to the original URL silently.
if (usingProxy && !proxyFailed) {
setProxyFailed(true)
return
}
setHasError(true)
onError?.(e)
}}