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:
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface DetailCommentComposerProps {
|
||||
|
||||
export function DetailCommentComposer({
|
||||
event,
|
||||
placeholder = "What's on your mind?",
|
||||
placeholder,
|
||||
onSuccess,
|
||||
className,
|
||||
}: DetailCommentComposerProps) {
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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">·</span>
|
||||
<span className="truncate">{authorName}</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-11
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>⚠️</span>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
|
||||
People on Bluesky can't see you because they're not actually decentralized.
|
||||
{t('replyModal.blueskyDisclaimer')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user