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:
@@ -142,6 +142,8 @@ const hardcodedConfig: AppConfig = {
|
||||
savedFeeds: [],
|
||||
autoplayVideos: false,
|
||||
imageQuality: 'compressed',
|
||||
imageProxy: 'https://wsrv.nl',
|
||||
lowBandwidthMode: false,
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
esploraApis: [
|
||||
'https://mempool.space/api',
|
||||
|
||||
+18
-5
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { BrowserRouter, Link, Navigate, Outlet, Route, Routes } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { TopNav } from "./components/TopNav";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
@@ -8,6 +9,7 @@ import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { openUrl } from "@/lib/downloadFile";
|
||||
|
||||
// Critical-path pages: eagerly loaded (landing + fallback)
|
||||
import Index from "./pages/Index";
|
||||
@@ -27,6 +29,7 @@ const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage
|
||||
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
|
||||
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
|
||||
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
|
||||
@@ -35,6 +38,7 @@ const MyDashboardPage = lazy(() => import("./pages/MyDashboardPage").then(m => (
|
||||
const AboutPage = lazy(() => import("./pages/AboutPage").then(m => ({ default: m.AboutPage })));
|
||||
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
|
||||
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
|
||||
const LanguageSettingsPage = lazy(() => import("./pages/LanguageSettingsPage").then(m => ({ default: m.LanguageSettingsPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
|
||||
@@ -70,15 +74,22 @@ function PageSkeleton() {
|
||||
}
|
||||
|
||||
function SiteFooter() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<footer className="bg-background mt-auto pt-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openUrl("https://gitlab.com/soapbox-pub/agora")}
|
||||
className="hover:text-foreground motion-safe:transition-colors"
|
||||
>
|
||||
{t('nav.sourceCode')}
|
||||
</button>
|
||||
<nav className="flex items-center gap-5">
|
||||
<Link to="/about" className="hover:text-foreground motion-safe:transition-colors">About</Link>
|
||||
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</Link>
|
||||
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</Link>
|
||||
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">Changelog</Link>
|
||||
<Link to="/about" className="hover:text-foreground motion-safe:transition-colors">{t('nav.about')}</Link>
|
||||
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">{t('nav.privacy')}</Link>
|
||||
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">{t('nav.safety')}</Link>
|
||||
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">{t('nav.changelog')}</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -127,6 +138,7 @@ export function AppRouter() {
|
||||
<Route path="/g/:geohash" element={<GeotagPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="/settings/language" element={<LanguageSettingsPage />} />
|
||||
<Route path="/settings/profile" element={<ProfileSettings />} />
|
||||
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
|
||||
<Route path="/settings/notifications" element={<NotificationSettings />} />
|
||||
@@ -159,6 +171,7 @@ export function AppRouter() {
|
||||
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
|
||||
<Route path="/groups" element={<CommunitiesPage />} />
|
||||
<Route path="/groups/new" element={<CreateCommunityPage />} />
|
||||
<Route path="/events/new" element={<CreateEventPage />} />
|
||||
<Route path="/pledges" element={<ActionsPage />} />
|
||||
<Route path="/pledges/new" element={<CreateActionPage />} />
|
||||
<Route path="/dashboard" element={<EventDashboardPage />} />
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
_آخر تحديث: 19 مارس 2026_
|
||||
|
||||
## التزامنا
|
||||
|
||||
ينتهج {{appName}} سياسة **عدم تسامح إطلاقًا** تجاه مواد الإساءة الجنسية للأطفال واستغلالهم (CSAE). إن سلامة الأطفال أمر بالغ الأهمية، ونحن ملتزمون بفعل كل ما في وسعنا، بوصفنا تطبيق عميل، لمنع توزيع محتوى CSAE أو الترويج له أو تسهيله عبر تطبيقنا.
|
||||
|
||||
تنطبق هذه السياسة على جميع المحتويات التي يمكن الوصول إليها عبر {{appName}}، بما في ذلك النصوص والصور ومقاطع الفيديو والروابط وأي وسائط أخرى. وتشمل جميع أشكال CSAE، بما في ذلك على سبيل المثال لا الحصر الصور والاستدراج والاستجداء والاتجار وتجنيس القاصرين.
|
||||
|
||||
## كيف يعمل {{appName}}
|
||||
|
||||
{{appName}} هو **تطبيق عميل** لبروتوكول Nostr، وهو شبكة اتصالات مفتوحة ولامركزية. فهم البنية المعمارية سياق مهم لهذه السياسة:
|
||||
|
||||
- **بنيتنا التحتية:** نُشغّل **مرحّل Agora** و**خادم Agora Blossom**، اللذين يُستخدمان كمرحّل ومستضيف للملفات الافتراضي في {{appName}}. ولدينا تحكم كامل في الإشراف على المحتوى المُخزَّن على هذه الخدمات.
|
||||
- **المرحّلات التابعة لأطراف ثالثة:** قد يتصل المستخدمون أيضًا بمرحّلات Nostr إضافية تُشغّلها أطراف ثالثة مستقلة. ويقوم {{appName}} بجلب المحتوى وعرضه من أي مرحّلات يتصل بها المستخدم. لا نملك تحكمًا إشرافيًا على المرحّلات التابعة لأطراف ثالثة، لكننا نتحكم فيما يعرضه التطبيق.
|
||||
- **خوادم الوسائط التابعة لأطراف ثالثة:** قد يرفع المستخدمون الصور ومقاطع الفيديو إلى خوادم ملفات متوافقة مع Blossom تابعة لأطراف ثالثة. ولا نُشغّل هذه الخدمات الخارجية ولا نُشرف عليها.
|
||||
|
||||
نتحمل المسؤولية الكاملة عن التجربة داخل تطبيقنا. وعلى بنيتنا التحتية الخاصة (مرحّل Agora وخادم Agora Blossom) يمكننا إزالة المحتوى وحظر الحسابات المخالفة مباشرة. أما المحتوى الذي مصدره خدمات تابعة لأطراف ثالثة، فنُكافحه بنشاط ونمنع عرضه داخل {{appName}}.
|
||||
|
||||
## المحتوى والسلوكيات المحظورة
|
||||
|
||||
يُحظر تمامًا ما يلي على {{appName}}. وأي مستخدم يثبت تورطه في أي مما يلي سيكون عرضة لإجراء فوري:
|
||||
|
||||
- **CSAM (مواد الإساءة الجنسية للأطفال):** أي تصوير مرئي لسلوك جنسي صريح يشمل قاصرًا، بما في ذلك الصور الفوتوغرافية ومقاطع الفيديو والصور المُولَّدة رقميًا أو بالذكاء الاصطناعي.
|
||||
- **الاستدراج:** أي محاولة لبناء علاقة مع قاصر بغرض الاستغلال أو الإساءة الجنسية.
|
||||
- **الاستجداء:** طلب أو عرض أو تسهيل تبادل مواد CSAE أو الاتصال الجنسي مع القاصرين.
|
||||
- **تجنيس القاصرين:** المحتوى الذي يُجنسن القاصرين، بما في ذلك التعليقات الإيحائية أو الجنسية عن الأطفال، حتى لو لم يتضمن صورًا صريحة.
|
||||
- **الاتجار:** أي محتوى يُسهّل أو يروّج أو ينسّق للاتجار بالقاصرين لأغراض جنسية.
|
||||
- **الروابط والإحالات:** مشاركة روابط لمواقع أو موارد خارجية تحوي مواد CSAE، أو تقديم تعليمات حول كيفية العثور على هذه المواد أو إنتاجها.
|
||||
|
||||
## الرصد والوقاية
|
||||
|
||||
يُطبّق {{appName}} طبقات حماية متعددة لمكافحة CSAE:
|
||||
|
||||
- **تصفية المحتوى:** نحتفظ بآليات تصفية محتوى داخل التطبيق ونُلزم بها، لمنع عرض مواد CSAE المعروفة بصرف النظر عن المرحّل الذي تأتي منه.
|
||||
- **بلاغات المستخدمين:** نوفر أدوات إبلاغ داخل التطبيق تسمح للمستخدمين بالإشارة إلى المحتوى المشتبه به للمراجعة الفورية.
|
||||
- **الإشراف على مرحّل Agora:** على مرحّلنا الخاص Agora، نُشرف بنشاط على المحتوى وسنزيل فورًا أي مواد CSAE ونحظر الحسابات المرتبطة بها نهائيًا.
|
||||
- **الإشراف على خادم Agora Blossom:** على خادم ملفات Agora Blossom الخاص بنا، سنحذف فورًا أي وسائط CSAE ونحظر الحساب الذي قام بالرفع.
|
||||
- **حجب المرحّلات التابعة لأطراف ثالثة:** يجوز إزالة المرحّلات التابعة لأطراف ثالثة المعروفة باستضافة مواد CSAE أو التساهل معها من قائمة المرحّلات الافتراضية في {{appName}} ومنع المستخدمين من إضافتها.
|
||||
- **أدوات الكتم والحجب:** يمكن للمستخدمين كتم الحسابات أو حجبها على مستوى العميل لمنع ظهور محتوى تلك الحسابات في خلاصاتهم.
|
||||
|
||||
## إجراءات التنفيذ
|
||||
|
||||
عند تحديد محتوى CSAE أو سلوك مرتبط به، سيتخذ {{appName}} الإجراءات التالية حسب الاقتضاء:
|
||||
|
||||
- **حجب المحتوى الفوري:** سيتم حجب محتوى CSAE المعروف من العرض في التطبيق عبر مرشحات المحتوى وقوائم الحظر.
|
||||
- **الإزالة من بنية Agora التحتية:** سيُحذف محتوى CSAE الموجود على مرحّل Agora وخادم Agora Blossom فورًا، وستُحظر الحسابات المرتبطة بشكل دائم.
|
||||
- **حظر الحسابات:** ستُضاف مفاتيح Nostr العامة المرتبطة بنشاط CSAE إلى قوائم الحظر على مستوى التطبيق، مما يمنع ظهور محتواها في {{appName}} بغض النظر عن المرحّل الذي يجلبها.
|
||||
- **حظر المرحّلات:** قد تتم إزالة المرحّلات التابعة لأطراف ثالثة التي لا تعالج محتوى CSAE من قائمة المرحّلات الافتراضية في {{appName}}، وحظر المستخدمين من إضافتها.
|
||||
- **الإبلاغ للسلطات:** سنُبلّغ عن مواد CSAE التي يتم تحديدها إلى [المركز الوطني للأطفال المفقودين والمستغلين (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) عبر CyberTipline، وإلى أجهزة إنفاذ القانون المختصة.
|
||||
|
||||
## الإبلاغ عن محتوى CSAE
|
||||
|
||||
إذا واجهتَ أي محتوى على {{appName}} ترى أنه يشكّل إساءة جنسية للأطفال أو استغلالًا لهم، فيرجى الإبلاغ عنه على الفور:
|
||||
|
||||
- **الإبلاغ داخل التطبيق:** استخدم زر الإبلاغ المتاح على أي منشور أو ملف شخصي للإشارة إلى المحتوى لمراجعته.
|
||||
- **التواصل المباشر معنا:** تواصل مع فريقنا عبر [soapbox.pub](https://soapbox.pub) مع تفاصيل المحتوى، بما في ذلك أي معرّفات أحداث Nostr أو مفاتيح عامة ذات صلة.
|
||||
- **الإبلاغ لـ NCMEC:** يمكنك أيضًا تقديم بلاغ مباشرة عبر [خط NCMEC CyberTipline](https://www.missingkids.org/gethelpnow/cybertipline).
|
||||
- **الاتصال بإنفاذ القانون:** إذا كنت تعتقد أن طفلًا في خطر وشيك، فاتصل بجهات إنفاذ القانون المحلية لديك أو اتصل بالرقم **911** (الولايات المتحدة) فورًا.
|
||||
|
||||
تُعامَل جميع البلاغات المتعلقة بمحتوى CSAE بأعلى أولوية وستُراجَع في أسرع وقت ممكن.
|
||||
|
||||
## التعاون مع جهات إنفاذ القانون
|
||||
|
||||
يلتزم {{appName}} بالتعاون التام مع أجهزة إنفاذ القانون التي تحقق في حالات CSAE. وعلى الرغم من أن {{appName}} لا يُخزّن محتوى المستخدمين على خوادمه الخاصة، فإننا سنقوم بما يلي:
|
||||
|
||||
- تقديم أي معلومات متاحة لدينا — بما في ذلك بيانات من مرحّل Agora وخادم Agora Blossom — قد تساعد في التحقيقات، وفقًا للقانون المعمول به.
|
||||
- تحديد ومشاركة عناوين URL المحددة للمرحّلات وخوادم الملفات حيث رُصد المحتوى المخالف، حتى تتمكن جهات إنفاذ القانون من التواصل مع المشغّلين مباشرة.
|
||||
- الحفاظ على أي أدلة أو معلومات متاحة عند استلام طلب قانوني صحيح.
|
||||
- الإبلاغ بشكل استباقي عن مواد CSAE التي يتم تحديدها إلى NCMEC والجهات المعنية الأخرى.
|
||||
|
||||
## اعتبارات البنية اللامركزية
|
||||
|
||||
تعني الطبيعة اللامركزية لـ Nostr أنه لا توجد جهة واحدة تمتلك تحكمًا كاملًا في كل المحتوى الموجود على الشبكة. ويُقرّ {{appName}} بالحقائق التالية ونهجنا تجاه كل منها:
|
||||
|
||||
- **تحكم كامل في بنيتنا التحتية الخاصة:** يمكننا — ونقوم بالفعل — إزالة المحتوى من مرحّل Agora وخادم Agora Blossom. وتُحذف مواد CSAE المكتشفة على بنيتنا التحتية فورًا، وتُحظر الحسابات بشكل دائم.
|
||||
- **تحكم محدود في المرحّلات التابعة لأطراف ثالثة:** لا يمكننا حذف المحتوى من المرحّلات التابعة لأطراف ثالثة. ومع ذلك، نحجب عرض هذا المحتوى داخل تطبيقنا عبر مرشحات وقوائم حظر على مستوى العميل.
|
||||
- **يتحكم المستخدمون باتصالاتهم بالمرحّلات:** بينما يستطيع المستخدمون الاتصال بالمرحّلات التي يختارونها، يحتفظ {{appName}} بالحق في حجب الاتصالات بالمرحّلات المعروفة باستضافة محتوى CSAE.
|
||||
- **المفاتيح العامة مستعارة:** تُحدَّد حسابات Nostr بأزواج مفاتيح تشفيرية بدلاً من هويات موثقة. ومع ذلك، سنواصل حظر المفاتيح المخالفة والإبلاغ عنها والتعاون مع جهات إنفاذ القانون لتحديد الأفراد الذين يقفون وراءها.
|
||||
|
||||
## الاستئناف
|
||||
|
||||
إذا كنت تعتقد أن محتواك أو حسابك قد تم وضع علامة عليه أو حجبه بشكل غير صحيح بموجب هذه السياسة، فيمكنك التواصل معنا عبر [soapbox.pub](https://soapbox.pub) لطلب مراجعة. وسنقيم الاستئنافات على أساس كل حالة على حدة. غير أننا نُغلّب جانب سلامة الطفل في جميع القرارات، وقرارنا نهائي.
|
||||
|
||||
## التغييرات على هذه السياسة
|
||||
|
||||
قد نُحدّث سياسة سلامة الأطفال هذه مع تطور أدواتنا وعملياتنا ومنظومة Nostr. وستنعكس التغييرات على هذه الصفحة مع تحديث التاريخ. ونحن ملتزمون بالتحسين المستمر لقدرتنا على رصد محتوى CSAE والوقاية منه والتصدي له.
|
||||
|
||||
## التواصل
|
||||
|
||||
للاستفسار عن هذه السياسة أو للإبلاغ عن محتوى CSAE، تواصل مع الفريق المسؤول عن {{appName}} عبر [soapbox.pub](https://soapbox.pub).
|
||||
@@ -0,0 +1,90 @@
|
||||
_Last updated: March 19, 2026_
|
||||
|
||||
## Our Commitment
|
||||
|
||||
{{appName}} has a **zero-tolerance policy** toward child sexual abuse and exploitation (CSAE) material. The safety of children is paramount, and we are committed to doing everything within our power as a client application to prevent the distribution, promotion, or facilitation of CSAE content through our app.
|
||||
|
||||
This policy applies to all content accessible through {{appName}}, including text, images, videos, links, and any other media. It covers all forms of CSAE, including but not limited to imagery, solicitation, grooming, trafficking, and the sexualization of minors.
|
||||
|
||||
## How {{appName}} Works
|
||||
|
||||
{{appName}} is a **client application** for the Nostr protocol, an open, decentralized communication network. Understanding the architecture is important context for this policy:
|
||||
|
||||
- **Our infrastructure:** We operate the **Agora relay** and **Agora Blossom server**, which serve as the default relay and file host for {{appName}}. We have full moderation control over content stored on these services.
|
||||
- **Third-party relays:** Users may also connect to additional Nostr relays operated by independent third parties. {{appName}} fetches and renders content from whatever relays the user is connected to. We do not have moderation control over third-party relays, but we control what the app displays.
|
||||
- **Third-party media servers:** Users may upload images and videos to third-party Blossom-compatible file servers. We do not operate or moderate these external services.
|
||||
|
||||
We take full responsibility for the experience within our app. On our own infrastructure (Agora relay and Agora Blossom server), we can directly remove content and ban offending accounts. For content originating from third-party services, we actively block it from being displayed within {{appName}}.
|
||||
|
||||
## Prohibited Content and Behavior
|
||||
|
||||
The following is strictly prohibited on {{appName}}. Users found engaging in any of the following will be subject to immediate action:
|
||||
|
||||
- **CSAM (Child Sexual Abuse Material):** Any visual depiction of sexually explicit conduct involving a minor, including photographs, videos, and digitally or AI-generated images.
|
||||
- **Grooming:** Any attempt to build a relationship with a minor for the purpose of sexual exploitation or abuse.
|
||||
- **Solicitation:** Requesting, offering, or facilitating the exchange of CSAE material or sexual contact with minors.
|
||||
- **Sexualization of minors:** Content that sexualizes minors, including suggestive or sexual commentary about children, even if no explicit imagery is involved.
|
||||
- **Trafficking:** Any content that facilitates, promotes, or coordinates the trafficking of minors for sexual purposes.
|
||||
- **Links and references:** Sharing links to external sites or resources containing CSAE material, or providing instructions on how to find or produce such material.
|
||||
|
||||
## Detection and Prevention
|
||||
|
||||
{{appName}} implements multiple layers of protection to combat CSAE:
|
||||
|
||||
- **Content filtering:** We maintain and enforce content filtering mechanisms within the app to block known CSAE material from being displayed, regardless of which relay it originates from.
|
||||
- **User reporting:** We provide in-app reporting tools that allow users to flag suspected CSAE content for immediate review.
|
||||
- **Agora relay moderation:** On our own Agora relay, we actively moderate content and will immediately remove any CSAE material and permanently ban associated accounts.
|
||||
- **Agora Blossom server moderation:** On our own Agora Blossom file server, we will immediately delete any CSAE media and ban the uploading account.
|
||||
- **Third-party relay blocking:** Third-party relays known to host or tolerate CSAE material may be removed from {{appName}}'s default relay list and blocked from being added by users.
|
||||
- **Mute and block tools:** Users can mute or block accounts at the client level, preventing content from those accounts from appearing in their feed.
|
||||
|
||||
## Enforcement Actions
|
||||
|
||||
When CSAE content or behavior is identified, {{appName}} will take the following actions as applicable:
|
||||
|
||||
- **Immediate content blocking:** Known CSAE content will be blocked from rendering in the app through content filters and blocklists.
|
||||
- **Removal from Agora infrastructure:** CSAE content on the Agora relay and Agora Blossom server will be immediately deleted, and the associated accounts permanently banned.
|
||||
- **Account blocking:** Nostr public keys associated with CSAE activity will be added to app-level blocklists, preventing their content from appearing in {{appName}} regardless of which relay it is fetched from.
|
||||
- **Relay blocking:** Third-party relays that fail to address CSAE content may be removed from {{appName}}'s default relay list and blocked from being added by users.
|
||||
- **Reporting to authorities:** We will report identified CSAE material to the [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) via the CyberTipline, and to applicable law enforcement agencies.
|
||||
|
||||
## Reporting CSAE Content
|
||||
|
||||
If you encounter any content on {{appName}} that you believe constitutes child sexual abuse or exploitation, please report it immediately:
|
||||
|
||||
- **In-app reporting:** Use the report button available on any post or user profile to flag content for review.
|
||||
- **Contact us directly:** Reach out to our team at [soapbox.pub](https://soapbox.pub) with details of the content, including any relevant Nostr event IDs or public keys.
|
||||
- **Report to NCMEC:** You can also file a report directly with the [NCMEC CyberTipline](https://www.missingkids.org/gethelpnow/cybertipline).
|
||||
- **Contact law enforcement:** If you believe a child is in immediate danger, contact your local law enforcement or call **911** (US) immediately.
|
||||
|
||||
All reports of CSAE content are treated with the highest priority and will be reviewed as quickly as possible.
|
||||
|
||||
## Cooperation with Law Enforcement
|
||||
|
||||
{{appName}} is committed to cooperating fully with law enforcement agencies investigating CSAE. While {{appName}} does not store user content on its own servers, we will:
|
||||
|
||||
- Provide any information available to us — including data from the Agora relay and Agora Blossom server — that may assist in investigations, in accordance with applicable law.
|
||||
- Identify and share the specific relay URLs and file server URLs where offending content was observed, so law enforcement can contact those operators directly.
|
||||
- Preserve any available evidence or information upon receiving a valid legal request.
|
||||
- Report identified CSAE material to NCMEC and other relevant authorities proactively.
|
||||
|
||||
## Decentralized Architecture Considerations
|
||||
|
||||
Nostr's decentralized nature means that no single entity has complete control over all content on the network. {{appName}} acknowledges the following realities and our approach to each:
|
||||
|
||||
- **Full control over our own infrastructure:** We can and do remove content from the Agora relay and Agora Blossom server. CSAE material found on our infrastructure is deleted immediately and accounts are permanently banned.
|
||||
- **Limited control over third-party relays:** We cannot delete content from third-party relays. However, we block such content from being displayed within our app through client-level filters and blocklists.
|
||||
- **Users control their relay connections:** While users can connect to relays of their choice, {{appName}} reserves the right to block connections to relays known to host CSAE content.
|
||||
- **Public keys are pseudonymous:** Nostr accounts are identified by cryptographic key pairs rather than verified identities. We will still block and report offending keys and cooperate with law enforcement to identify individuals behind them.
|
||||
|
||||
## Appeals
|
||||
|
||||
If you believe your content or account has been incorrectly flagged or blocked under this policy, you may contact us at [soapbox.pub](https://soapbox.pub) to request a review. We will evaluate appeals on a case-by-case basis. However, we err on the side of child safety in all decisions, and our determination is final.
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
We may update this child safety policy as our tools, processes, and the Nostr ecosystem evolve. Changes will be reflected on this page with an updated date. We are committed to continuously improving our ability to detect, prevent, and respond to CSAE content.
|
||||
|
||||
## Contact
|
||||
|
||||
For questions about this policy or to report CSAE content, contact the team behind {{appName}} at [soapbox.pub](https://soapbox.pub).
|
||||
@@ -0,0 +1,90 @@
|
||||
_Última actualización: 19 de marzo de 2026_
|
||||
|
||||
## Nuestro compromiso
|
||||
|
||||
{{appName}} mantiene una política de **tolerancia cero** frente al material de abuso y explotación sexual infantil (CSAE, por sus siglas en inglés). La seguridad de la infancia es primordial, y como aplicación cliente nos comprometemos a hacer todo lo que esté en nuestra mano para evitar la distribución, promoción o facilitación de contenido CSAE a través de nuestra aplicación.
|
||||
|
||||
Esta política se aplica a todo el contenido accesible a través de {{appName}}, incluidos textos, imágenes, vídeos, enlaces y cualquier otro medio. Abarca todas las formas de CSAE, incluidas, entre otras, imágenes, solicitudes, grooming, trata y sexualización de menores.
|
||||
|
||||
## Cómo funciona {{appName}}
|
||||
|
||||
{{appName}} es una **aplicación cliente** para el protocolo Nostr, una red de comunicación abierta y descentralizada. Comprender la arquitectura es un contexto importante para esta política:
|
||||
|
||||
- **Nuestra infraestructura:** Operamos el **relé Agora** y el **servidor Agora Blossom**, que sirven como relé y servidor de archivos predeterminados para {{appName}}. Tenemos control de moderación total sobre el contenido almacenado en estos servicios.
|
||||
- **Relés de terceros:** Los usuarios también pueden conectarse a otros relés Nostr operados por terceros independientes. {{appName}} obtiene y muestra contenido desde cualquier relé al que el usuario esté conectado. No tenemos control de moderación sobre los relés de terceros, pero sí controlamos lo que la aplicación muestra.
|
||||
- **Servidores de medios de terceros:** Los usuarios pueden subir imágenes y vídeos a servidores de archivos de terceros compatibles con Blossom. No operamos ni moderamos estos servicios externos.
|
||||
|
||||
Asumimos plena responsabilidad por la experiencia dentro de nuestra aplicación. En nuestra propia infraestructura (relé Agora y servidor Agora Blossom), podemos eliminar contenido y vetar las cuentas infractoras directamente. Para el contenido procedente de servicios de terceros, bloqueamos activamente su visualización dentro de {{appName}}.
|
||||
|
||||
## Contenido y comportamiento prohibidos
|
||||
|
||||
Lo siguiente está estrictamente prohibido en {{appName}}. Las personas usuarias que incurran en cualquiera de los siguientes comportamientos serán objeto de acción inmediata:
|
||||
|
||||
- **CSAM (material de abuso sexual infantil):** Cualquier representación visual de conducta sexual explícita que involucre a una persona menor de edad, incluidas fotografías, vídeos e imágenes generadas digitalmente o por IA.
|
||||
- **Grooming:** Cualquier intento de establecer una relación con una persona menor de edad con fines de explotación o abuso sexual.
|
||||
- **Solicitud:** Solicitar, ofrecer o facilitar el intercambio de material CSAE o el contacto sexual con menores.
|
||||
- **Sexualización de menores:** Contenido que sexualice a menores, incluidos comentarios sugerentes o sexuales sobre infantes, aun cuando no haya imágenes explícitas.
|
||||
- **Trata:** Cualquier contenido que facilite, promueva o coordine la trata de menores con fines sexuales.
|
||||
- **Enlaces y referencias:** Compartir enlaces a sitios o recursos externos que contengan material CSAE, o proporcionar instrucciones para encontrar o producir dicho material.
|
||||
|
||||
## Detección y prevención
|
||||
|
||||
{{appName}} implementa múltiples capas de protección para combatir el CSAE:
|
||||
|
||||
- **Filtrado de contenido:** Mantenemos y aplicamos mecanismos de filtrado dentro de la aplicación para bloquear la visualización de material CSAE conocido, independientemente del relé del que provenga.
|
||||
- **Reporte de usuarios:** Ofrecemos herramientas de reporte dentro de la aplicación que permiten a las personas usuarias señalar contenido sospechoso de CSAE para una revisión inmediata.
|
||||
- **Moderación del relé Agora:** En nuestro propio relé Agora, moderamos activamente el contenido y eliminaremos de inmediato cualquier material CSAE, vetando permanentemente las cuentas asociadas.
|
||||
- **Moderación del servidor Agora Blossom:** En nuestro propio servidor de archivos Agora Blossom, eliminaremos de inmediato cualquier medio CSAE y vetaremos la cuenta que lo haya subido.
|
||||
- **Bloqueo de relés de terceros:** Los relés de terceros que se sepa que alojan o toleran material CSAE pueden ser eliminados de la lista de relés predeterminada de {{appName}} y bloqueados para que los usuarios no puedan añadirlos.
|
||||
- **Herramientas de silencio y bloqueo:** Las personas usuarias pueden silenciar o bloquear cuentas a nivel de cliente, evitando que el contenido de esas cuentas aparezca en su feed.
|
||||
|
||||
## Acciones de cumplimiento
|
||||
|
||||
Cuando se identifica contenido o comportamiento de tipo CSAE, {{appName}} tomará las siguientes acciones según corresponda:
|
||||
|
||||
- **Bloqueo inmediato de contenido:** El contenido CSAE conocido será bloqueado para que no se renderice en la aplicación mediante filtros y listas de bloqueo.
|
||||
- **Eliminación de la infraestructura de Agora:** El contenido CSAE en el relé Agora y en el servidor Agora Blossom será eliminado de inmediato, y las cuentas asociadas, vetadas permanentemente.
|
||||
- **Bloqueo de cuentas:** Las claves públicas de Nostr asociadas a actividad CSAE se añadirán a las listas de bloqueo a nivel de aplicación, evitando que su contenido aparezca en {{appName}} independientemente del relé desde el que se obtenga.
|
||||
- **Bloqueo de relés:** Los relés de terceros que no aborden el contenido CSAE pueden ser eliminados de la lista de relés predeterminada de {{appName}} y bloqueados para que los usuarios no puedan añadirlos.
|
||||
- **Reporte a las autoridades:** Reportaremos el material CSAE identificado al [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) a través de CyberTipline, y a las agencias de aplicación de la ley pertinentes.
|
||||
|
||||
## Cómo reportar contenido CSAE
|
||||
|
||||
Si encuentra cualquier contenido en {{appName}} que considere que constituye abuso o explotación sexual infantil, por favor repórtelo de inmediato:
|
||||
|
||||
- **Reporte dentro de la app:** Use el botón de reporte disponible en cualquier publicación o perfil de usuario para señalar el contenido para revisión.
|
||||
- **Contáctenos directamente:** Comuníquese con nuestro equipo en [soapbox.pub](https://soapbox.pub) con los detalles del contenido, incluidos cualesquiera identificadores de eventos de Nostr o claves públicas relevantes.
|
||||
- **Reporte a NCMEC:** También puede presentar un reporte directamente en la [línea CyberTipline de NCMEC](https://www.missingkids.org/gethelpnow/cybertipline).
|
||||
- **Contacte a las autoridades:** Si cree que una persona menor está en peligro inminente, comuníquese con la policía local o llame al **911** (EE. UU.) de inmediato.
|
||||
|
||||
Todos los reportes de contenido CSAE se tratan con la máxima prioridad y se revisarán lo antes posible.
|
||||
|
||||
## Cooperación con las autoridades
|
||||
|
||||
{{appName}} se compromete a cooperar plenamente con las autoridades que investiguen casos de CSAE. Aunque {{appName}} no almacena contenido de personas usuarias en sus propios servidores, haremos lo siguiente:
|
||||
|
||||
- Proporcionar cualquier información de la que dispongamos —incluidos datos del relé Agora y del servidor Agora Blossom— que pueda asistir en las investigaciones, de conformidad con la ley aplicable.
|
||||
- Identificar y compartir las URL específicas de los relés y de los servidores de archivos donde se observó el contenido infractor, para que las autoridades puedan contactar directamente con esos operadores.
|
||||
- Preservar cualquier evidencia o información disponible al recibir un requerimiento legal válido.
|
||||
- Reportar de manera proactiva el material CSAE identificado a NCMEC y a otras autoridades pertinentes.
|
||||
|
||||
## Consideraciones sobre la arquitectura descentralizada
|
||||
|
||||
La naturaleza descentralizada de Nostr implica que ninguna entidad tiene control completo sobre todo el contenido de la red. {{appName}} reconoce las siguientes realidades y nuestro enfoque ante cada una:
|
||||
|
||||
- **Control total sobre nuestra propia infraestructura:** Podemos eliminar —y eliminamos— contenido del relé Agora y del servidor Agora Blossom. El material CSAE detectado en nuestra infraestructura se elimina de inmediato y las cuentas son vetadas permanentemente.
|
||||
- **Control limitado sobre relés de terceros:** No podemos eliminar contenido de relés de terceros. Sin embargo, bloqueamos la visualización de dicho contenido dentro de nuestra aplicación mediante filtros y listas de bloqueo a nivel de cliente.
|
||||
- **Las personas usuarias controlan sus conexiones a relés:** Aunque las personas usuarias pueden conectarse a los relés que elijan, {{appName}} se reserva el derecho de bloquear las conexiones a relés que se sepa que alojan contenido CSAE.
|
||||
- **Las claves públicas son seudónimas:** Las cuentas de Nostr se identifican mediante pares de claves criptográficas en lugar de identidades verificadas. Aun así, bloquearemos y reportaremos las claves infractoras y cooperaremos con las autoridades para identificar a las personas detrás de ellas.
|
||||
|
||||
## Apelaciones
|
||||
|
||||
Si cree que su contenido o cuenta ha sido marcado o bloqueado incorrectamente en virtud de esta política, puede ponerse en contacto con nosotros en [soapbox.pub](https://soapbox.pub) para solicitar una revisión. Evaluaremos las apelaciones caso por caso. No obstante, en todas las decisiones priorizamos la seguridad infantil, y nuestra determinación es final.
|
||||
|
||||
## Cambios en esta política
|
||||
|
||||
Podemos actualizar esta política de seguridad infantil a medida que evolucionan nuestras herramientas, procesos y el ecosistema Nostr. Los cambios se reflejarán en esta página con una fecha actualizada. Estamos comprometidos con la mejora continua de nuestra capacidad para detectar, prevenir y responder al contenido CSAE.
|
||||
|
||||
## Contacto
|
||||
|
||||
Para cuestiones relativas a esta política o para reportar contenido CSAE, contacte al equipo detrás de {{appName}} en [soapbox.pub](https://soapbox.pub).
|
||||
@@ -0,0 +1,90 @@
|
||||
_آخرین بهروزرسانی: ۱۹ مارس ۲۰۲۶_
|
||||
|
||||
## تعهد ما
|
||||
|
||||
{{appName}} نسبت به مواد سوءاستفاده و بهرهکشی جنسی از کودکان (CSAE) **سیاست تحمل صفر** دارد. ایمنی کودکان از اهمیت بالایی برخوردار است و ما به عنوان یک برنامهٔ کلاینت متعهدیم تا حد توان خود از توزیع، تبلیغ یا تسهیل محتوای CSAE از طریق برنامهٔ خود جلوگیری کنیم.
|
||||
|
||||
این سیاست شامل تمام محتوای قابل دسترسی از طریق {{appName}} میشود، از جمله متن، تصویر، ویدیو، لینک و هرگونه رسانهٔ دیگر. این سیاست تمامی اشکال CSAE را پوشش میدهد، از جمله — اما نه محدود به — تصاویر، تقاضا، دلربایی (grooming)، قاچاق و جنسیسازی خردسالان.
|
||||
|
||||
## نحوهٔ کار {{appName}}
|
||||
|
||||
{{appName}} یک **برنامهٔ کلاینت** برای پروتکل Nostr است، شبکهای ارتباطی باز و غیرمتمرکز. درک معماری آن، زمینهٔ مهمی برای این سیاست است:
|
||||
|
||||
- **زیرساخت ما:** ما **رلهٔ Agora** و **سرور Agora Blossom** را به عنوان رله و میزبان فایل پیشفرض {{appName}} اداره میکنیم. کنترل کاملی بر مدیریت محتوای ذخیرهشده در این سرویسها داریم.
|
||||
- **رلههای اشخاص ثالث:** کاربران میتوانند به رلههای Nostr دیگری که توسط اشخاص ثالث مستقل اداره میشوند نیز متصل شوند. {{appName}} محتوا را از هر رلهای که کاربر به آن متصل است دریافت و نمایش میدهد. ما کنترل مدیریت بر رلههای اشخاص ثالث نداریم، اما آنچه برنامه نمایش میدهد را کنترل میکنیم.
|
||||
- **سرورهای رسانهای اشخاص ثالث:** کاربران ممکن است تصاویر و ویدیوها را در سرورهای فایل سازگار با Blossom که توسط اشخاص ثالث اداره میشوند بارگذاری کنند. ما این سرویسهای خارجی را اداره یا مدیریت نمیکنیم.
|
||||
|
||||
ما مسئولیت کامل تجربهٔ کاربر در درون برنامهٔ خود را میپذیریم. در زیرساختهای خودمان (رلهٔ Agora و سرور Agora Blossom) میتوانیم محتوا را بهطور مستقیم حذف و حسابهای متخلف را مسدود کنیم. برای محتوایی که از سرویسهای اشخاص ثالث میآید، فعالانه نمایش آن در {{appName}} را مسدود میکنیم.
|
||||
|
||||
## محتوا و رفتارهای ممنوع
|
||||
|
||||
موارد زیر در {{appName}} اکیداً ممنوع است. کاربرانی که در هر یک از این موارد درگیر باشند، فوراً مشمول اقدام خواهند شد:
|
||||
|
||||
- **CSAM (مواد سوءاستفادهٔ جنسی از کودکان):** هرگونه تصویر صریح جنسی از رفتارهای دربردارندهٔ خردسال، شامل عکس، ویدیو و تصاویر تولیدشده بهصورت دیجیتال یا با هوش مصنوعی.
|
||||
- **دلربایی (Grooming):** هر تلاش برای ایجاد رابطه با خردسال بهقصد بهرهکشی یا سوءاستفادهٔ جنسی.
|
||||
- **تقاضا:** درخواست، ارائه یا تسهیل تبادل مواد CSAE یا تماس جنسی با خردسالان.
|
||||
- **جنسیسازی خردسالان:** محتوایی که خردسالان را جنسیسازی میکند، از جمله توضیحات تحریکآمیز یا جنسی دربارهٔ کودکان، حتی اگر هیچ تصویر صریحی در میان نباشد.
|
||||
- **قاچاق:** هرگونه محتوایی که قاچاق خردسالان برای اهداف جنسی را تسهیل، تبلیغ یا هماهنگ کند.
|
||||
- **لینکها و ارجاعات:** اشتراکگذاری لینک به سایتها یا منابع خارجی حاوی مواد CSAE، یا ارائهٔ دستورالعمل دربارهٔ نحوهٔ یافتن یا تولید چنین موادی.
|
||||
|
||||
## شناسایی و پیشگیری
|
||||
|
||||
{{appName}} لایههای متعددی از حفاظت را برای مبارزه با CSAE پیادهسازی میکند:
|
||||
|
||||
- **فیلتر محتوا:** ما سازوکارهای فیلتر محتوا را در درون برنامه نگهداری و اعمال میکنیم تا از نمایش مواد CSAE شناختهشده، صرف نظر از اینکه از کدام رله بیایند، جلوگیری شود.
|
||||
- **گزارشدهی کاربران:** ابزارهای گزارشدهی درونبرنامهای ارائه میدهیم که به کاربران امکان میدهد محتوای مشکوک به CSAE را برای بازبینی فوری علامتگذاری کنند.
|
||||
- **مدیریت رلهٔ Agora:** در رلهٔ Agora متعلق به خودمان، محتوا را فعالانه مدیریت میکنیم و هرگونه مواد CSAE را فوراً حذف کرده و حسابهای مرتبط را برای همیشه مسدود خواهیم کرد.
|
||||
- **مدیریت سرور Agora Blossom:** در سرور فایل Agora Blossom متعلق به خودمان، هرگونه رسانهٔ CSAE را فوراً حذف کرده و حساب بارگذاریکننده را مسدود خواهیم کرد.
|
||||
- **مسدودسازی رلههای اشخاص ثالث:** رلههای اشخاص ثالث که میزبانی مواد CSAE یا تحمل آنها از سویشان شناخته شده باشد، ممکن است از فهرست رلههای پیشفرض {{appName}} حذف شوند و کاربران از افزودن آنها منع شوند.
|
||||
- **ابزارهای بیصدا کردن و مسدودسازی:** کاربران میتوانند حسابها را در سطح کلاینت بیصدا کنند یا مسدود نمایند تا محتوای آن حسابها در خوراک آنها ظاهر نشود.
|
||||
|
||||
## اقدامات اجرایی
|
||||
|
||||
هنگام شناسایی محتوای CSAE یا رفتار مرتبط، {{appName}} حسب مورد اقدامات زیر را انجام خواهد داد:
|
||||
|
||||
- **مسدودسازی فوری محتوا:** محتوای CSAE شناختهشده از طریق فیلترها و فهرستهای مسدودسازی، در برنامه نمایش داده نخواهد شد.
|
||||
- **حذف از زیرساخت Agora:** محتوای CSAE موجود در رلهٔ Agora و سرور Agora Blossom فوراً حذف خواهد شد و حسابهای مرتبط برای همیشه مسدود میشوند.
|
||||
- **مسدودسازی حساب:** کلیدهای عمومی Nostr مرتبط با فعالیتهای CSAE به فهرستهای مسدودسازی در سطح برنامه افزوده خواهند شد تا محتوای آنها در {{appName}} ظاهر نشود، صرف نظر از اینکه از کدام رله گرفته شود.
|
||||
- **مسدودسازی رله:** رلههای اشخاص ثالثی که در رسیدگی به محتوای CSAE کوتاهی کنند، ممکن است از فهرست رلههای پیشفرض {{appName}} حذف شوند و کاربران از افزودن آنها منع شوند.
|
||||
- **گزارش به مقامات:** مواد CSAE شناساییشده را از طریق CyberTipline به [مرکز ملی کودکان مفقود و مورد بهرهکشی (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) و به نهادهای اجرای قانون مربوطه گزارش خواهیم داد.
|
||||
|
||||
## گزارش محتوای CSAE
|
||||
|
||||
اگر در {{appName}} با محتوایی روبهرو شدید که بهنظرتان مصداق سوءاستفاده یا بهرهکشی جنسی از کودکان است، لطفاً بلافاصله گزارش دهید:
|
||||
|
||||
- **گزارش درونبرنامه:** از دکمهٔ گزارش که در هر پست یا پروفایل کاربری در دسترس است برای علامتگذاری محتوا جهت بازبینی استفاده کنید.
|
||||
- **تماس مستقیم با ما:** با تیم ما در [soapbox.pub](https://soapbox.pub) تماس بگیرید و جزئیات محتوا، از جمله شناسهٔ رویداد Nostr یا کلیدهای عمومی مرتبط را ارائه دهید.
|
||||
- **گزارش به NCMEC:** همچنین میتوانید گزارش را مستقیماً در [خط CyberTipline NCMEC](https://www.missingkids.org/gethelpnow/cybertipline) ثبت کنید.
|
||||
- **تماس با نیروی انتظامی:** اگر تصور میکنید کودکی در خطر فوری است، فوراً با پلیس محلی تماس بگیرید یا با شمارهٔ **۹۱۱** (ایالات متحده) تماس بگیرید.
|
||||
|
||||
تمامی گزارشهای مربوط به محتوای CSAE با بالاترین اولویت بررسی میشوند و در سریعترین زمان ممکن مرور خواهند شد.
|
||||
|
||||
## همکاری با نیروی انتظامی
|
||||
|
||||
{{appName}} متعهد است به همکاری کامل با نهادهای اجرای قانون که در حال تحقیق دربارهٔ CSAE هستند. هرچند {{appName}} محتوای کاربران را در سرورهای خود ذخیره نمیکند، اما:
|
||||
|
||||
- اطلاعاتی که در اختیار ما باشد — از جمله دادههای رلهٔ Agora و سرور Agora Blossom — را در چارچوب قانون قابل اجرا، در اختیار خواهیم گذاشت تا به تحقیقات کمک کند.
|
||||
- آدرسهای مشخص رلهها و سرورهای فایلی را که محتوای متخلف در آنها مشاهده شده، شناسایی و به اشتراک میگذاریم تا نیروی انتظامی بتواند مستقیماً با اپراتورهای آنها تماس بگیرد.
|
||||
- پس از دریافت درخواست قانونی معتبر، هرگونه شواهد یا اطلاعات در دسترس را حفظ میکنیم.
|
||||
- مواد CSAE شناساییشده را بهصورت پیشگیرانه به NCMEC و سایر مقامات مربوطه گزارش میکنیم.
|
||||
|
||||
## ملاحظات معماری غیرمتمرکز
|
||||
|
||||
ماهیت غیرمتمرکز Nostr به این معناست که هیچ نهاد یگانهای کنترل کامل بر تمام محتوای موجود در شبکه ندارد. {{appName}} واقعیتهای زیر و رویکرد خود نسبت به هر یک را اذعان میکند:
|
||||
|
||||
- **کنترل کامل بر زیرساخت خودمان:** ما میتوانیم و در عمل محتوا را از رلهٔ Agora و سرور Agora Blossom حذف میکنیم. مواد CSAE یافتشده در زیرساخت ما فوراً حذف میشوند و حسابها برای همیشه مسدود میگردند.
|
||||
- **کنترل محدود بر رلههای اشخاص ثالث:** ما نمیتوانیم محتوای رلههای اشخاص ثالث را حذف کنیم. اما با فیلترها و فهرستهای مسدودسازی در سطح کلاینت، از نمایش چنین محتوایی در برنامهٔ خود جلوگیری میکنیم.
|
||||
- **کاربران اتصالهای رلهٔ خود را کنترل میکنند:** هرچند کاربران میتوانند به رلههای دلخواه خود متصل شوند، {{appName}} این حق را برای خود محفوظ میدارد که اتصال به رلههایی که شناختهشده است محتوای CSAE میزبانی میکنند را مسدود کند.
|
||||
- **کلیدهای عمومی، نام مستعار هستند:** حسابهای Nostr با جفتکلیدهای رمزنگاری شناسایی میشوند نه با هویتهای احراز شده. با این حال، ما کلیدهای متخلف را مسدود و گزارش خواهیم کرد و با نیروی انتظامی برای شناسایی افراد پشت آنها همکاری میکنیم.
|
||||
|
||||
## فرجامخواهی
|
||||
|
||||
اگر معتقدید محتوا یا حساب شما بر اساس این سیاست بهاشتباه علامتگذاری یا مسدود شده است، میتوانید از طریق [soapbox.pub](https://soapbox.pub) برای درخواست بازنگری با ما تماس بگیرید. ما فرجامخواهیها را بهصورت موردی بررسی خواهیم کرد. با این حال، در همهٔ تصمیمات، جانب ایمنی کودک را در نظر میگیریم و تصمیم نهایی با ماست.
|
||||
|
||||
## تغییرات در این سیاست
|
||||
|
||||
ممکن است این سیاست ایمنی کودکان را با تکامل ابزارها، فرایندها و اکوسیستم Nostr بهروزرسانی کنیم. تغییرات با تاریخ بهروزشده در این صفحه منعکس خواهد شد. ما متعهد به بهبود مداوم توانایی خود در شناسایی، پیشگیری و پاسخ به محتوای CSAE هستیم.
|
||||
|
||||
## تماس
|
||||
|
||||
برای پرسشها دربارهٔ این سیاست یا گزارش محتوای CSAE، با تیم پشت {{appName}} از طریق [soapbox.pub](https://soapbox.pub) تماس بگیرید.
|
||||
@@ -0,0 +1,90 @@
|
||||
_Dernière mise à jour : 19 mars 2026_
|
||||
|
||||
## Notre engagement
|
||||
|
||||
{{appName}} applique une **politique de tolérance zéro** envers les contenus d'exploitation et d'abus sexuels d'enfants (CSAE). La sécurité des enfants est primordiale, et nous nous engageons à faire tout ce qui est en notre pouvoir en tant qu'application cliente pour empêcher la distribution, la promotion ou la facilitation de contenus CSAE via notre application.
|
||||
|
||||
Cette politique s'applique à tout contenu accessible via {{appName}}, y compris le texte, les images, les vidéos, les liens et tout autre média. Elle couvre toutes les formes de CSAE, y compris, sans s'y limiter, l'imagerie, la sollicitation, le grooming, la traite et la sexualisation de mineurs.
|
||||
|
||||
## Comment fonctionne {{appName}}
|
||||
|
||||
{{appName}} est une **application cliente** pour le protocole Nostr, un réseau de communication ouvert et décentralisé. Comprendre l'architecture est un contexte important pour cette politique :
|
||||
|
||||
- **Notre infrastructure :** Nous exploitons le **relais Agora** et le **serveur Blossom Agora**, qui servent de relais et d'hôte de fichiers par défaut pour {{appName}}. Nous avons un contrôle complet de modération sur le contenu stocké sur ces services.
|
||||
- **Relais tiers :** Les utilisateurs peuvent également se connecter à d'autres relais Nostr exploités par des tiers indépendants. {{appName}} récupère et affiche le contenu de tous les relais auxquels l'utilisateur est connecté. Nous n'avons pas de contrôle de modération sur les relais tiers, mais nous contrôlons ce que l'application affiche.
|
||||
- **Serveurs de médias tiers :** Les utilisateurs peuvent téléverser des images et des vidéos sur des serveurs de fichiers tiers compatibles Blossom. Nous n'exploitons ni ne modérons ces services externes.
|
||||
|
||||
Nous assumons l'entière responsabilité de l'expérience au sein de notre application. Sur notre propre infrastructure (relais Agora et serveur Blossom Agora), nous pouvons directement supprimer le contenu et bannir les comptes contrevenants. Pour le contenu provenant de services tiers, nous le bloquons activement pour qu'il ne s'affiche pas dans {{appName}}.
|
||||
|
||||
## Contenu et comportement interdits
|
||||
|
||||
Ce qui suit est strictement interdit sur {{appName}}. Les utilisateurs trouvés à se livrer à l'un des éléments suivants feront l'objet de mesures immédiates :
|
||||
|
||||
- **CSAM (matériel d'abus sexuel sur enfants) :** Toute représentation visuelle de conduite sexuelle explicite impliquant un mineur, y compris les photographies, vidéos et images générées numériquement ou par IA.
|
||||
- **Grooming :** Toute tentative de construire une relation avec un mineur dans le but d'exploitation ou d'abus sexuel.
|
||||
- **Sollicitation :** Demande, offre ou facilitation de l'échange de matériel CSAE ou de contact sexuel avec des mineurs.
|
||||
- **Sexualisation de mineurs :** Contenu qui sexualise les mineurs, y compris des commentaires suggestifs ou sexuels sur des enfants, même si aucune imagerie explicite n'est impliquée.
|
||||
- **Traite :** Tout contenu qui facilite, promeut ou coordonne la traite de mineurs à des fins sexuelles.
|
||||
- **Liens et références :** Partage de liens vers des sites externes ou des ressources contenant du matériel CSAE, ou fourniture d'instructions sur la façon de trouver ou de produire un tel matériel.
|
||||
|
||||
## Détection et prévention
|
||||
|
||||
{{appName}} met en œuvre plusieurs couches de protection pour combattre la CSAE :
|
||||
|
||||
- **Filtrage de contenu :** Nous maintenons et appliquons des mécanismes de filtrage de contenu dans l'application pour bloquer l'affichage de matériel CSAE connu, quel que soit le relais d'origine.
|
||||
- **Signalement par les utilisateurs :** Nous fournissons des outils de signalement intégrés à l'application qui permettent aux utilisateurs de signaler tout contenu CSAE suspect pour examen immédiat.
|
||||
- **Modération du relais Agora :** Sur notre propre relais Agora, nous modérons activement le contenu et supprimons immédiatement tout matériel CSAE et bannissons définitivement les comptes associés.
|
||||
- **Modération du serveur Blossom Agora :** Sur notre propre serveur de fichiers Blossom Agora, nous supprimerons immédiatement tout média CSAE et bannirons le compte qui l'a téléversé.
|
||||
- **Blocage des relais tiers :** Les relais tiers connus pour héberger ou tolérer le matériel CSAE peuvent être retirés de la liste de relais par défaut de {{appName}} et bloqués pour être ajoutés par les utilisateurs.
|
||||
- **Outils de mise en sourdine et de blocage :** Les utilisateurs peuvent mettre en sourdine ou bloquer des comptes au niveau du client, empêchant le contenu de ces comptes d'apparaître dans leur fil.
|
||||
|
||||
## Actions d'application
|
||||
|
||||
Lorsqu'un contenu ou un comportement CSAE est identifié, {{appName}} prendra les mesures suivantes selon le cas :
|
||||
|
||||
- **Blocage immédiat du contenu :** Le contenu CSAE connu sera bloqué à l'affichage dans l'application par des filtres de contenu et des listes de blocage.
|
||||
- **Suppression de l'infrastructure Agora :** Le contenu CSAE sur le relais Agora et le serveur Blossom Agora sera immédiatement supprimé, et les comptes associés bannis définitivement.
|
||||
- **Blocage de comptes :** Les clés publiques Nostr associées à l'activité CSAE seront ajoutées aux listes de blocage au niveau de l'application, empêchant leur contenu d'apparaître dans {{appName}} quel que soit le relais d'où il est récupéré.
|
||||
- **Blocage de relais :** Les relais tiers qui ne traitent pas le contenu CSAE peuvent être retirés de la liste de relais par défaut de {{appName}} et bloqués pour être ajoutés par les utilisateurs.
|
||||
- **Signalement aux autorités :** Nous signalerons le matériel CSAE identifié au [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) via la CyberTipline, et aux agences d'application de la loi applicables.
|
||||
|
||||
## Signalement de contenu CSAE
|
||||
|
||||
Si vous rencontrez sur {{appName}} un contenu que vous pensez constituer un abus sexuel ou une exploitation d'enfants, veuillez le signaler immédiatement :
|
||||
|
||||
- **Signalement dans l'application :** Utilisez le bouton de signalement disponible sur toute publication ou profil d'utilisateur pour signaler le contenu pour examen.
|
||||
- **Contactez-nous directement :** Contactez notre équipe sur [soapbox.pub](https://soapbox.pub) avec les détails du contenu, y compris tous les ID d'événement Nostr ou clés publiques pertinents.
|
||||
- **Signaler au NCMEC :** Vous pouvez également déposer un rapport directement auprès de la [NCMEC CyberTipline](https://www.missingkids.org/gethelpnow/cybertipline).
|
||||
- **Contacter les forces de l'ordre :** Si vous pensez qu'un enfant est en danger immédiat, contactez immédiatement les forces de l'ordre locales ou appelez le **911** (États-Unis).
|
||||
|
||||
Tous les signalements de contenu CSAE sont traités avec la plus haute priorité et seront examinés aussi rapidement que possible.
|
||||
|
||||
## Coopération avec les forces de l'ordre
|
||||
|
||||
{{appName}} s'engage à coopérer pleinement avec les agences d'application de la loi enquêtant sur la CSAE. Bien que {{appName}} ne stocke pas le contenu utilisateur sur ses propres serveurs, nous :
|
||||
|
||||
- Fournirons toute information dont nous disposons — y compris les données du relais Agora et du serveur Blossom Agora — qui peut aider aux enquêtes, conformément à la loi applicable.
|
||||
- Identifierons et partagerons les URL spécifiques de relais et de serveurs de fichiers où le contenu contrevenant a été observé, afin que les forces de l'ordre puissent contacter directement ces opérateurs.
|
||||
- Préserverons toute preuve ou information disponible à la réception d'une demande légale valide.
|
||||
- Signalerons proactivement le matériel CSAE identifié au NCMEC et aux autres autorités compétentes.
|
||||
|
||||
## Considérations sur l'architecture décentralisée
|
||||
|
||||
La nature décentralisée de Nostr signifie qu'aucune entité unique n'a un contrôle complet sur tout le contenu du réseau. {{appName}} reconnaît les réalités suivantes et notre approche pour chacune :
|
||||
|
||||
- **Contrôle complet sur notre propre infrastructure :** Nous pouvons et supprimons le contenu du relais Agora et du serveur Blossom Agora. Le matériel CSAE trouvé sur notre infrastructure est supprimé immédiatement et les comptes sont bannis définitivement.
|
||||
- **Contrôle limité sur les relais tiers :** Nous ne pouvons pas supprimer le contenu des relais tiers. Cependant, nous bloquons l'affichage de ce contenu dans notre application via des filtres et des listes de blocage au niveau du client.
|
||||
- **Les utilisateurs contrôlent leurs connexions aux relais :** Bien que les utilisateurs puissent se connecter aux relais de leur choix, {{appName}} se réserve le droit de bloquer les connexions aux relais connus pour héberger du contenu CSAE.
|
||||
- **Les clés publiques sont pseudonymes :** Les comptes Nostr sont identifiés par des paires de clés cryptographiques plutôt que par des identités vérifiées. Nous bloquerons et signalerons toujours les clés contrevenantes et coopérerons avec les forces de l'ordre pour identifier les individus derrière elles.
|
||||
|
||||
## Recours
|
||||
|
||||
Si vous pensez que votre contenu ou votre compte a été signalé ou bloqué à tort en vertu de cette politique, vous pouvez nous contacter sur [soapbox.pub](https://soapbox.pub) pour demander un examen. Nous évaluerons les recours au cas par cas. Cependant, nous penchons du côté de la sécurité des enfants dans toutes les décisions, et notre détermination est définitive.
|
||||
|
||||
## Modifications de cette politique
|
||||
|
||||
Nous pouvons mettre à jour cette politique de sécurité des enfants à mesure que nos outils, processus et l'écosystème Nostr évoluent. Les modifications seront reflétées sur cette page avec une date mise à jour. Nous nous engageons à améliorer continuellement notre capacité à détecter, prévenir et répondre au contenu CSAE.
|
||||
|
||||
## Contact
|
||||
|
||||
Pour des questions concernant cette politique ou pour signaler du contenu CSAE, contactez l'équipe derrière {{appName}} sur [soapbox.pub](https://soapbox.pub).
|
||||
@@ -0,0 +1,90 @@
|
||||
_បានធ្វើបច្ចុប្បន្នភាពចុងក្រោយ៖ ថ្ងៃទី១៩ ខែមីនា ឆ្នាំ២០២៦_
|
||||
|
||||
## ការប្ដេជ្ញាចិត្តរបស់យើង
|
||||
|
||||
{{appName}} មាន **គោលនយោបាយមិនអត់ឱន** ចំពោះការរំលោភបំពាន និងការកេងប្រវ័ញ្ចផ្លូវភេទលើកុមារ (CSAE)។ សុវត្ថិភាពកុមារសំខាន់បំផុត ហើយយើងប្ដេជ្ញាធ្វើអ្វីៗគ្រប់យ៉ាងតាមលទ្ធភាពរបស់យើងក្នុងនាមជាកម្មវិធីភ្ញៀវ ដើម្បីការពារការផ្សព្វផ្សាយ លើកកម្ពស់ ឬជួយសម្រួលដល់មាតិកា CSAE តាមរយៈកម្មវិធីរបស់យើង។
|
||||
|
||||
គោលនយោបាយនេះអនុវត្តចំពោះមាតិកាទាំងអស់ដែលអាចចូលប្រើបានតាមរយៈ {{appName}} រួមមានអក្សរ រូបភាព វីដេអូ តំណ និងមេឌៀផ្សេងៗទៀត។ វាគ្របដណ្ដប់រាល់ទម្រង់នៃ CSAE រួមមានប៉ុន្តែមិនកំណត់ត្រឹមរូបភាព ការទាមទារ ការទាក់ទាញ (grooming) ការជួញដូរ និងការធ្វើឱ្យកុមារក្លាយជាវត្ថុផ្លូវភេទ។
|
||||
|
||||
## របៀបដែល {{appName}} ដំណើរការ
|
||||
|
||||
{{appName}} គឺជា **កម្មវិធីភ្ញៀវ** សម្រាប់ប្រូតូកូល Nostr ដែលជាបណ្ដាញទំនាក់ទំនងបើកចំហ និងគ្មានមជ្ឈការ។ ការយល់ដឹងពីស្ថាបត្យកម្មគឺជាបរិបទដ៏សំខាន់សម្រាប់គោលនយោបាយនេះ៖
|
||||
|
||||
- **ហេដ្ឋារចនាសម្ព័ន្ធរបស់យើង៖** យើងគ្រប់គ្រងប្រតិបត្តិការ **Agora relay** និង **Agora Blossom server** ដែលដើរតួជា relay និងម៉ាស៊ីនមេឯកសារលំនាំដើមសម្រាប់ {{appName}}។ យើងមានសិទ្ធិត្រួតពិនិត្យពេញលេញលើមាតិកាដែលរក្សាទុកនៅលើសេវាកម្មទាំងនេះ។
|
||||
- **Relay របស់ភាគីទីបី៖** អ្នកប្រើអាចភ្ជាប់ទៅ Nostr relay បន្ថែមដែលដំណើរការដោយភាគីទីបីឯករាជ្យ។ {{appName}} ទាញ និងបង្ហាញមាតិកាពី relay ណាមួយដែលអ្នកប្រើបានភ្ជាប់។ យើងគ្មានសិទ្ធិត្រួតពិនិត្យ relay របស់ភាគីទីបីទេ ប៉ុន្តែយើងគ្រប់គ្រងអ្វីដែលកម្មវិធីបង្ហាញ។
|
||||
- **ម៉ាស៊ីនមេមេឌៀរបស់ភាគីទីបី៖** អ្នកប្រើអាចអាប់ឡូតរូបភាព និងវីដេអូទៅម៉ាស៊ីនមេឯកសារដែលត្រូវនឹង Blossom របស់ភាគីទីបី។ យើងមិនដំណើរការ ឬត្រួតពិនិត្យសេវាកម្មខាងក្រៅទាំងនេះទេ។
|
||||
|
||||
យើងទទួលខុសត្រូវពេញលេញចំពោះបទពិសោធន៍នៅក្នុងកម្មវិធីរបស់យើង។ នៅលើហេដ្ឋារចនាសម្ព័ន្ធរបស់យើងផ្ទាល់ (Agora relay និង Agora Blossom server) យើងអាចលុបមាតិកាដោយផ្ទាល់ និងហាមឃាត់គណនីដែលរំលោភ។ សម្រាប់មាតិកាដែលមានប្រភពពីសេវាកម្មរបស់ភាគីទីបី យើងរារាំងវាយ៉ាងសកម្មពីការបង្ហាញនៅក្នុង {{appName}}។
|
||||
|
||||
## មាតិកា និងឥរិយាបថដែលត្រូវហាមឃាត់
|
||||
|
||||
ខាងក្រោមនេះត្រូវហាមឃាត់យ៉ាងតឹងរឹងនៅលើ {{appName}}។ អ្នកប្រើដែលត្រូវបានរកឃើញចូលរួមក្នុងសកម្មភាពណាមួយខាងក្រោម នឹងត្រូវទទួលរងសកម្មភាពភ្លាមៗ៖
|
||||
|
||||
- **CSAM (សម្ភារៈរំលោភបំពានផ្លូវភេទលើកុមារ)៖** ការបង្ហាញលក្ខណៈនៃឥរិយាបថផ្លូវភេទច្បាស់លាស់ដែលពាក់ព័ន្ធនឹងអនីតិជន រួមមានរូបថត វីដេអូ និងរូបភាពដែលបង្កើតដោយឌីជីថល ឬ AI។
|
||||
- **Grooming៖** ការប៉ុនប៉ងណាមួយដើម្បីបង្កើតទំនាក់ទំនងជាមួយអនីតិជនក្នុងគោលបំណងកេងប្រវ័ញ្ច ឬរំលោភបំពានផ្លូវភេទ។
|
||||
- **ការទាមទារ៖** ការស្នើសុំ ការផ្ដល់ជូន ឬការជួយសម្រួលការផ្លាស់ប្ដូរសម្ភារៈ CSAE ឬការទាក់ទងផ្លូវភេទជាមួយអនីតិជន។
|
||||
- **ការធ្វើឱ្យអនីតិជនក្លាយជាវត្ថុផ្លូវភេទ៖** មាតិកាដែលធ្វើឱ្យអនីតិជនក្លាយជាវត្ថុផ្លូវភេទ រួមមានយោបល់ដែលជំរុញ ឬនិយាយអំពីផ្លូវភេទនៅលើកុមារ ទោះបីជាមិនមានរូបភាពច្បាស់លាស់ក៏ដោយ។
|
||||
- **ការជួញដូរ៖** មាតិកាណាមួយដែលជួយសម្រួល លើកកម្ពស់ ឬសម្របសម្រួលការជួញដូរអនីតិជនសម្រាប់គោលបំណងផ្លូវភេទ។
|
||||
- **តំណ និងសេចក្ដីយោង៖** ការចែករំលែកតំណទៅគេហទំព័រ ឬធនធានខាងក្រៅដែលមានសម្ភារៈ CSAE ឬផ្ដល់ការណែនាំអំពីរបៀបស្វែងរក ឬផលិតសម្ភារៈបែបនេះ។
|
||||
|
||||
## ការរកឃើញ និងការការពារ
|
||||
|
||||
{{appName}} អនុវត្តស្រទាប់ការការពារច្រើនដើម្បីប្រយុទ្ធនឹង CSAE៖
|
||||
|
||||
- **ការត្រងមាតិកា៖** យើងថែទាំ និងអនុវត្តយន្តការត្រងមាតិកាក្នុងកម្មវិធី ដើម្បីរារាំងសម្ភារៈ CSAE ដែលគេស្គាល់ពីការបង្ហាញ ដោយមិនគិតពី relay ណាដែលវាមានប្រភពមកពី។
|
||||
- **ការរាយការណ៍របស់អ្នកប្រើ៖** យើងផ្ដល់ឧបករណ៍រាយការណ៍ក្នុងកម្មវិធីដែលអនុញ្ញាតឱ្យអ្នកប្រើដាក់សញ្ញាសម្ភារៈ CSAE ដែលគួរឱ្យសង្ស័យដើម្បីពិនិត្យឡើងវិញភ្លាមៗ។
|
||||
- **ការត្រួតពិនិត្យ Agora relay៖** នៅលើ Agora relay របស់យើងផ្ទាល់ យើងត្រួតពិនិត្យមាតិកាយ៉ាងសកម្ម ហើយនឹងលុបសម្ភារៈ CSAE ភ្លាមៗ និងហាមឃាត់គណនីដែលពាក់ព័ន្ធជារៀងរហូត។
|
||||
- **ការត្រួតពិនិត្យ Agora Blossom server៖** នៅលើម៉ាស៊ីនមេឯកសារ Agora Blossom របស់យើងផ្ទាល់ យើងនឹងលុបមេឌៀ CSAE ភ្លាមៗ និងហាមឃាត់គណនីដែលអាប់ឡូត។
|
||||
- **ការរារាំង relay របស់ភាគីទីបី៖** Relay របស់ភាគីទីបីដែលគេស្គាល់ថាដាក់សម្ភារៈ CSAE ឬមិនយកចិត្តទុកដាក់នឹងត្រូវបានដកចេញពីបញ្ជី relay លំនាំដើមរបស់ {{appName}} និងត្រូវរារាំងពីអ្នកប្រើ។
|
||||
- **ឧបករណ៍បិទសម្លេង និងទប់ស្កាត់៖** អ្នកប្រើអាចបិទសម្លេង ឬទប់ស្កាត់គណនីនៅកម្រិតភ្ញៀវ ដើម្បីការពារមាតិកាពីគណនីទាំងនោះមិនឱ្យបង្ហាញនៅក្នុង feed របស់ពួកគេ។
|
||||
|
||||
## សកម្មភាពអនុវត្ត
|
||||
|
||||
នៅពេលដែលមាតិកា ឬឥរិយាបថ CSAE ត្រូវបានកំណត់ {{appName}} នឹងចាត់សកម្មភាពខាងក្រោមតាមការអនុវត្ត៖
|
||||
|
||||
- **ការរារាំងមាតិកាភ្លាមៗ៖** មាតិកា CSAE ដែលគេស្គាល់នឹងត្រូវរារាំងពីការបង្ហាញនៅក្នុងកម្មវិធីតាមរយៈតម្រងមាតិកា និងបញ្ជីហាមឃាត់។
|
||||
- **ការដកចេញពីហេដ្ឋារចនាសម្ព័ន្ធ Agora៖** មាតិកា CSAE នៅលើ Agora relay និង Agora Blossom server នឹងត្រូវលុបភ្លាមៗ ហើយគណនីពាក់ព័ន្ធត្រូវហាមឃាត់ជារៀងរហូត។
|
||||
- **ការរារាំងគណនី៖** កូនសោសាធារណៈ Nostr ដែលពាក់ព័ន្ធនឹងសកម្មភាព CSAE នឹងត្រូវបន្ថែមទៅបញ្ជីហាមឃាត់កម្រិតកម្មវិធី ដែលរារាំងមាតិការបស់ពួកគេពីការបង្ហាញនៅក្នុង {{appName}} ដោយមិនគិតពី relay ណាដែលវាត្រូវបានទាញ។
|
||||
- **ការរារាំង relay៖** Relay របស់ភាគីទីបីដែលបរាជ័យក្នុងការដោះស្រាយមាតិកា CSAE អាចត្រូវដកចេញពីបញ្ជី relay លំនាំដើមរបស់ {{appName}} និងត្រូវរារាំងពីការបន្ថែមដោយអ្នកប្រើ។
|
||||
- **ការរាយការណ៍ទៅអាជ្ញាធរ៖** យើងនឹងរាយការណ៍សម្ភារៈ CSAE ដែលបានកំណត់ទៅ [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) តាមរយៈ CyberTipline និងទៅភ្នាក់ងារអនុវត្តច្បាប់ដែលអនុវត្តបាន។
|
||||
|
||||
## ការរាយការណ៍មាតិកា CSAE
|
||||
|
||||
ប្រសិនបើអ្នកជួបមាតិកាណាមួយនៅលើ {{appName}} ដែលអ្នកជឿថាបង្កើតឱ្យមានការរំលោភបំពាន ឬការកេងប្រវ័ញ្ចផ្លូវភេទលើកុមារ សូមរាយការណ៍វាភ្លាមៗ៖
|
||||
|
||||
- **ការរាយការណ៍ក្នុងកម្មវិធី៖** ប្រើប៊ូតុងរាយការណ៍ដែលមាននៅលើការប្រកាស ឬប្រវត្តិរូបអ្នកប្រើណាមួយ ដើម្បីដាក់សញ្ញាមាតិកាសម្រាប់ការពិនិត្យឡើងវិញ។
|
||||
- **ទាក់ទងយើងដោយផ្ទាល់៖** ទាក់ទងក្រុមរបស់យើងតាមរយៈ [soapbox.pub](https://soapbox.pub) ជាមួយព័ត៌មានលម្អិតនៃមាតិកា រួមមាន ID ព្រឹត្តិការណ៍ Nostr ឬកូនសោសាធារណៈដែលពាក់ព័ន្ធ។
|
||||
- **រាយការណ៍ទៅ NCMEC៖** អ្នកក៏អាចដាក់របាយការណ៍ដោយផ្ទាល់ជាមួយ [NCMEC CyberTipline](https://www.missingkids.org/gethelpnow/cybertipline)។
|
||||
- **ទាក់ទងអ្នកអនុវត្តច្បាប់៖** ប្រសិនបើអ្នកជឿថាកុមារកំពុងស្ថិតក្នុងគ្រោះថ្នាក់បន្ទាន់ សូមទាក់ទងអ្នកអនុវត្តច្បាប់ក្នុងតំបន់របស់អ្នក ឬហៅ **911** (សហរដ្ឋអាមេរិក) ភ្លាមៗ។
|
||||
|
||||
រាល់របាយការណ៍មាតិកា CSAE ត្រូវបានគ្រប់គ្រងជាមួយការផ្ដល់អាទិភាពខ្ពស់បំផុត ហើយនឹងត្រូវពិនិត្យឡើងវិញឱ្យបានឆាប់ៗ។
|
||||
|
||||
## កិច្ចសហប្រតិបត្តិការជាមួយអាជ្ញាធរអនុវត្តច្បាប់
|
||||
|
||||
{{appName}} ប្ដេជ្ញាសហការពេញលេញជាមួយភ្នាក់ងារអនុវត្តច្បាប់ដែលកំពុងស៊ើបអង្កេតលើ CSAE។ ទោះបី {{appName}} មិនរក្សាទុកមាតិកាអ្នកប្រើនៅលើម៉ាស៊ីនមេផ្ទាល់ខ្លួនរបស់ខ្លួនក៏ដោយ យើងនឹង៖
|
||||
|
||||
- ផ្ដល់ព័ត៌មានណាមួយដែលមានសម្រាប់យើង — រួមមានទិន្នន័យពី Agora relay និង Agora Blossom server — ដែលអាចជួយដល់ការស៊ើបអង្កេត តាមច្បាប់ដែលអនុវត្តបាន។
|
||||
- កំណត់អត្តសញ្ញាណ និងចែករំលែក URL ជាក់លាក់នៃ relay និង URL ម៉ាស៊ីនមេឯកសារដែលមាតិការំលោភត្រូវបានសង្កេតឃើញ ដើម្បីឱ្យអ្នកអនុវត្តច្បាប់អាចទាក់ទងប្រតិបត្តិករទាំងនោះដោយផ្ទាល់។
|
||||
- រក្សាភ័ស្តុតាង ឬព័ត៌មានដែលមាន នៅពេលទទួលបានសំណើផ្លូវច្បាប់ត្រឹមត្រូវ។
|
||||
- រាយការណ៍សម្ភារៈ CSAE ដែលបានកំណត់ទៅ NCMEC និងអាជ្ញាធរពាក់ព័ន្ធផ្សេងទៀតជាមួយការសកម្ម។
|
||||
|
||||
## ការពិចារណាស្ថាបត្យកម្មគ្មានមជ្ឈការ
|
||||
|
||||
ធម្មជាតិគ្មានមជ្ឈការនៃ Nostr មានន័យថាគ្មានស្ថាប័នតែមួយដែលមានការគ្រប់គ្រងពេញលេញលើមាតិកាទាំងអស់នៅលើបណ្ដាញនោះទេ។ {{appName}} ទទួលស្គាល់ការពិតខាងក្រោម និងវិធីសាស្ត្ររបស់យើងចំពោះនីមួយៗ៖
|
||||
|
||||
- **ការគ្រប់គ្រងពេញលេញលើហេដ្ឋារចនាសម្ព័ន្ធផ្ទាល់ខ្លួនរបស់យើង៖** យើងអាច និងធ្វើការដកមាតិកាពី Agora relay និង Agora Blossom server។ សម្ភារៈ CSAE ដែលរកឃើញនៅលើហេដ្ឋារចនាសម្ព័ន្ធរបស់យើងត្រូវលុបភ្លាមៗ ហើយគណនីត្រូវហាមឃាត់ជារៀងរហូត។
|
||||
- **ការគ្រប់គ្រងមានកំណត់លើ relay របស់ភាគីទីបី៖** យើងមិនអាចលុបមាតិកាពី relay របស់ភាគីទីបីបានទេ។ ប៉ុន្តែយើងរារាំងមាតិកាបែបនេះពីការបង្ហាញនៅក្នុងកម្មវិធីរបស់យើងតាមរយៈតម្រង និងបញ្ជីហាមឃាត់នៅកម្រិតភ្ញៀវ។
|
||||
- **អ្នកប្រើគ្រប់គ្រងការតភ្ជាប់ relay របស់ពួកគេ៖** ខណៈពេលដែលអ្នកប្រើអាចភ្ជាប់ទៅ relay ដែលពួកគេជ្រើសរើស {{appName}} រក្សាសិទ្ធិក្នុងការរារាំងការតភ្ជាប់ទៅ relay ដែលគេស្គាល់ថាដាក់មាតិកា CSAE។
|
||||
- **កូនសោសាធារណៈគឺឈ្មោះក្លែងក្លាយ៖** គណនី Nostr ត្រូវបានកំណត់អត្តសញ្ញាណដោយគូកូនសោគ្រីបតូ មិនមែនដោយអត្តសញ្ញាណដែលបានផ្ទៀងផ្ទាត់ទេ។ យើងនឹងនៅតែរារាំង និងរាយការណ៍កូនសោដែលរំលោភ និងសហការជាមួយអ្នកអនុវត្តច្បាប់ដើម្បីកំណត់អត្តសញ្ញាណបុគ្គលនៅពីក្រោយពួកគេ។
|
||||
|
||||
## ការប្ដឹងឧទ្ធរណ៍
|
||||
|
||||
ប្រសិនបើអ្នកជឿថាមាតិកា ឬគណនីរបស់អ្នកត្រូវបានដាក់សញ្ញា ឬរារាំងមិនត្រឹមត្រូវក្រោមគោលនយោបាយនេះ អ្នកអាចទាក់ទងយើងតាមរយៈ [soapbox.pub](https://soapbox.pub) ដើម្បីស្នើសុំការពិនិត្យឡើងវិញ។ យើងនឹងវាយតម្លៃការប្ដឹងឧទ្ធរណ៍លើមូលដ្ឋានករណីនីមួយៗ។ ទោះជាយ៉ាងណាក៏ដោយ យើងផ្ដោតលើផ្នែកនៃសុវត្ថិភាពកុមារនៅក្នុងគ្រប់ការសម្រេចចិត្ត ហើយការសម្រេចចិត្តរបស់យើងគឺចុងក្រោយ។
|
||||
|
||||
## ការផ្លាស់ប្ដូរនៅក្នុងគោលនយោបាយនេះ
|
||||
|
||||
យើងអាចធ្វើបច្ចុប្បន្នភាពគោលនយោបាយសុវត្ថិភាពកុមារនេះ នៅពេលដែលឧបករណ៍ ដំណើរការ និងប្រព័ន្ធអេកូ Nostr របស់យើងមានការវិវឌ្ឍ។ ការផ្លាស់ប្ដូរនឹងត្រូវឆ្លុះបញ្ចាំងនៅលើទំព័រនេះជាមួយនឹងកាលបរិច្ឆេទដែលធ្វើបច្ចុប្បន្នភាព។ យើងប្ដេជ្ញាចិត្តក្នុងការកែលម្អជាបន្តបន្ទាប់នូវសមត្ថភាពរបស់យើងក្នុងការរកឃើញ ការការពារ និងការឆ្លើយតបទៅនឹងមាតិកា CSAE។
|
||||
|
||||
## ទំនាក់ទំនង
|
||||
|
||||
សម្រាប់សំណួរអំពីគោលនយោបាយនេះ ឬដើម្បីរាយការណ៍មាតិកា CSAE សូមទាក់ទងក្រុមដែលនៅពីក្រោយ {{appName}} នៅ [soapbox.pub](https://soapbox.pub)។
|
||||
@@ -0,0 +1,90 @@
|
||||
_وروستی تازه: د ۲۰۲۶ کال د مارچ ۱۹مه_
|
||||
|
||||
## زموږ ژمنه
|
||||
|
||||
{{appName}} د ماشومانو د جنسي ناوړه ګټې اخیستنې او استثمار (CSAE) موادو په وړاندې **د صفر زغم** پالیسي لري. د ماشومانو خوندیتوب لومړنی دی، او موږ ژمن یو چې د یوې مراجع غوښتنلیک په توګه ټول هغه څه چې زموږ په توان کې وي تر سره کړو ترڅو زموږ د اپلیکیشن له لارې د CSAE محتوا د ویش، ترویج یا اسانتیا څخه مخنیوی وکړو.
|
||||
|
||||
دا پالیسي په {{appName}} کې د لاسرسي وړ ټولې محتوا باندې پلې کېږي، چې شامل دي متن، عکسونه، ویډیوګانې، لینکونه او نورې ټولې رسنۍ. دا د CSAE ټول ډولونه پوښي، په شمول د — خو محدود نه — تصاویرو، غوښتنې، groomingـ، قاچاق او د ماشومانو جنسيتوب.
|
||||
|
||||
## {{appName}} څنګه کار کوي
|
||||
|
||||
{{appName}} د Nostr پروتوکول لپاره یوه **مراجع غوښتنلیک** دی، یوه پرانیستی او نامرکزي اړیکنیز شبکه. د معماري په پوهیدل د دې پالیسي لپاره مهم شالید دی:
|
||||
|
||||
- **زموږ زیربنا:** موږ **د Agora رلی** او **د Agora Blossom سرور** چلوو، چې د {{appName}} لپاره د ډیفالټ رلی او د فایلونو میزبان په توګه کار کوي. موږ د دې خدماتو په زېرمهشویو موادو باندې بشپړ نظارتي کنټرول لرو.
|
||||
- **د دریمو ډلو رلیګانې:** کاروونکي ممکن د خپلواکو دریمو ډلو لخوا چلیدونکو نورو Nostr رلیګانو سره هم وصل شي. {{appName}} له هر هغه رلی څخه چې کاروونکی ورسره وصل وي، محتوا راوړي او ښیي. موږ د دریمو ډلو په رلیګانو باندې نظارتي کنټرول نه لرو، خو زموږ په کنټرول کې دی چې اپلیکیشن څه ښیي.
|
||||
- **د دریمو ډلو رسنیز سرورونه:** کاروونکي ممکن انځورونه او ویډیوګانې د دریمو ډلو Blossom سره ښکاره فایل سرورونو ته اپلوډ کړي. موږ دا بهرني خدمات نه چلوو او نه پرې نظارت کوو.
|
||||
|
||||
موږ زموږ په اپلیکیشن کې د تجربې بشپړه مسؤلیت اخلو. زموږ په خپلې زیربنا (د Agora رلی او د Agora Blossom سرور) کې موږ کولای شو محتوا په مستقیم ډول لرې کړو او ګناهکار حسابونه بند کړو. د هغو محتویاتو لپاره چې له دریمو ډلو خدماتو څخه راځي، موږ په فعاله توګه د هغو په {{appName}} کې له ښودلو څخه مخنیوی کوو.
|
||||
|
||||
## ممنوع محتوا او چلند
|
||||
|
||||
لاندې موارد په {{appName}} کې اکیداً منع دي. هر هغه کاروونکی چې په کوم یوه کې ښکیل ومومل شي، د سمدستي اقدام موضوع به وي:
|
||||
|
||||
- **CSAM (د ماشومانو د جنسي ناوړه ګټې اخیستنې مواد):** هر ډول د جنسي صریح چلند بصري انځور چې یو خرد سال یې شامل وي، په شمول د عکسونو، ویډیوګانو او د ډیجیټل یا د مصنوعي ذکاوت لخوا تولید شویو انځورونو.
|
||||
- **Grooming:** د جنسي استثمار یا ناوړه ګټې اخیستنې په موخه له یوه خردسال سره د اړیکې جوړولو لپاره هره هڅه.
|
||||
- **غوښتنه:** د CSAE موادو د تبادلې یا د خردسالانو سره د جنسي اړیکې غوښتل، وړاندیز یا اسانتیا.
|
||||
- **د خردسالانو جنسيتوب:** هغه محتوا چې خردسالان جنسي کوي، په شمول د ماشومانو په اړه د جنسي یا سپکسپور تبصرو، حتی که هیڅ څرګند انځور پکې نه وي.
|
||||
- **قاچاق:** هره هغه محتوا چې د خردسالانو د جنسي موخو لپاره قاچاق اسانه کوي، تبلیغوي یا همغږي کوي.
|
||||
- **لینکونه او ارجاعات:** د هغو بهرنیو سایټونو یا سرچینو د لینکونو شریکول چې د CSAE مواد لري، یا د دې موادو د موندلو یا تولید د لارښوونو وړاندې کول.
|
||||
|
||||
## کشف او مخنیوی
|
||||
|
||||
{{appName}} د CSAE سره د مبارزې لپاره څو طبقې محافظت پلي کوي:
|
||||
|
||||
- **د محتوا فلټر:** موږ د اپلیکیشن دننه د محتوا د فلټرولو میکانیزمونه ساتو او پلي کوو ترڅو د پېژندلشویو CSAE موادو د ښودلو مخنیوی وکړو، له هر هغه رلی پرته چې پکې راځي.
|
||||
- **د کاروونکو راپور ورکونه:** موږ د اپلیکیشن دننه د راپور ورکولو وسیلې وړاندې کوو چې کاروونکو ته اجازه ورکوي د سمدستي بیاکتنې لپاره د CSAE شکمنه محتوا نښه کړي.
|
||||
- **د Agora رلی نظارت:** زموږ په خپل Agora رلی کې موږ په فعاله توګه پر محتوا څار کوو او هر ډول CSAE مواد به سمدستي لرې کړو، او ورته حسابونه به دایمي بند کړو.
|
||||
- **د Agora Blossom سرور نظارت:** زموږ په خپل Agora Blossom فایل سرور کې، موږ به سمدستي هر ډول CSAE رسنۍ حذف کړو او هغه حساب چې یې اپلوډ کړی بند کړو.
|
||||
- **د دریمو ډلو رلیګانو بندول:** هغه د دریمو ډلو رلیګانې چې پېژندل شوې وي چې د CSAE مواد یې میزباني کوي یا یې زغمي، ممکن د {{appName}} د ډیفالټ رلیګانو له لیست څخه لرې شي او کاروونکي د هغوی له اضافه کولو څخه منع شي.
|
||||
- **د چوپتیا او بندولو وسیلې:** کاروونکي کولای شي حسابونه د کلاینټ په کچه چوپ کړي یا بند کړي، چې مخه نیسي د هغو حسابونو محتوا یې په فید کې راشي.
|
||||
|
||||
## د اجرا اقدامات
|
||||
|
||||
کله چې د CSAE محتوا یا چلند وپېژندل شي، {{appName}} به مناسب اقدامات تر سره کړي:
|
||||
|
||||
- **سمدستي د محتوا بندول:** پېژندلشوې CSAE محتوا به د محتوا فلټرونو او بلاکلیستونو له لارې په اپلیکیشن کې د ښودلو څخه مخنیوی وشي.
|
||||
- **د Agora زیربنا څخه لرې کول:** د Agora رلی او Agora Blossom سرور باندې د CSAE محتوا به سمدستي حذف شي او ورته حسابونه به دایمي بند شي.
|
||||
- **د حسابونو بندول:** د CSAE فعالیت سره تړلې د Nostr عمومي کليګانې به د اپلیکیشن په کچه بلاکلیستونو ته اضافه شي چې د هغو محتوا په {{appName}} کې له ښودلو څخه مخنیوی وکړي، بې له پامه چې له کوم رلی څخه راوړل کېږي.
|
||||
- **د رلی بندول:** هغه د دریمو ډلو رلیګانې چې د CSAE محتوا ته رسیدنه ونه کړي، ممکن د {{appName}} د ډیفالټ رلیګانو له لیست څخه لرې شي او کاروونکي د هغو له اضافه کولو څخه منع شي.
|
||||
- **چارواکو ته راپور:** موږ به پېژندلشوي CSAE مواد د CyberTipline له لارې [د بېسرنوشت او استثمار شویو ماشومانو ملي مرکز (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) ته او اړوندو قانون پلي کوونکو ادارو ته راپور کړو.
|
||||
|
||||
## د CSAE محتوا راپور
|
||||
|
||||
که تاسو په {{appName}} کې له داسې محتوا سره مخامخ شئ چې فکر کوئ د ماشومانو جنسي ناوړه ګټه اخیستنه یا استثمار جوړوي، نو مهرباني وکړئ سمدستي یې راپور کړئ:
|
||||
|
||||
- **د اپلیکیشن دننه راپور:** د هرې پوسټ یا د کاروونکي پروفایل پر دستوري شته راپور تڼۍ وکاروئ ترڅو محتوا د بیاکتنې لپاره نښه کړئ.
|
||||
- **مستقیم تماس:** زموږ ټیم سره په [soapbox.pub](https://soapbox.pub) کې اړیکه ونیسئ او د محتوا تفصیلات، په شمول د Nostr د اړونده پېښې IDs او عامو کلیو، شریک کړئ.
|
||||
- **NCMEC ته راپور:** تاسو کولای شئ په مستقیم ډول په [NCMEC CyberTipline](https://www.missingkids.org/gethelpnow/cybertipline) کې راپور هم ثبت کړئ.
|
||||
- **له قانون پلي کوونکو سره تماس:** که تاسو فکر کوئ یو ماشوم سمدستي په خطر کې دی، نو سمدستي د خپلې سیمې قانون پلي کوونکو سره اړیکه ونیسئ یا **۹۱۱** (متحده ایالات) ته زنګ ووهئ.
|
||||
|
||||
د CSAE محتوا ټول راپورونه په ترټولو لومړیتوب سره چلند کېږي او هر څومره چې ممکن وي ژر به بیاکتل شي.
|
||||
|
||||
## له قانون پلي کوونکو سره همکاري
|
||||
|
||||
{{appName}} ژمن دی چې له هغو قانون پلي کوونکو ادارو سره چې د CSAE په اړه څیړنه کوي، بشپړ همکاري وکړي. که څه هم {{appName}} د کاروونکو محتوا په خپلو سرورونو کې نه ساتي، خو موږ به:
|
||||
|
||||
- له موږ سره موجود هرې معلومات — په شمول د Agora رلی او Agora Blossom سرور څخه — چې د څیړنو سره مرسته کولی شي، د قابل پلي قانون مطابق وړاندې کړو.
|
||||
- هغه ځانګړي د رلی URL او د فایل سرور URL پېژندو او شریکوو چې ګناهکاره محتوا پکې لیدل شوې وي، ترڅو قانون پلي کوونکي په مستقیم ډول د هغو چلونکو سره اړیکه ونیسي.
|
||||
- د یوې اعتباري قانوني غوښتنې له ترلاسه کولو وروسته به هرې شته شواهد یا معلومات وساتو.
|
||||
- پېژندلشوي CSAE مواد به په فعاله توګه NCMEC او نورو اړوندو چارواکو ته راپور کړو.
|
||||
|
||||
## د نامرکزي معماري ملاحظې
|
||||
|
||||
د Nostr نامرکزي طبیعت یې پدې مانا دی چې هیڅ یوازینی موجود د شبکې پر ټولې محتوا بشپړ کنټرول نه لري. {{appName}} لاندې واقعیات او زموږ تګلاره یې د هر یوه په وړاندې منلې:
|
||||
|
||||
- **زموږ پر خپله زیربنا بشپړ کنټرول:** موږ کولای شو او کوو چې محتوا د Agora رلی او Agora Blossom سرور څخه لرې کړو. زموږ په زیربنا کې میندلشوي CSAE مواد سمدستي حذف کېږي او حسابونه دایمي بندېږي.
|
||||
- **پر دریمو ډلو رلیګانو محدود کنټرول:** موږ نشو کولای د دریمو ډلو رلیګانو محتوا حذف کړو. خو موږ زموږ په اپلیکیشن دننه د کلاینټ په کچه فلټرونو او بلاکلیستونو له لارې د دغه محتوا د ښودلو مخه نیسو.
|
||||
- **کاروونکي خپلې رلی اړیکې کنټرولوي:** که څه هم کاروونکي کولای شي خپلې غوره رلیګانې وکاروي، {{appName}} دا حق ساتي چې له هغو رلیګانو سره اړیکه بنده کړي چې پېژندل شوي وي چې CSAE محتوا میزباني کوي.
|
||||
- **عمومي کليګانې نام مستعار دي:** د Nostr حسابونه د کرپټوګرافیک کليګانو جوړو لخوا پېژندل کېږي نه د تائید شویو هویتونو لخوا. خو بیا هم موږ به ګناهکار کليګانې بنده او راپور کړو او د دوی شاته اشخاصو د پېژندنې لپاره به له قانون پلي کوونکو سره همکاري وکړو.
|
||||
|
||||
## اعتراضات
|
||||
|
||||
که تاسو فکر کوئ ستاسو محتوا یا حساب د دې پالیسي لاندې په غلط ډول نښه شوی یا بند شوی، تاسو کولای شئ په [soapbox.pub](https://soapbox.pub) کې له موږ سره د بیاکتنې غوښتنې لپاره اړیکه ونیسئ. موږ به اعتراضات د قضیه په قضیه اساس وارزوو. خو په ټولو پرېکړو کې موږ د ماشوم خوندیتوب ته ترجیح ورکوو، او زموږ پرېکړه وروستۍ ده.
|
||||
|
||||
## د دې پالیسي بدلونونه
|
||||
|
||||
موږ ممکن د خپلو وسایلو، پروسو او د Nostr اکوسیستم د پرمختګ سره د ماشومانو د خوندیتوب پالیسي تازه کړو. بدلونونه به په دې پاڼه کې د تازهشوې نېټې سره منعکس شي. موږ د CSAE محتوا د کشف، مخنیوي او ځواب ورکولو په برخه کې د خپل وړتیا د دوامداره ښه کولو ته ژمن یو.
|
||||
|
||||
## اړیکه
|
||||
|
||||
د دې پالیسي په اړه پوښتنو یا د CSAE محتوا راپور لپاره، د {{appName}} شاته ټیم سره د [soapbox.pub](https://soapbox.pub) له لارې اړیکه ونیسئ.
|
||||
@@ -0,0 +1,90 @@
|
||||
_Última atualização: 19 de março de 2026_
|
||||
|
||||
## Nosso compromisso
|
||||
|
||||
O {{appName}} aplica uma **política de tolerância zero** em relação a material de abuso e exploração sexual infantil (CSAE). A segurança das crianças é primordial, e estamos comprometidos a fazer tudo dentro do nosso poder como aplicativo cliente para prevenir a distribuição, promoção ou facilitação de conteúdo CSAE através do nosso aplicativo.
|
||||
|
||||
Esta política se aplica a todo o conteúdo acessível através do {{appName}}, incluindo texto, imagens, vídeos, links e qualquer outra mídia. Cobre todas as formas de CSAE, incluindo mas não se limitando a imagens, solicitação, aliciamento, tráfico e sexualização de menores.
|
||||
|
||||
## Como o {{appName}} funciona
|
||||
|
||||
O {{appName}} é um **aplicativo cliente** para o protocolo Nostr, uma rede de comunicação aberta e descentralizada. Entender a arquitetura é um contexto importante para esta política:
|
||||
|
||||
- **Nossa infraestrutura:** Operamos o **relay Agora** e o **servidor Blossom Agora**, que servem como o relay e host de arquivos padrão para o {{appName}}. Temos controle total de moderação sobre o conteúdo armazenado nesses serviços.
|
||||
- **Relays de terceiros:** Os usuários também podem se conectar a relays Nostr adicionais operados por terceiros independentes. O {{appName}} busca e exibe conteúdo de quaisquer relays aos quais o usuário esteja conectado. Não temos controle de moderação sobre relays de terceiros, mas controlamos o que o aplicativo exibe.
|
||||
- **Servidores de mídia de terceiros:** Os usuários podem enviar imagens e vídeos para servidores de arquivos compatíveis com Blossom de terceiros. Não operamos ou moderamos esses serviços externos.
|
||||
|
||||
Assumimos total responsabilidade pela experiência dentro do nosso aplicativo. Em nossa própria infraestrutura (relay Agora e servidor Blossom Agora), podemos remover diretamente o conteúdo e banir contas infratoras. Para conteúdo originário de serviços de terceiros, ativamente o bloqueamos de ser exibido dentro do {{appName}}.
|
||||
|
||||
## Conteúdo e comportamento proibidos
|
||||
|
||||
O seguinte é estritamente proibido no {{appName}}. Usuários encontrados se envolvendo em qualquer um dos seguintes estarão sujeitos a ação imediata:
|
||||
|
||||
- **CSAM (material de abuso sexual infantil):** Qualquer representação visual de conduta sexual explícita envolvendo um menor, incluindo fotografias, vídeos e imagens geradas digitalmente ou por IA.
|
||||
- **Aliciamento:** Qualquer tentativa de construir um relacionamento com um menor com o propósito de exploração sexual ou abuso.
|
||||
- **Solicitação:** Solicitar, oferecer ou facilitar a troca de material CSAE ou contato sexual com menores.
|
||||
- **Sexualização de menores:** Conteúdo que sexualiza menores, incluindo comentários sugestivos ou sexuais sobre crianças, mesmo que nenhuma imagem explícita esteja envolvida.
|
||||
- **Tráfico:** Qualquer conteúdo que facilite, promova ou coordene o tráfico de menores para fins sexuais.
|
||||
- **Links e referências:** Compartilhar links para sites externos ou recursos contendo material CSAE, ou fornecer instruções sobre como encontrar ou produzir tal material.
|
||||
|
||||
## Detecção e prevenção
|
||||
|
||||
O {{appName}} implementa múltiplas camadas de proteção para combater CSAE:
|
||||
|
||||
- **Filtragem de conteúdo:** Mantemos e aplicamos mecanismos de filtragem de conteúdo dentro do aplicativo para bloquear material CSAE conhecido de ser exibido, independentemente de qual relay seja originário.
|
||||
- **Denúncia por usuários:** Fornecemos ferramentas de denúncia no aplicativo que permitem aos usuários sinalizar conteúdo CSAE suspeito para revisão imediata.
|
||||
- **Moderação do relay Agora:** Em nosso próprio relay Agora, moderamos ativamente o conteúdo e removeremos imediatamente qualquer material CSAE e baniremos permanentemente as contas associadas.
|
||||
- **Moderação do servidor Blossom Agora:** Em nosso próprio servidor de arquivos Blossom Agora, excluiremos imediatamente qualquer mídia CSAE e baniremos a conta que fez o upload.
|
||||
- **Bloqueio de relays de terceiros:** Relays de terceiros conhecidos por hospedar ou tolerar material CSAE podem ser removidos da lista de relays padrão do {{appName}} e bloqueados de serem adicionados por usuários.
|
||||
- **Ferramentas de silenciar e bloquear:** Os usuários podem silenciar ou bloquear contas no nível do cliente, impedindo que o conteúdo dessas contas apareça em seu feed.
|
||||
|
||||
## Ações de aplicação
|
||||
|
||||
Quando conteúdo ou comportamento CSAE é identificado, o {{appName}} tomará as seguintes ações conforme aplicável:
|
||||
|
||||
- **Bloqueio imediato de conteúdo:** Conteúdo CSAE conhecido será bloqueado de renderizar no aplicativo através de filtros de conteúdo e listas de bloqueio.
|
||||
- **Remoção da infraestrutura Agora:** Conteúdo CSAE no relay Agora e no servidor Blossom Agora será imediatamente excluído, e as contas associadas permanentemente banidas.
|
||||
- **Bloqueio de contas:** Chaves públicas Nostr associadas à atividade CSAE serão adicionadas a listas de bloqueio em nível de aplicativo, impedindo que seu conteúdo apareça no {{appName}} independentemente de qual relay seja buscado.
|
||||
- **Bloqueio de relays:** Relays de terceiros que falham em abordar conteúdo CSAE podem ser removidos da lista de relays padrão do {{appName}} e bloqueados de serem adicionados por usuários.
|
||||
- **Denúncia às autoridades:** Denunciaremos material CSAE identificado ao [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) através da CyberTipline, e às agências de aplicação da lei aplicáveis.
|
||||
|
||||
## Denúncia de conteúdo CSAE
|
||||
|
||||
Se você encontrar algum conteúdo no {{appName}} que acredita constituir abuso ou exploração sexual infantil, por favor denuncie imediatamente:
|
||||
|
||||
- **Denúncia no aplicativo:** Use o botão de denúncia disponível em qualquer publicação ou perfil de usuário para sinalizar conteúdo para revisão.
|
||||
- **Entre em contato conosco diretamente:** Entre em contato com nossa equipe em [soapbox.pub](https://soapbox.pub) com detalhes do conteúdo, incluindo quaisquer IDs de eventos Nostr relevantes ou chaves públicas.
|
||||
- **Denunciar ao NCMEC:** Você também pode arquivar um relatório diretamente com a [NCMEC CyberTipline](https://www.missingkids.org/gethelpnow/cybertipline).
|
||||
- **Contatar autoridades policiais:** Se você acredita que uma criança está em perigo imediato, contate sua polícia local ou ligue **911** (EUA) imediatamente.
|
||||
|
||||
Todas as denúncias de conteúdo CSAE são tratadas com a maior prioridade e serão revisadas o mais rápido possível.
|
||||
|
||||
## Cooperação com autoridades policiais
|
||||
|
||||
O {{appName}} está comprometido em cooperar totalmente com agências de aplicação da lei investigando CSAE. Embora o {{appName}} não armazene conteúdo de usuário em seus próprios servidores, nós:
|
||||
|
||||
- Forneceremos qualquer informação disponível para nós — incluindo dados do relay Agora e do servidor Blossom Agora — que possa ajudar em investigações, de acordo com a lei aplicável.
|
||||
- Identificaremos e compartilharemos as URLs específicas de relays e servidores de arquivos onde o conteúdo infrator foi observado, para que as autoridades policiais possam contatar esses operadores diretamente.
|
||||
- Preservaremos qualquer evidência ou informação disponível ao receber uma solicitação legal válida.
|
||||
- Denunciaremos material CSAE identificado ao NCMEC e outras autoridades relevantes proativamente.
|
||||
|
||||
## Considerações sobre arquitetura descentralizada
|
||||
|
||||
A natureza descentralizada do Nostr significa que nenhuma entidade única tem controle completo sobre todo o conteúdo na rede. O {{appName}} reconhece as seguintes realidades e nossa abordagem para cada uma:
|
||||
|
||||
- **Controle total sobre nossa própria infraestrutura:** Podemos e removemos conteúdo do relay Agora e do servidor Blossom Agora. Material CSAE encontrado em nossa infraestrutura é excluído imediatamente e contas são permanentemente banidas.
|
||||
- **Controle limitado sobre relays de terceiros:** Não podemos excluir conteúdo de relays de terceiros. No entanto, bloqueamos esse conteúdo de ser exibido dentro do nosso aplicativo através de filtros e listas de bloqueio em nível de cliente.
|
||||
- **Usuários controlam suas conexões de relay:** Embora os usuários possam se conectar a relays de sua escolha, o {{appName}} reserva o direito de bloquear conexões a relays conhecidos por hospedar conteúdo CSAE.
|
||||
- **Chaves públicas são pseudônimas:** Contas Nostr são identificadas por pares de chaves criptográficas em vez de identidades verificadas. Ainda assim, bloquearemos e denunciaremos chaves infratoras e cooperaremos com a polícia para identificar os indivíduos por trás delas.
|
||||
|
||||
## Recursos
|
||||
|
||||
Se você acredita que seu conteúdo ou conta foi sinalizado ou bloqueado incorretamente sob esta política, você pode nos contatar em [soapbox.pub](https://soapbox.pub) para solicitar uma revisão. Avaliaremos recursos caso a caso. No entanto, erramos do lado da segurança infantil em todas as decisões, e nossa determinação é final.
|
||||
|
||||
## Alterações nesta política
|
||||
|
||||
Podemos atualizar esta política de segurança infantil à medida que nossas ferramentas, processos e o ecossistema Nostr evoluem. As alterações serão refletidas nesta página com uma data atualizada. Estamos comprometidos em melhorar continuamente nossa capacidade de detectar, prevenir e responder a conteúdo CSAE.
|
||||
|
||||
## Contato
|
||||
|
||||
Para dúvidas sobre esta política ou para denunciar conteúdo CSAE, entre em contato com a equipe por trás do {{appName}} em [soapbox.pub](https://soapbox.pub).
|
||||
@@ -0,0 +1,90 @@
|
||||
_Последнее обновление: 19 марта 2026 г._
|
||||
|
||||
## Наша приверженность
|
||||
|
||||
{{appName}} применяет **политику абсолютной нетерпимости** к материалам сексуального насилия и эксплуатации детей (CSAE). Безопасность детей имеет первостепенное значение, и мы привержены делать всё в нашей власти как клиентское приложение, чтобы предотвратить распространение, продвижение или содействие контенту CSAE через наше приложение.
|
||||
|
||||
Эта политика применяется ко всему контенту, доступному через {{appName}}, включая текст, изображения, видео, ссылки и любые другие медиа. Она охватывает все формы CSAE, включая, но не ограничиваясь, изображения, склонение, груминг, торговлю людьми и сексуализацию несовершеннолетних.
|
||||
|
||||
## Как работает {{appName}}
|
||||
|
||||
{{appName}} — это **клиентское приложение** для протокола Nostr, открытой децентрализованной коммуникационной сети. Понимание архитектуры — важный контекст для этой политики:
|
||||
|
||||
- **Наша инфраструктура:** Мы управляем **реле Agora** и **сервером Blossom Agora**, которые служат реле по умолчанию и хостом файлов для {{appName}}. У нас есть полный контроль модерации над контентом, хранящимся на этих сервисах.
|
||||
- **Реле третьих сторон:** Пользователи также могут подключаться к дополнительным реле Nostr, управляемым независимыми третьими сторонами. {{appName}} получает и отображает контент с любых реле, к которым подключён пользователь. У нас нет контроля модерации над реле третьих сторон, но мы контролируем то, что отображает приложение.
|
||||
- **Медиа-серверы третьих сторон:** Пользователи могут загружать изображения и видео на сторонние файловые серверы, совместимые с Blossom. Мы не управляем и не модерируем эти внешние сервисы.
|
||||
|
||||
Мы берём на себя полную ответственность за опыт внутри нашего приложения. На нашей собственной инфраструктуре (реле Agora и сервер Blossom Agora) мы можем напрямую удалять контент и банить аккаунты-нарушители. Для контента, происходящего из сервисов третьих сторон, мы активно блокируем его отображение в {{appName}}.
|
||||
|
||||
## Запрещённый контент и поведение
|
||||
|
||||
Следующее строго запрещено в {{appName}}. Пользователи, замеченные в любом из следующего, будут подвергнуты немедленным мерам:
|
||||
|
||||
- **CSAM (материалы сексуального насилия над детьми):** Любое визуальное изображение сексуального поведения с участием несовершеннолетнего, включая фотографии, видео и цифровые или сгенерированные ИИ изображения.
|
||||
- **Груминг:** Любая попытка построить отношения с несовершеннолетним с целью сексуальной эксплуатации или насилия.
|
||||
- **Склонение:** Запрос, предложение или содействие обмену материалами CSAE или сексуальному контакту с несовершеннолетними.
|
||||
- **Сексуализация несовершеннолетних:** Контент, сексуализирующий несовершеннолетних, включая намекающие или сексуальные комментарии о детях, даже если явные изображения не задействованы.
|
||||
- **Торговля людьми:** Любой контент, который содействует, продвигает или координирует торговлю несовершеннолетними в сексуальных целях.
|
||||
- **Ссылки и упоминания:** Распространение ссылок на внешние сайты или ресурсы, содержащие материалы CSAE, или предоставление инструкций о том, как найти или произвести такие материалы.
|
||||
|
||||
## Обнаружение и предотвращение
|
||||
|
||||
{{appName}} реализует несколько уровней защиты для борьбы с CSAE:
|
||||
|
||||
- **Фильтрация контента:** Мы поддерживаем и обеспечиваем работу механизмов фильтрации контента внутри приложения, чтобы блокировать отображение известных материалов CSAE, независимо от того, с какого реле они происходят.
|
||||
- **Сообщения пользователей:** Мы предоставляем встроенные в приложение инструменты сообщений, которые позволяют пользователям отмечать подозрительный контент CSAE для немедленной проверки.
|
||||
- **Модерация реле Agora:** На нашем собственном реле Agora мы активно модерируем контент и немедленно удаляем любые материалы CSAE и навсегда баним связанные аккаунты.
|
||||
- **Модерация сервера Blossom Agora:** На нашем собственном файловом сервере Blossom Agora мы немедленно удалим любые медиа CSAE и забаним загрузивший аккаунт.
|
||||
- **Блокировка реле третьих сторон:** Реле третьих сторон, известные тем, что размещают или терпят материалы CSAE, могут быть удалены из списка реле {{appName}} по умолчанию и заблокированы для добавления пользователями.
|
||||
- **Инструменты заглушения и блокировки:** Пользователи могут заглушать или блокировать аккаунты на уровне клиента, предотвращая появление контента с этих аккаунтов в их ленте.
|
||||
|
||||
## Меры принуждения
|
||||
|
||||
При обнаружении контента или поведения CSAE {{appName}} предпримет следующие меры по мере необходимости:
|
||||
|
||||
- **Немедленная блокировка контента:** Известный контент CSAE будет заблокирован от отображения в приложении через фильтры контента и списки блокировки.
|
||||
- **Удаление с инфраструктуры Agora:** Контент CSAE на реле Agora и сервере Blossom Agora будет немедленно удалён, а связанные аккаунты навсегда забанены.
|
||||
- **Блокировка аккаунтов:** Публичные ключи Nostr, связанные с активностью CSAE, будут добавлены в списки блокировки на уровне приложения, предотвращая появление их контента в {{appName}} независимо от того, с какого реле он получен.
|
||||
- **Блокировка реле:** Реле третьих сторон, которые не справляются с контентом CSAE, могут быть удалены из списка реле {{appName}} по умолчанию и заблокированы для добавления пользователями.
|
||||
- **Сообщение властям:** Мы будем сообщать об идентифицированных материалах CSAE в [Национальный центр для пропавших и эксплуатируемых детей (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) через CyberTipline и в применимые правоохранительные органы.
|
||||
|
||||
## Сообщение о контенте CSAE
|
||||
|
||||
Если вы столкнулись с каким-либо контентом в {{appName}}, который, по вашему мнению, представляет собой сексуальное насилие или эксплуатацию детей, пожалуйста, немедленно сообщите об этом:
|
||||
|
||||
- **Сообщение в приложении:** Используйте кнопку сообщения, доступную на любой публикации или профиле пользователя, чтобы отметить контент для проверки.
|
||||
- **Свяжитесь с нами напрямую:** Обратитесь к нашей команде на [soapbox.pub](https://soapbox.pub) с подробностями контента, включая любые соответствующие ID событий Nostr или публичные ключи.
|
||||
- **Сообщите в NCMEC:** Вы также можете подать сообщение напрямую в [NCMEC CyberTipline](https://www.missingkids.org/gethelpnow/cybertipline).
|
||||
- **Свяжитесь с правоохранительными органами:** Если вы считаете, что ребёнок находится в непосредственной опасности, немедленно свяжитесь с местными правоохранительными органами или позвоните **911** (США).
|
||||
|
||||
Все сообщения о контенте CSAE рассматриваются с наивысшим приоритетом и будут рассмотрены как можно быстрее.
|
||||
|
||||
## Сотрудничество с правоохранительными органами
|
||||
|
||||
{{appName}} обязуется полностью сотрудничать с правоохранительными органами, расследующими CSAE. Хотя {{appName}} не хранит пользовательский контент на собственных серверах, мы:
|
||||
|
||||
- Предоставим любую доступную нам информацию — включая данные с реле Agora и сервера Blossom Agora — которая может помочь в расследованиях, в соответствии с применимым законодательством.
|
||||
- Идентифицируем и поделимся конкретными URL реле и серверов файлов, где был обнаружен нарушающий контент, чтобы правоохранительные органы могли напрямую связаться с этими операторами.
|
||||
- Сохраним любые доступные доказательства или информацию по получении действительного юридического запроса.
|
||||
- Будем проактивно сообщать об идентифицированных материалах CSAE в NCMEC и другие соответствующие органы.
|
||||
|
||||
## Соображения о децентрализованной архитектуре
|
||||
|
||||
Децентрализованная природа Nostr означает, что ни одно отдельное юридическое лицо не имеет полного контроля над всем контентом в сети. {{appName}} признаёт следующие реалии и наш подход к каждой из них:
|
||||
|
||||
- **Полный контроль над нашей собственной инфраструктурой:** Мы можем и удаляем контент с реле Agora и сервера Blossom Agora. Материалы CSAE, найденные на нашей инфраструктуре, удаляются немедленно, а аккаунты навсегда банятся.
|
||||
- **Ограниченный контроль над реле третьих сторон:** Мы не можем удалить контент с реле третьих сторон. Однако мы блокируем такой контент от отображения в нашем приложении через фильтры на уровне клиента и списки блокировки.
|
||||
- **Пользователи контролируют свои подключения к реле:** Хотя пользователи могут подключаться к реле по своему выбору, {{appName}} оставляет за собой право блокировать подключения к реле, известным тем, что размещают контент CSAE.
|
||||
- **Публичные ключи являются псевдонимами:** Аккаунты Nostr идентифицируются криптографическими парами ключей, а не проверенными идентичностями. Мы всё равно будем блокировать и сообщать о нарушающих ключах и сотрудничать с правоохранительными органами для идентификации лиц, стоящих за ними.
|
||||
|
||||
## Апелляции
|
||||
|
||||
Если вы считаете, что ваш контент или аккаунт были неправильно отмечены или заблокированы в соответствии с этой политикой, вы можете связаться с нами на [soapbox.pub](https://soapbox.pub), чтобы запросить проверку. Мы будем оценивать апелляции в каждом конкретном случае. Однако мы склоняемся в сторону безопасности детей во всех решениях, и наше решение является окончательным.
|
||||
|
||||
## Изменения в этой политике
|
||||
|
||||
Мы можем обновлять эту политику безопасности детей по мере развития наших инструментов, процессов и экосистемы Nostr. Изменения будут отражены на этой странице с обновлённой датой. Мы привержены постоянному улучшению нашей способности обнаруживать, предотвращать и реагировать на контент CSAE.
|
||||
|
||||
## Контакт
|
||||
|
||||
По вопросам об этой политике или для сообщения о контенте CSAE свяжитесь с командой за {{appName}} на [soapbox.pub](https://soapbox.pub).
|
||||
@@ -0,0 +1,90 @@
|
||||
_Yagara musi: 19 Kurume 2026_
|
||||
|
||||
## Kuzvipira Kwedu
|
||||
|
||||
{{appName}} ine **mutemo wekusatambira zvachose** kumavhidhio nemashoko ekuchinjirira uye kushandiswa kwepabonde kwevana (CSAE). Kuchengetedzeka kwevana ndiko kwakanyanya kukosha, uye tinozvipira kuita zvese zviri musimba redu sechishandiso chemutengi kuti tidzivirire kuparadzirwa, kusimudzirwa, kana kurerusa kwemavhidhio eCSAE kuburikidza nechishandiso chedu.
|
||||
|
||||
Mutemo uyu unoshanda kune zvese zvinooneka kuburikidza ne{{appName}}, kusanganisira zvinyorwa, mifananidzo, mavhidhio, masaini, neimwe miti yose. Inovharira mhando dzese dzeCSAE, kusanganisira asi isina kuganhurirwa kumifananidzo, kunyengetera, kunyengera (grooming), kutengeseswa, uye kushandiswa kwevana sezviri zvepabonde.
|
||||
|
||||
## Kushanda kwe{{appName}}
|
||||
|
||||
{{appName}} **chishandiso chemutengi** chepurotokoro yeNostr, donhodzo rakavhurika risina nzvimbo huru rekurukurirano. Kunzwisisa zvakavakwa kwacho izvozvo zvakakosha kumutemo uyu:
|
||||
|
||||
- **Zvivakwa zvedu:** Tinochengeta **rerai yeAgora** ne**Agora Blossom server**, izvo zvinoshanda serai rinozvimirira nemuchengeti wefaira we{{appName}}. Tine simba rakazara rekutarisira zvinhu zvakachengetwa pamabasa aya.
|
||||
- **Marerai evevamwe:** Vashandisi vanogona kubatanidzwawo neimwe Nostr rerai inoshandiswa nevamwe vakazvimirira. {{appName}} inotora uye inoratidza zvinhu zvinobva kumarerai api zvawo aakabatana navo mushandisi. Hatina simba rekutarisira marerai evevamwe, asi tinotonga zvinoratidzwa nechishandiso.
|
||||
- **Maservers emawero evamwe:** Vashandisi vanogona kutumira mifananidzo nemavhidhio kuwakavandudzika neBlossom maservers evevamwe. Hatishandise kana kutarisira aya mabasa ekunze.
|
||||
|
||||
Tinotora mutoro wakakwana wezvinoitika muchishandiso chedu. Pazvivakwa zvedu (Agora rerai uye Agora Blossom server), tinogona kubvisa zvinhu zvakananga nekudzima makaundi anodarika. Kune zvinhu zvinobva kumabasa evevamwe, tinotambira kuvharira kuratidzwa kwawo mukati me{{appName}}.
|
||||
|
||||
## Zvinhu Nezvinoitika Zvisingatenderwi
|
||||
|
||||
Zvinotevera zvakanyatsobviswa pa{{appName}}. Vashandisi vanowanikwa vachiita chero chinotevera vachaitirwa zvinhu pakarepo:
|
||||
|
||||
- **CSAM (Mishonga yeKushandiswa Kwepabonde Kwevana):** Chero mufananidzo wezviitiko zvepabonde zvine mwana, kusanganisira mifananidzo, mavhidhio, nemifananidzo yakagadzirwa nedigitari kana neAI.
|
||||
- **Grooming:** Chero kuedza kuvaka hukama nemwana neichinda chekushandisa kana kuchinjira kwepabonde.
|
||||
- **Kutsvaga:** Kukumbira, kupa, kana kurerusa kuchinjana kwezvinhu zveCSAE kana kusangana kwepabonde nevana.
|
||||
- **Kushandisa vana sezviri zvepabonde:** Zvinhu zvinoshandisa vana sezviri zvepabonde, kusanganisira zvirevo zvinokurudzira kana zvepabonde pamusoro pevana, kunyange pasina mifananidzo iri pachena.
|
||||
- **Kutengeseswa:** Chero chinhu chinorerusa, chinosimudzira, kana chinoronga kutengeseswa kwevana kweichinda chepabonde.
|
||||
- **Masaini nezvinhu zvevamwe:** Kugovera masaini kumasaiti ekunze kana mavambo ane mishonga yeCSAE, kana kupa mirayiridzo yekuti unogona kuwana kana kugadzira sei mishonga yakadaro.
|
||||
|
||||
## Kuwana neKudzivirira
|
||||
|
||||
{{appName}} inoshandisa matsetse akawanda ekudzivirira kurwisa CSAE:
|
||||
|
||||
- **Kusefa kwezvinhu:** Tinochengetedza nekushandisa nzira dzekusefa zvinhu mukati mechishandiso kudzivirira mishonga yeCSAE inozivikanwa pakuratidzwa, pasinei kuti inobva kurerai ipi zvayo.
|
||||
- **Kushuma kwemushandisi:** Tinopa zvishandiso zvekushuma mukati meapp kupa mvumo kuvashandisi kupa chiratidzo kuzvinhu zvinofungidzirwa zveCSAE kuti zvionongorwe pakarepo.
|
||||
- **Kutarisira kweAgora rerai:** Pa-Agora rerai redu pachedu, tinotarisira zvinhu nemwoyo wese uye ticharumura chero mishonga yeCSAE pakarepo nekuvharira makaundi anobatana nokusingaperi.
|
||||
- **Kutarisira kweAgora Blossom server:** Pasever yedu yefaira yeAgora Blossom, tichabvisa chero mediya yeCSAE pakarepo uye kuvharira akaundi rakaiteresera.
|
||||
- **Kuvharira marerai evevamwe:** Marerai evevamwe anozivikanwa kuti ano-host kana ano-tolerate mishonga yeCSAE anogona kubviswa pa-default rerai list ye{{appName}} uye kuvharirwa kubva kuwedzerwa nevashandisi.
|
||||
- **Zvishandiso zvekuvharira nekunyaradza:** Vashandisi vanogona kunyaradza kana kuvharira makaundi pamutsetse wemutengi, vachidzivirira zvinhu kubva kumakaundi iwayo kuti zvisaonekwe mufid yavo.
|
||||
|
||||
## Zviito Zvekushandisa
|
||||
|
||||
Kana zvinhu kana maitiro eCSAE awanikwa, {{appName}} ichatora zviito zvinotevera sezvinodikanwa:
|
||||
|
||||
- **Kuvharira kwezvinhu pakarepo:** Zvinhu zveCSAE zvinozivikanwa zvichavharirwa kubva pakuratidzwa muchishandiso kuburikidza nezvisefedzo zvezvinhu uye marondedzero ekuvharira.
|
||||
- **Kubvisa kubva muzvivakwa zveAgora:** Zvinhu zveCSAE pa-Agora rerai uye Agora Blossom server zvichabviswa pakarepo, uye makaundi anobatana acharamwadzwa nokusingaperi.
|
||||
- **Kuvharira makaundi:** Makey everuzhinji eNostr anobatana neuchengetedzi hweCSAE acharangarwa kumarondedzero ekuvharira mutsetse weapp, achidzivirira zvinhu zvavo kubva pakuoneka mu{{appName}} pasinei kuti zvinobva kurerai ipi zvayo.
|
||||
- **Kuvharira rerai:** Marerai evevamwe anokundikana kugadzirisa zvinhu zveCSAE anogona kubviswa kubva pa-default rerai list ye{{appName}} uye kuvharirwa kubva kuwedzerwa nevashandisi.
|
||||
- **Kushuma kuvakuru:** Tichashuma mishonga yeCSAE yakaonekwa ku[National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) kuburikidza neCyberTipline, uye kumamishonga ekushandisa mutemo ane simba.
|
||||
|
||||
## Kushuma Zvinhu zveCSAE
|
||||
|
||||
Kana ukasangana nezvinhu pa{{appName}} zvaunofunga zvinoumba kushandisa kwepabonde kana kuchinjira kwevana, ndapota zvishumire pakarepo:
|
||||
|
||||
- **Kushuma mukati meapp:** Shandisa bhatani rekushuma riripo papost ipi zvayo kana papurofiri yemushandisi kupa chiratidzo kuzvinhu zvekuongororwa.
|
||||
- **Tibatei zvakananga:** Bata timu yedu pa[soapbox.pub](https://soapbox.pub) nezvirevo zvezvinhu, kusanganisira chero ma-ID eNostr event kana makey everuzhinji anobatanidzwa.
|
||||
- **Shumira NCMEC:** Unogona zvakare kuburitsa shumo zvakananga ne[NCMEC CyberTipline](https://www.missingkids.org/gethelpnow/cybertipline).
|
||||
- **Bata vekushandisa mutemo:** Kana uchifunga kuti mwana ari munjodzi yepakarepo, bata vekushandisa mutemo venzvimbo yenyu kana kufona pa**911** (US) pakarepo.
|
||||
|
||||
Shumo dzese dzezvinhu zveCSAE dzinobatwa nehurongwa hwepamusoro uye dzichaongororwa nokukurumidza sezvazvinogoneka.
|
||||
|
||||
## Kushanda Pamwe nevekushandisa Mutemo
|
||||
|
||||
{{appName}} yakazvipira kushanda pamwe zvakakwana nevemishonga yekushandisa mutemo iri kuferefeta CSAE. Kunyange {{appName}} isingachengeti zvinhu zvemushandisi pamaserver ayo pachayo, tichaita zvinotevera:
|
||||
|
||||
- Kupa chero ruzivo runowanikwa kwatiri — kusanganisira data inobva kuAgora rerai uye Agora Blossom server — inogona kubatsira kuferefeta, maererano nemutemo unoshandiswa.
|
||||
- Kuziva nekugovera maURL chaiwo erelay nemaURL emaservers efaira kwakaoneswa zvinhu zvinodarika, kuitira kuti vekushandisa mutemo vagone kubata zvakananga nevashandisi ivavo.
|
||||
- Kuchengetedza chero humbowo kana ruzivo runowanikwa pakugamuchira chikumbiro chemutemo chinokwanisika.
|
||||
- Kushumira mishonga yeCSAE yakaonekwa kuNCMEC nevamwe vakuru vakakodzera nemwoyo wese.
|
||||
|
||||
## Pfungwa Pamusoro peZvakavakwa Zvisina Nzvimbo Huru
|
||||
|
||||
Hunhu hwekusava nenzvimbo huru hweNostr kunoreva kuti hapana boka rimwe rine simba rakakwana pamusoro pezvinhu zvese pasaiti. {{appName}} inogamuchira chokwadi chinotevera nenzira yedu kune chimwe nechimwe:
|
||||
|
||||
- **Simba rakakwana pamusoro pezvivakwa zvedu pachedu:** Tinogona uye tinobvisa zvinhu kubva paAgora rerai uye Agora Blossom server. Mishonga yeCSAE yakawanikwa pazvivakwa zvedu inobviswa pakarepo uye makaundi anorambwa nokusingaperi.
|
||||
- **Simba rakangotemerwa pamarerai evevamwe:** Hatigone kubvisa zvinhu kubva kumarerai evevamwe. Asi, tinovharira zvinhu zvakadaro kubva pakuratidzwa mukati meapp yedu kuburikidza nezvisefedzo nezvirondedzero zvekuvharira pamutsetse wemutengi.
|
||||
- **Vashandisi vanodzora kubatana kwavo kurerai:** Kunyange vashandisi vachigona kubatana kumarerai avanenge vasarudza, {{appName}} inochengetedza kodzero yekuvharira kubatana kumarerai anozivikanwa kuti ano-host zvinhu zveCSAE.
|
||||
- **Makey everuzhinji mazita ekunyepedzera:** Makaundi eNostr anoonekwa nemapeya emakey ekriptiografi, pane hunhu hwakasimbiswa. Asi bedzi tichavharira nekushumira makey anodarika uye kushanda pamwe nevekushandisa mutemo kuti tizive vanhu vari kumashure kwawo.
|
||||
|
||||
## Kukwirira
|
||||
|
||||
Kana uchifunga kuti zvinhu zvako kana akaundi yako yakapiwa chiratidzo kana kuvharirwa zvisina kunaka pasi pemutemo uyu, unogona kutibata pa[soapbox.pub](https://soapbox.pub) kukumbira kuongororwa. Tichaongorora kukwirira kunoenderera nedambudziko rega rega. Asi, tinotsigira ruzivo rwekuchengetedza vana muzvese zvatinosarudza, uye sarudzo yedu ndiyo yekupedzisira.
|
||||
|
||||
## Shanduko padzidziso iyi
|
||||
|
||||
Tinogona kuvandudza mutemo wekuchengetedza vana uyu sezvo zvishandiso zvedu, maitiro, nesangano reNostr richikura. Shanduko dzichaonekwa papeji ino nezuva rakavandudzwa. Takazvipira kuvandudza nguva dzese kugona kwedu kuwana, kudzivirira, nekupindura kuzvinhu zveCSAE.
|
||||
|
||||
## Kuonana
|
||||
|
||||
Kuti uite mibvunzo pamusoro pemutemo uyu kana kushuma zvinhu zveCSAE, bata timu iri kumashure kwe{{appName}} pa[soapbox.pub](https://soapbox.pub).
|
||||
@@ -0,0 +1,90 @@
|
||||
_最后更新:2026 年 3 月 19 日_
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
{{appName}} 对儿童性虐待和剥削(CSAE)相关内容采取**零容忍政策**。儿童的安全至关重要,作为一款客户端应用,我们承诺将竭尽所能,防止通过本应用传播、推广或便利 CSAE 内容。
|
||||
|
||||
本政策适用于通过 {{appName}} 可访问的所有内容,包括文字、图像、视频、链接以及其他任何媒体。它涵盖所有形式的 CSAE,包括但不限于影像、招揽、诱骗、贩运以及对未成年人的性化。
|
||||
|
||||
## {{appName}} 的工作方式
|
||||
|
||||
{{appName}} 是 Nostr 协议的**客户端应用**,Nostr 是一个开放、去中心化的通信网络。理解其架构对于本政策的语境十分重要:
|
||||
|
||||
- **我们的基础设施:** 我们运营 **Agora 中继**和 **Agora Blossom 服务器**,作为 {{appName}} 的默认中继和文件托管服务。我们对存储在这些服务上的内容拥有完整的审核控制权。
|
||||
- **第三方中继:** 用户也可以连接由独立第三方运营的其他 Nostr 中继。{{appName}} 会从用户所连接的任意中继获取并渲染内容。我们对第三方中继没有审核控制权,但可以控制应用所显示的内容。
|
||||
- **第三方媒体服务器:** 用户可以将图片和视频上传至与 Blossom 兼容的第三方文件服务器。我们既不运营也不审核这些外部服务。
|
||||
|
||||
我们对应用内的体验承担全部责任。在我们自有的基础设施(Agora 中继和 Agora Blossom 服务器)上,我们可以直接删除内容并封禁违规账户。对于源自第三方服务的内容,我们会主动阻止其在 {{appName}} 中显示。
|
||||
|
||||
## 禁止的内容与行为
|
||||
|
||||
以下内容在 {{appName}} 上被严格禁止。任何被发现参与下列行为的用户都将受到立即处置:
|
||||
|
||||
- **CSAM(儿童性虐待材料):** 任何涉及未成年人的露骨性行为视觉描绘,包括照片、视频以及通过数字或人工智能生成的图像。
|
||||
- **诱骗:** 任何试图与未成年人建立关系以达成性剥削或性虐待目的的行为。
|
||||
- **招揽:** 索取、提供或便利 CSAE 材料的交换,或与未成年人进行性接触。
|
||||
- **未成年人性化:** 将未成年人性化的内容,包括针对儿童的暗示性或性相关评论,即使其中不含露骨影像。
|
||||
- **贩运:** 任何便利、推广或协调以性目的贩运未成年人的内容。
|
||||
- **链接与引用:** 分享指向含有 CSAE 材料的外部站点或资源的链接,或提供查找或制作此类材料的指引。
|
||||
|
||||
## 侦测与预防
|
||||
|
||||
{{appName}} 采用多层防护以打击 CSAE:
|
||||
|
||||
- **内容过滤:** 我们在应用内维护并执行内容过滤机制,无论内容来源于哪个中继,都会阻止已知 CSAE 材料被显示。
|
||||
- **用户举报:** 我们在应用内提供举报工具,允许用户标记疑似 CSAE 内容以便立即审核。
|
||||
- **Agora 中继审核:** 在我们自有的 Agora 中继上,我们会主动审核内容,并立即移除任何 CSAE 材料、永久封禁相关账户。
|
||||
- **Agora Blossom 服务器审核:** 在我们自有的 Agora Blossom 文件服务器上,我们会立即删除任何 CSAE 媒体并封禁上传账户。
|
||||
- **第三方中继封锁:** 已知托管或容忍 CSAE 材料的第三方中继可能会被从 {{appName}} 的默认中继列表中移除,并被阻止用户添加。
|
||||
- **静音与屏蔽工具:** 用户可以在客户端层面静音或屏蔽账户,防止这些账户的内容出现在其信息流中。
|
||||
|
||||
## 执行行动
|
||||
|
||||
当识别到 CSAE 内容或行为时,{{appName}} 将视情况采取以下行动:
|
||||
|
||||
- **立即阻止内容:** 已知的 CSAE 内容将通过内容过滤器和封锁列表被阻止在应用中渲染。
|
||||
- **从 Agora 基础设施中移除:** Agora 中继和 Agora Blossom 服务器上的 CSAE 内容将被立即删除,相关账户被永久封禁。
|
||||
- **封禁账户:** 与 CSAE 活动相关联的 Nostr 公钥将被加入应用级封锁列表,无论其内容来自哪个中继,都将无法在 {{appName}} 中显示。
|
||||
- **封锁中继:** 未能处理 CSAE 内容的第三方中继可能会被从 {{appName}} 的默认中继列表中移除,并被阻止用户添加。
|
||||
- **向当局举报:** 我们会通过 CyberTipline 将已识别的 CSAE 材料举报给 [国家失踪与受剥削儿童中心(NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline),并通报相关执法机构。
|
||||
|
||||
## 举报 CSAE 内容
|
||||
|
||||
如果您在 {{appName}} 上发现任何您认为构成儿童性虐待或剥削的内容,请立即举报:
|
||||
|
||||
- **应用内举报:** 使用任意帖子或用户资料上提供的举报按钮来标记内容以待审核。
|
||||
- **直接联系我们:** 请通过 [soapbox.pub](https://soapbox.pub) 联系我们的团队,并提供内容详情,包括相关的 Nostr 事件 ID 或公钥。
|
||||
- **向 NCMEC 举报:** 您也可以直接向 [NCMEC CyberTipline](https://www.missingkids.org/gethelpnow/cybertipline) 提交举报。
|
||||
- **联系执法部门:** 如果您认为有儿童面临紧迫危险,请立即联系当地执法机构,或拨打 **911**(美国)。
|
||||
|
||||
所有关于 CSAE 内容的举报都会以最高优先级处理,并将尽快进行审核。
|
||||
|
||||
## 与执法部门的合作
|
||||
|
||||
{{appName}} 承诺与调查 CSAE 的执法机构充分合作。尽管 {{appName}} 并不在自有服务器上存储用户内容,我们仍将:
|
||||
|
||||
- 在适用法律允许的范围内,提供我们所掌握的任何信息——包括来自 Agora 中继和 Agora Blossom 服务器的数据——以协助调查。
|
||||
- 识别并分享观察到违规内容的具体中继 URL 与文件服务器 URL,使执法机构可以直接联系这些运营方。
|
||||
- 在收到合法的法律请求后,保存任何可获得的证据或信息。
|
||||
- 主动向 NCMEC 及其他相关当局举报已识别的 CSAE 材料。
|
||||
|
||||
## 关于去中心化架构的考量
|
||||
|
||||
Nostr 的去中心化特性意味着没有任何单一实体能够完全控制网络上的所有内容。{{appName}} 承认以下现实,以及我们对每一项现实的应对方式:
|
||||
|
||||
- **对自有基础设施的完整控制:** 我们能够并且会从 Agora 中继和 Agora Blossom 服务器上移除内容。在我们的基础设施上发现的 CSAE 材料将被立即删除,账户被永久封禁。
|
||||
- **对第三方中继的有限控制:** 我们无法删除第三方中继上的内容。但是,我们会通过客户端层面的过滤器和封锁列表,阻止此类内容在应用中显示。
|
||||
- **用户控制其中继连接:** 虽然用户可以连接到自行选择的中继,但 {{appName}} 保留对已知托管 CSAE 内容的中继切断连接的权利。
|
||||
- **公钥是假名:** Nostr 账户由密码学密钥对而非已验证身份进行标识。即便如此,我们仍会封禁并举报违规公钥,并与执法部门合作以识别其背后的个人。
|
||||
|
||||
## 申诉
|
||||
|
||||
如果您认为您的内容或账户在本政策下被错误地标记或封禁,您可以通过 [soapbox.pub](https://soapbox.pub) 与我们联系以申请复核。我们将逐案评估申诉。但在所有决定中,我们始终将儿童安全放在首位,我们的判定为最终决定。
|
||||
|
||||
## 政策变更
|
||||
|
||||
随着我们的工具、流程以及 Nostr 生态的发展,我们可能会更新本儿童安全政策。变更将通过更新本页面上的日期来体现。我们致力于持续提升对 CSAE 内容的侦测、预防和应对能力。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如需就本政策提出疑问或举报 CSAE 内容,请通过 [soapbox.pub](https://soapbox.pub) 联系 {{appName}} 背后的团队。
|
||||
@@ -0,0 +1,53 @@
|
||||
_آخر تحديث: 18 مارس 2026_
|
||||
|
||||
## نظرة عامة
|
||||
|
||||
{{appName}} هو تطبيق عميل لـ**بروتوكول Nostr**، وهو شبكة اتصالات مفتوحة ولامركزية. توضح سياسة الخصوصية هذه كيفية تعامل {{appName}} مع بياناتك، وما هي المعلومات التي تتم مشاركتها عند استخدامك التطبيق.
|
||||
|
||||
## كيف يعمل Nostr
|
||||
|
||||
Nostr بروتوكول لامركزي. عندما تنشر محتوى، يُرسَل إلى **مرحّل** أو أكثر (خوادم مستقلة) تختارها بنفسك. لا يدير {{appName}} هذه المرحّلات وليس له أي تحكم في البيانات المخزنة عليها. المحتوى المنشور على مرحّلات Nostr هو **عام افتراضيًا** وقد يكون مرئيًا لأي شخص.
|
||||
|
||||
## البيانات التي نجمعها
|
||||
|
||||
صُمِّم {{appName}} لتقليل جمع البيانات قدر الإمكان. وفيما يلي ما يصل إليه التطبيق:
|
||||
|
||||
- **المفتاح العام:** يُستخدَم مفتاح Nostr العام لتعريف حسابك. ولا يُعتبر معلومة خاصة في شبكة Nostr.
|
||||
- **اتصالات المرحّلات:** يتصل التطبيق بمرحّلات Nostr نيابةً عنك لجلب الأحداث ونشرها. وقد يقوم مشغّلو المرحّلات بتسجيل بيانات الاتصال الوصفية مثل عنوان IP الخاص بك.
|
||||
- **التخزين المحلي:** تُحفَظ التفضيلات ومعلومات الحساب والبيانات المخزّنة مؤقتًا محليًا في متصفحك. ولا تغادر هذه البيانات جهازك ما لم تنشرها صراحةً.
|
||||
- **الأحداث المنشورة:** أي محتوى تنشره (منشورات، تفاعلات، تحديثات للملف الشخصي، إلخ) يُرسَل إلى المرحّلات التي قمت بضبطها ويصبح جزءًا من شبكة Nostr العامة.
|
||||
|
||||
## المفاتيح الخاصة
|
||||
|
||||
يدعم {{appName}} التوقيع عبر إضافات المتصفح (NIP-07) وموقّعين خارجيين آخرين. عند استخدام هذه الطرق، يدير الموقّعُ مفتاحَك الخاص ولا يصل إليه {{appName}} ولا يخزّنه **أبدًا**. ونوصي بشدة باستخدام إضافة متصفح أو موقّع عتادي لحماية مفتاحك الخاص.
|
||||
|
||||
## تحميل الملفات
|
||||
|
||||
عند تحميل الملفات (صور، فيديوهات، إلخ) تُرسَل إلى خوادم ملفات متوافقة مع Blossom. تُدار هذه الخوادم من قِبل جهات خارجية وقد يكون لها سياسات خصوصية خاصة بها. والملفات المحمَّلة عادةً ما تكون متاحة للجميع عبر عناوين URL الخاصة بها.
|
||||
|
||||
## التحليلات
|
||||
|
||||
قد يستخدم {{appName}} أدوات تحليل صديقة للخصوصية (مثل Plausible) لفهم أنماط الاستخدام العامة. ولا تستخدم هذه الأدوات ملفات تعريف الارتباط، ولا تتعقّب المستخدمين الأفراد، ولا تجمع معلومات شخصية.
|
||||
|
||||
## خدمات الأطراف الثالثة
|
||||
|
||||
قد يتفاعل التطبيق مع خدمات الأطراف الثالثة التالية:
|
||||
|
||||
- **مرحّلات Nostr:** لقراءة الأحداث ونشرها
|
||||
- **خوادم Blossom:** لتحميل الملفات واستضافة الوسائط
|
||||
- **شبكة Lightning / NWC:** لمعالجة مدفوعات Zap إذا اخترت استخدامها
|
||||
- **مزوّدو NIP-05:** للتحقق من عناوين Nostr
|
||||
|
||||
تُدار كل خدمة من هذه الخدمات بشكل مستقل، وقد يكون لها ممارسات خاصة بها في التعامل مع البيانات.
|
||||
|
||||
## حذف البيانات
|
||||
|
||||
نظرًا لأن Nostr بروتوكول لامركزي، فلا يستطيع {{appName}} ضمان حذف المحتوى بعد نشره على المرحّلات. يمكنك طلب الحذف بنشر حدث حذف (NIP-09)، لكن المرحّلات الفردية ليست ملزمة بتلبية هذه الطلبات. ولمسح البيانات المحلية، يمكنك مسح تخزين متصفحك لهذا الموقع.
|
||||
|
||||
## التغييرات على هذه السياسة
|
||||
|
||||
قد نحدّث سياسة الخصوصية هذه من وقت لآخر. ستنعكس التغييرات في هذه الصفحة مع تحديث التاريخ. ويُعدّ استمرارك في استخدام {{appName}} بعد التغييرات قبولًا للسياسة المعدّلة.
|
||||
|
||||
## التواصل
|
||||
|
||||
إذا كانت لديك أسئلة حول سياسة الخصوصية هذه، يمكنك التواصل مع الفريق المسؤول عن {{appName}} عبر [soapbox.pub](https://soapbox.pub).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user