Merge branch 'main' into feat-blobbi

This commit is contained in:
filemon
2026-03-18 10:51:45 -03:00
81 changed files with 3224 additions and 1001 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
VITE_SENTRY_DSN="https://********************************@*****************.example.com/****************"
VITE_PLAUSIBLE_DOMAIN="example.tld"
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
VITE_NOSTR_PUSH_PUBKEY=""
@@ -15,9 +15,13 @@ import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;
@@ -79,15 +83,46 @@ public class NostrPoller {
if (filtered.isEmpty()) return;
// Collect referenced event IDs for reactions, reposts, and zaps so we
// can verify the referenced post was authored by the current user.
Set<String> refIdsNeeded = new HashSet<>();
for (JSONObject event : filtered) {
int kind = event.optInt("kind");
if (kind == 7 || kind == 6 || kind == 16 || kind == 9735) {
String refId = getReferencedEventId(event);
if (refId != null) refIdsNeeded.add(refId);
}
}
// Fetch referenced events synchronously so we can filter before notifying.
Map<String, JSONObject> referencedMap = refIdsNeeded.isEmpty()
? new HashMap<>()
: fetchEventsByIds(new ArrayList<>(refIdsNeeded), relayUrl, httpClient);
// Filter out reactions/reposts/zaps on posts the user didn't author.
List<JSONObject> notifiable = new ArrayList<>();
for (JSONObject event : filtered) {
int kind = event.optInt("kind");
if (kind == 7 || kind == 6 || kind == 16 || kind == 9735) {
String refId = getReferencedEventId(event);
if (refId == null) continue;
JSONObject refEvent = referencedMap.get(refId);
if (refEvent == null || !userPubkey.equals(refEvent.optString("pubkey"))) continue;
}
notifiable.add(event);
}
if (notifiable.isEmpty()) return;
// Dispatch notifications
if (filtered.size() > 3) {
if (notifiable.size() > 3) {
showNotification(
hashId(filtered.get(0).optString("id") + "-summary"),
hashId(notifiable.get(0).optString("id") + "-summary"),
"Ditto",
"You have " + filtered.size() + " new notifications"
"You have " + notifiable.size() + " new notifications"
);
} else {
for (JSONObject event : filtered) {
for (JSONObject event : notifiable) {
String action = kindToAction(event);
showNotification(
hashId(event.optString("id")),
@@ -97,7 +132,8 @@ public class NostrPoller {
}
}
// Update last-seen to newest event
// Update last-seen to newest event (use full filtered list, not just
// notifiable, so we don't re-fetch already-seen events on next cycle).
long newestTs = getLastSeenTimestamp();
for (JSONObject event : filtered) {
long ts = event.optLong("created_at", 0);
@@ -106,13 +142,96 @@ public class NostrPoller {
setLastSeenTimestamp(newestTs);
}
/** Returns the last `e` tag value from the event, or null if absent. */
private String getReferencedEventId(JSONObject event) {
JSONArray tags = event.optJSONArray("tags");
if (tags == null) return null;
String last = null;
for (int i = 0; i < tags.length(); i++) {
JSONArray tag = tags.optJSONArray(i);
if (tag != null && "e".equals(tag.optString(0)) && tag.length() > 1) {
last = tag.optString(1);
}
}
return last;
}
/**
* Synchronously fetch a set of events by ID from the relay.
* Uses a CountDownLatch so the caller blocks until EOSE or timeout (5 s).
*/
private Map<String, JSONObject> fetchEventsByIds(List<String> ids, String relayUrl, OkHttpClient httpClient) {
Map<String, JSONObject> result = new HashMap<>();
if (ids.isEmpty()) return result;
CountDownLatch latch = new CountDownLatch(1);
String subId = "ref-" + Long.toHexString(System.nanoTime());
try {
JSONArray idsArr = new JSONArray();
for (String id : ids) idsArr.put(id);
JSONObject filter = new JSONObject();
filter.put("ids", idsArr);
filter.put("limit", ids.size());
JSONArray req = new JSONArray();
req.put("REQ");
req.put(subId);
req.put(filter);
String reqStr = req.toString();
okhttp3.Request request = new okhttp3.Request.Builder().url(relayUrl).build();
httpClient.newWebSocket(request, new okhttp3.WebSocketListener() {
@Override
public void onOpen(okhttp3.WebSocket webSocket, okhttp3.Response response) {
webSocket.send(reqStr);
}
@Override
public void onMessage(okhttp3.WebSocket webSocket, String text) {
try {
JSONArray msg = new JSONArray(text);
String type = msg.optString(0);
if ("EVENT".equals(type) && subId.equals(msg.optString(1))) {
JSONObject ev = msg.getJSONObject(2);
result.put(ev.optString("id"), ev);
} else if ("EOSE".equals(type) || "CLOSED".equals(type)) {
JSONArray close = new JSONArray();
close.put("CLOSE");
close.put(subId);
webSocket.send(close.toString());
webSocket.close(1000, "done");
latch.countDown();
}
} catch (Exception ignored) {}
}
@Override
public void onFailure(okhttp3.WebSocket webSocket, Throwable t, okhttp3.Response response) {
latch.countDown();
}
@Override
public void onClosed(okhttp3.WebSocket webSocket, int code, String reason) {
latch.countDown();
}
});
latch.await(5, TimeUnit.SECONDS);
} catch (Exception ignored) {}
return result;
}
// --- Event helpers ---
private String kindToAction(JSONObject event) {
int kind = event.optInt("kind");
switch (kind) {
case 7: return "reacted to your post";
case 6: return "reposted your note";
case 6: // fall-through
case 16: return "reposted your note";
case 9735: {
long sats = getZapAmount(event);
if (sats > 0) {
@@ -173,7 +173,9 @@ public class NotificationRelayService extends Service {
if (fetchWakeLock != null && fetchWakeLock.isHeld()) {
fetchWakeLock.release();
}
httpClient.dispatcher().executorService().shutdownNow();
if (httpClient != null) {
httpClient.dispatcher().executorService().shutdownNow();
}
}
@Override
@@ -282,7 +284,7 @@ public class NotificationRelayService extends Service {
try {
JSONObject filter = new JSONObject();
JSONArray kinds = new JSONArray();
kinds.put(1); kinds.put(6); kinds.put(7); kinds.put(9735); kinds.put(1111);
kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111);
filter.put("kinds", kinds);
JSONArray pTags = new JSONArray();
pTags.put(userPubkey);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 111 KiB

+63
View File
@@ -0,0 +1,63 @@
/**
* Ditto Service Worker
*
* Handles incoming Web Push notifications from the nostr-push server and
* opens/focuses the app when the user taps a notification.
*/
// --- Push received ---
self.addEventListener('push', (event) => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
payload = { title: 'Ditto', body: event.data.text() };
}
const title = payload.title ?? 'Ditto';
const options = {
body: payload.body ?? '',
icon: payload.icon ?? '/icon-192.png',
badge: payload.badge ?? '/icon-192.png',
data: payload.data ?? {},
requireInteraction: false,
tag: payload.data?.subscription_id ?? 'ditto-notification',
renotify: true,
};
event.waitUntil(
self.registration.showNotification(title, options),
);
});
// --- Notification click ---
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus an existing Ditto tab if one is open
for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
client.navigate('/notifications');
return client.focus();
}
}
// Otherwise open a new tab
return self.clients.openWindow('/notifications');
}),
);
});
// --- Activate immediately ---
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
+48 -38
View File
@@ -9,6 +9,7 @@ import { InferSeoMetaPlugin } from "@unhead/addons";
import { createHead, UnheadProvider } from "@unhead/react/client";
import { useEffect } from "react";
import { AppProvider } from "@/components/AppProvider";
import { DMProvider, type DMConfig } from "@/components/DMProvider";
import { InitialSyncGate } from "@/components/InitialSyncGate";
import { NativeNotifications } from "@/components/NativeNotifications";
import NostrProvider from "@/components/NostrProvider";
@@ -19,8 +20,14 @@ import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import AppRouter from "./AppRouter";
const dmConfig: DMConfig = {
enabled: true,
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
};
const head = createHead({
plugins: [InferSeoMetaPlugin()],
});
@@ -51,27 +58,27 @@ const hardcodedConfig: AppConfig = {
feedSettings: {
feedIncludePosts: true,
feedIncludeReposts: true,
feedIncludeArticles: false,
showArticles: false,
showEvents: false,
feedIncludeEvents: false,
showVines: false,
showPolls: false,
showTreasures: false,
feedIncludeArticles: true,
showArticles: true,
showEvents: true,
feedIncludeEvents: true,
showVines: true,
showPolls: true,
showTreasures: true,
showTreasureGeocaches: true,
showTreasureFoundLogs: true,
showColors: false,
showPacks: false,
feedIncludeVines: false,
feedIncludePolls: false,
feedIncludeTreasureGeocaches: false,
feedIncludeTreasureFoundLogs: false,
feedIncludeColors: false,
feedIncludePacks: false,
showDecks: false,
feedIncludeDecks: false,
showWebxdc: false,
feedIncludeWebxdc: false,
showColors: true,
showPacks: true,
feedIncludeVines: true,
feedIncludePolls: true,
feedIncludeTreasureGeocaches: true,
feedIncludeTreasureFoundLogs: true,
feedIncludeColors: true,
feedIncludePacks: true,
showDecks: true,
feedIncludeDecks: true,
showWebxdc: true,
feedIncludeWebxdc: true,
showPhotos: true,
feedIncludePhotos: true,
showVideos: true,
@@ -84,29 +91,30 @@ const hardcodedConfig: AppConfig = {
showProfileThemeUpdates: true,
feedIncludeProfileThemeUpdates: true,
showCustomProfileThemes: true,
feedIncludeVoiceMessages: false,
showEmojiPacks: false,
feedIncludeEmojiPacks: false,
feedIncludeVoiceMessages: true,
showEmojiPacks: true,
feedIncludeEmojiPacks: true,
showCustomEmojis: true,
showUserStatuses: true,
showMusic: false,
feedIncludeMusicTracks: false,
feedIncludeMusicPlaylists: false,
showPodcasts: false,
feedIncludePodcastEpisodes: false,
feedIncludePodcastTrailers: false,
showMusic: true,
feedIncludeMusicTracks: true,
feedIncludeMusicPlaylists: true,
showPodcasts: true,
feedIncludePodcastEpisodes: true,
feedIncludePodcastTrailers: true,
showDevelopment: false,
feedIncludeDevelopment: false,
showBadges: false,
showBadges: true,
showBadgeDefinitions: true,
showProfileBadges: true,
feedIncludeBadgeDefinitions: false,
feedIncludeProfileBadges: false,
feedIncludeBadgeDefinitions: true,
feedIncludeProfileBadges: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
"feed",
"notifications",
"messages",
"search",
"bookmarks",
"profile",
@@ -169,13 +177,15 @@ export function App() {
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
<NWCProvider>
<DMProvider config={dmConfig}>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</DMProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
+26 -11
View File
@@ -1,7 +1,9 @@
import { useState } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { ReplyComposeModal } from "@/components/ReplyComposeModal";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { MainLayout } from "./components/MainLayout";
@@ -20,12 +22,14 @@ import { ContentSettingsPage } from "./pages/ContentSettingsPage";
import { DomainFeedPage } from "./pages/DomainFeedPage";
import { EventsFeedPage } from "./pages/EventsFeedPage";
import { ExternalContentPage } from "./pages/ExternalContentPage";
import { GeotagPage } from "./pages/GeotagPage";
import { HashtagPage } from "./pages/HashtagPage";
import { HelpPage } from "./pages/HelpPage";
import { HomePage } from "./pages/HomePage";
import Index from "./pages/Index";
import { KindFeedPage } from "./pages/KindFeedPage";
import { MagicSettingsPage } from "./pages/MagicSettingsPage";
import Messages from "./pages/Messages";
import { MusicFeedPage } from "./pages/MusicFeedPage";
import { NetworkSettingsPage } from "./pages/NetworkSettingsPage";
import { NIP19Page } from "./pages/NIP19Page";
@@ -34,6 +38,7 @@ import { NotificationSettings } from "./pages/NotificationSettings";
import { NotificationsPage } from "./pages/NotificationsPage";
import { PhotosFeedPage } from "./pages/PhotosFeedPage";
import { PodcastsFeedPage } from "./pages/PodcastsFeedPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { ProfileSettings } from "./pages/ProfileSettings";
import { RelayPage } from "./pages/RelayPage";
import { SearchPage } from "./pages/SearchPage";
@@ -56,6 +61,22 @@ const decksDef = getExtraKindDef("decks")!;
const emojisDef = getExtraKindDef("emojis")!;
const developmentDef = getExtraKindDef("development")!;
/** Polls feed page with a FAB that opens the compose modal (poll mode via + menu). */
function PollsFeedPage() {
const [composeOpen, setComposeOpen] = useState(false);
return (
<>
<KindFeedPage
kind={pollsDef.kind}
title={pollsDef.label}
icon={sidebarItemIcon("polls", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
</>
);
}
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
function ProfileRedirect() {
const { user, metadata } = useCurrentUser();
@@ -78,10 +99,12 @@ export function AppRouter() {
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/messages" element={<Messages />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/trends" element={<TrendsPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
<Route path="/t/:tag" element={<HashtagPage />} />
<Route path="/t/:tag" element={<HashtagPage />} />
<Route path="/g/:geohash" element={<GeotagPage />} />
<Route path="/feed/:domain" element={<DomainFeedPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
@@ -110,16 +133,7 @@ export function AppRouter() {
<Route path="/vines" element={<VinesFeedPage />} />
<Route path="/music" element={<MusicFeedPage />} />
<Route path="/podcasts" element={<PodcastsFeedPage />} />
<Route
path="/polls"
element={
<KindFeedPage
kind={pollsDef.kind}
title={pollsDef.label}
icon={sidebarItemIcon("polls", "size-5")}
/>
}
/>
<Route path="/polls" element={<PollsFeedPage />} />
<Route path="/treasures" element={<TreasuresPage />} />
<Route
path="/colors"
@@ -194,6 +208,7 @@ export function AppRouter() {
<Route path="/badges" element={<BadgesFeedPage />} />
<Route path="/books" element={<BooksPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/r/*" element={<RelayPage />} />
<Route
path="/settings/lists"
+1 -1
View File
@@ -217,7 +217,7 @@ export function AddToListDialog({ pubkey, displayName, open, onOpenChange }: Add
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateAndAdd(); }}
className="flex-1 h-8 text-sm"
className="flex-1 h-8 text-base md:text-sm"
disabled={creatingNew}
/>
<Button
+61 -6
View File
@@ -1,10 +1,11 @@
import { useState } from 'react';
import { ChevronDown, ChevronUp, Bug, RotateCcw } from 'lucide-react';
import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
@@ -20,6 +21,8 @@ export function AdvancedSettings() {
const { user } = useCurrentUser();
const [systemOpen, setSystemOpen] = useState(true);
const [sentryOpen, setSentryOpen] = useState(false);
const [dangerOpen, setDangerOpen] = useState(false);
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey);
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
@@ -72,7 +75,7 @@ export function AdvancedSettings() {
value={statsPubkey}
onChange={(e) => handleStatsPubkeyChange(e.target.value)}
placeholder="Enter 64-character hex pubkey"
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
maxLength={64}
/>
{statsPubkey && statsPubkey.length !== 64 && (
@@ -107,7 +110,7 @@ export function AdvancedSettings() {
}
}}
placeholder="https://fetch.ditto.pub/favicon/{hostname}"
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
/>
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
@@ -136,7 +139,7 @@ export function AdvancedSettings() {
}
}}
placeholder="https://fetch.ditto.pub/link/{url}"
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
/>
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
@@ -165,7 +168,7 @@ export function AdvancedSettings() {
}
}}
placeholder="https://proxy.shakespeare.diy/?url={href}"
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
/>
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
@@ -261,13 +264,65 @@ export function AdvancedSettings() {
}
}}
placeholder="https://examplePublicKey@o0.ingest.sentry.io/0"
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
{/* Danger Zone Section — only when logged in */}
{user && (
<div>
<Collapsible open={dangerOpen} onOpenChange={setDangerOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="flex items-center gap-2 text-base font-semibold text-destructive">
<AlertTriangle className="h-4 w-4" />
Danger Zone
</span>
{dangerOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-destructive rounded-full" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pt-3 pb-4 space-y-4">
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">Request to Vanish</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Permanently request all relays to delete your data, including your profile,
posts, reactions, and direct messages. This action is irreversible and legally
binding in some jurisdictions (NIP-62).
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setVanishDialogOpen(true)}
>
Request to Vanish
</Button>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<RequestToVanishDialog
open={vanishDialogOpen}
onOpenChange={setVanishDialogOpen}
/>
</div>
)}
</div>
);
}
+1 -1
View File
@@ -251,7 +251,7 @@ export function BlossomSettings() {
if (e.key === 'Enter') handleAddServer();
}}
placeholder="https://blossom.example.com/"
className="h-9 text-sm font-mono"
className="h-9 text-base md:text-sm font-mono"
/>
</div>
<Button
+5 -1
View File
@@ -344,7 +344,11 @@ export function ColorMomentContent({ event }: { event: NostrEvent }) {
const colors = useMemo(() => getColors(event.tags), [event.tags]);
const layout = (getTag(event.tags, 'layout') ?? 'horizontal') as Layout;
const name = getTag(event.tags, 'name');
const emoji = event.content.trim() || undefined;
const rawContent = event.content.trim();
// Only treat content as an emoji if it's a single grapheme cluster (emoji or short symbol).
// Long text should not be overlaid on the palette.
const isSingleEmoji = rawContent.length > 0 && [...rawContent].length <= 2 && /\p{Emoji}/u.test(rawContent);
const emoji = isSingleEmoji ? rawContent : undefined;
const LayoutComponent = LAYOUT_MAP[layout] ?? HorizontalLayout;
+231 -34
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker } from 'lucide-react';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { encode as blurhashEncode } from 'blurhash';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -13,6 +13,7 @@ import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { EmojiPicker } from '@/components/EmojiPicker';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
@@ -42,6 +43,11 @@ import { DITTO_RELAY } from '@/lib/appRelays';
const MAX_CHARS = 5000;
/** Short random ID for poll options. */
function pollOptionId(): string {
return Math.random().toString(36).slice(2, 8);
}
/**
* For an image File, returns `{ dim: "WxH", blurhash: "..." }`.
* Decodes to a small canvas (max 64px wide) for speed — large enough
@@ -110,6 +116,8 @@ interface ComposeBoxProps {
onHasPreviewableContentChange?: (hasContent: boolean) => void;
/** Pre-filled content for the compose box. */
initialContent?: string;
/** Open directly in poll mode. */
initialMode?: 'post' | 'poll';
}
/** Circular progress ring for character count. */
@@ -149,6 +157,7 @@ function CharRing({ count, max }: { count: number; max: number }) {
);
}
export function ComposeBox({
onSuccess,
placeholder = "What's on your mind?",
@@ -160,11 +169,12 @@ export function ComposeBox({
previewMode: controlledPreviewMode,
onHasPreviewableContentChange,
initialContent = '',
initialMode = 'post',
}: ComposeBoxProps) {
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
const avatarShape = getAvatarShape(metadata);
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const { mutateAsync: createEvent, isPending } = useNostrPublish();
const { mutateAsync: createEvent, isPending, isPending: isPollPending } = useNostrPublish();
const { mutateAsync: postComment, isPending: isCommentPending } = usePostComment();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { feedSettings } = useFeedSettings();
@@ -180,7 +190,18 @@ export function ComposeBox({
const [cwText, setCwText] = useState('');
const [pickerOpen, setPickerOpen] = useState(false);
const [pickerTab, setPickerTab] = useState<'emoji' | 'gif' | 'stickers'>('emoji');
const [trayOpen, setTrayOpen] = useState(false);
const [internalPreviewMode, setInternalPreviewMode] = useState(false);
// Poll mode state
const [mode, setMode] = useState<'post' | 'poll'>(initialMode);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState([
{ id: pollOptionId(), label: '' },
{ id: pollOptionId(), label: '' },
]);
const [pollType, setPollType] = useState<'singlechoice' | 'multiplechoice'>('singlechoice');
const [pollDuration, setPollDuration] = useState<7 | 3 | 1 | 0>(7);
const [removedEmbeds, setRemovedEmbeds] = useState<Set<string>>(new Set());
const [_uploadedFileTags, setUploadedFileTags] = useState<string[][]>([]);
/** Maps uploaded file URLs to their NIP-94 tags (grouped per upload). */
@@ -953,6 +974,39 @@ export function ComposeBox({
}
};
const handlePollSubmit = async () => {
const filledOptions = pollOptions.filter((o) => o.label.trim());
if (!pollQuestion.trim() || filledOptions.length < 2 || !user || isPollPending) return;
const tags: string[][] = [];
for (const opt of filledOptions) {
tags.push(['option', opt.id, opt.label.trim()]);
}
tags.push(['polltype', pollType]);
if (pollDuration > 0) {
tags.push(['endsAt', String(Math.floor(Date.now() / 1000) + pollDuration * 86_400)]);
}
tags.push(['alt', `Poll: ${pollQuestion.trim()}`]);
try {
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
// Reset poll state
setMode('post');
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
queryClient.invalidateQueries({ queryKey: ['feed'] });
toast({ title: 'Poll published!' });
onSuccess?.();
} catch {
toast({ title: 'Error', description: 'Failed to publish poll.', variant: 'destructive' });
}
};
const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length;
const isPollValid = pollQuestion.trim().length > 0 && pollFilledCount >= 2;
const isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
// Early return after all hooks to avoid violating Rules of Hooks
@@ -1007,8 +1061,113 @@ export function ComposeBox({
)}
<div className="flex-1 min-w-0">
{!previewMode ? (
/* Edit mode - Textarea */
{mode === 'poll' ? (
/* ── Inline poll builder ─────────────────────────────── */
<div className="pt-2.5 pb-1 space-y-3">
{/* Back to post link — hidden when poll mode is the only mode */}
{initialMode !== 'poll' && (
<button
type="button"
onClick={() => setMode('post')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors -mt-0.5"
>
<ChevronLeft className="size-3.5" />
Back to post
</button>
)}
{/* Question */}
<textarea
value={pollQuestion}
onChange={(e) => setPollQuestion(e.target.value)}
placeholder="Ask a question…"
rows={2}
maxLength={280}
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pb-1 opacity-85 break-words"
/>
{/* Options */}
<div className="space-y-1.5">
{pollOptions.map((opt, idx) => (
<div key={opt.id} className="flex items-center gap-2">
<input
type="text"
value={opt.label}
onChange={(e) =>
setPollOptions((prev) =>
prev.map((o) => (o.id === opt.id ? { ...o, label: e.target.value } : o)),
)
}
placeholder={`Option ${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"
/>
<button
type="button"
onClick={() => {
if (pollOptions.length > 2) {
setPollOptions((prev) => prev.filter((o) => o.id !== opt.id));
}
}}
disabled={pollOptions.length <= 2}
className="p-1 rounded-full text-muted-foreground hover:text-destructive transition-colors disabled:opacity-20"
>
<X className="size-3.5" />
</button>
</div>
))}
{pollOptions.length < 8 && (
<button
type="button"
onClick={() =>
setPollOptions((prev) => [...prev, { id: pollOptionId(), label: '' }])
}
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
</button>
)}
</div>
{/* Settings row — pill toggles */}
<div className="flex flex-wrap gap-2 pt-0.5">
{(['singlechoice', 'multiplechoice'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setPollType(t)}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
pollType === t
? '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'}
</button>
))}
<div className="w-px bg-border self-stretch mx-0.5" />
{([1, 3, 7, 0] as const).map((d) => (
<button
key={d}
type="button"
onClick={() => setPollDuration(d)}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
pollDuration === d
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/30',
)}
>
{d === 0 ? <span style={{ fontSize: '15px', lineHeight: 1, position: 'relative', top: '-1px' }}></span> : `${d}d`}
</button>
))}
</div>
</div>
) : !previewMode ? (
/* ── Edit mode — Textarea ────────────────────────────── */
<div className="relative">
<textarea
ref={textareaRef}
@@ -1077,7 +1236,7 @@ export function ComposeBox({
value={cwText}
onChange={(e) => setCwText(e.target.value)}
placeholder="Content warning reason (optional)"
className="h-8 text-sm bg-secondary/50 border-0 rounded-lg"
className="h-8 text-base md:text-sm bg-secondary/50 border-0 rounded-lg"
/>
<button
onClick={() => { setCwEnabled(false); setCwText(''); }}
@@ -1184,10 +1343,13 @@ export function ComposeBox({
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*,.xdc"
multiple
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
const files = e.target.files;
if (files) {
Array.from(files).forEach((file) => handleFileUpload(file));
}
e.target.value = '';
}}
/>
@@ -1335,32 +1497,56 @@ export function ComposeBox({
</DialogContent>
</Dialog>
{/* Content warning (NIP-36) */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCwEnabled(!cwEnabled)}
className={cn(
'p-2 rounded-full transition-colors',
cwEnabled
? 'text-amber-500 bg-amber-500/10'
: 'text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10',
{/* Overflow: Poll + CW */}
<Popover open={trayOpen} onOpenChange={setTrayOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
disabled={!user}
className={cn(
'p-2 rounded-full transition-colors disabled:opacity-40',
(trayOpen || mode === 'poll' || cwEnabled)
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<Plus className="size-[18px]" strokeWidth={2.5} />
</button>
</PopoverTrigger>
</TooltipTrigger>
{!trayOpen && <TooltipContent>More</TooltipContent>}
</Tooltip>
<PopoverContent side="bottom" align="start" sideOffset={6} className="w-44 p-1.5 rounded-xl border-border shadow-lg">
<div className="flex flex-col gap-0.5">
{!replyTo && (
<button
type="button"
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>
</button>
)}
>
<AlertTriangle className="size-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>Content warning (NIP-36)</TooltipContent>
</Tooltip>
<button
type="button"
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>
</button>
</div>
</PopoverContent>
</Popover>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right: char count + post button */}
{/* Right: char count + post/poll button */}
<div className="flex items-center gap-3">
{charCount > 0 && (
{mode === 'post' && charCount > 0 && (
<div className="flex items-center gap-1.5">
<CharRing count={charCount} max={MAX_CHARS} />
<span className={cn(
@@ -1372,14 +1558,25 @@ export function ComposeBox({
</div>
)}
<Button
onClick={handleSubmit}
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
className="rounded-full px-5 font-bold"
size="sm"
>
{isPending || isCommentPending ? 'Posting...' : 'Post!'}
</Button>
{mode === 'poll' ? (
<Button
onClick={handlePollSubmit}
disabled={!isPollValid || isPollPending || !user}
className="rounded-full px-5 font-bold"
size="sm"
>
{isPollPending ? 'Publishing...' : 'Publish poll'}
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
className="rounded-full px-5 font-bold"
size="sm"
>
{isPending || isCommentPending ? 'Posting...' : 'Post!'}
</Button>
)}
</div>
</div>
)
+1 -1
View File
@@ -137,7 +137,7 @@ export function EmojiPicker({ onSelect, customEmojis }: EmojiPickerProps) {
const shadowRoot = (container.firstChild as HTMLElement)?.shadowRoot;
if (shadowRoot) {
const style = document.createElement('style');
style.textContent = '.sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: var(--em-color-background) !important; }';
style.textContent = '.sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: var(--em-color-background) !important; } input { font-size: 16px !important; }';
shadowRoot.appendChild(style);
}
});
+84 -22
View File
@@ -8,7 +8,7 @@ import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { Skeleton } from '@/components/ui/skeleton';
import { Loader2 } from 'lucide-react';
import { Loader2, MapPin } from 'lucide-react';
import LoginDialog from '@/components/auth/LoginDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { useFeed } from '@/hooks/useFeed';
@@ -24,8 +24,8 @@ import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { TabButton } from '@/components/TabButton';
import { DITTO_RELAY } from '@/lib/appRelays';
import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils';
import type { NostrEvent } from '@nostrify/nostrify';
import type { SavedFeed } from '@/contexts/AppContext';
@@ -67,6 +67,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const queryClient = useQueryClient();
const { savedFeeds } = useSavedFeeds();
const { hashtags } = useInterests();
const { hashtags: geotags } = useInterests('g');
// Tab settings from localStorage
const showGlobalFeed = (() => {
@@ -97,10 +98,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
return 'Community';
})();
const [activeTab, handleSetActiveTab] = useFeedTab<FeedTab>(feedId);
const [rawActiveTab, handleSetActiveTab] = useFeedTab<FeedTab>(feedId);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const { startSignup } = useOnboarding();
// Kind-specific pages only support Follows + Global. Clamp any other
// persisted tab (e.g. 'ditto', 'communities') back to 'follows'.
const activeTab: FeedTab = kinds && rawActiveTab !== 'follows' && rawActiveTab !== 'global'
? 'follows'
: rawActiveTab;
// Is the active tab a saved feed?
const activeSavedFeed = useMemo(
() => savedFeeds.find((f) => f.id === activeTab) ?? null,
@@ -110,12 +117,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// Is the active tab a hashtag interest?
const activeHashtag = activeTab.startsWith('hashtag:') ? activeTab.slice(8) : null;
// Is the active tab a geotag interest?
const activeGeotag = activeTab.startsWith('geotag:') ? activeTab.slice(7) : null;
// When logged out (and not on a kind-specific page), show the "hot" sorted
// feed instead of the noisy global feed so new visitors see quality content.
const useTopFeedForLoggedOut = !user && !kinds;
// When the Ditto tab is active (logged in), show the same hot-sorted curated feed.
const useDittoTab = user && activeTab === 'ditto';
// Disabled on kind-specific pages — the Ditto tab is not shown there.
const useDittoTab = user && activeTab === 'ditto' && !kinds;
// Standard feed query (used when logged in, or on kind-specific pages, or core tabs)
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'ditto';
@@ -209,8 +220,10 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const showSkeleton = isPending || (isLoading && !rawData);
// Saved feed tabs are only shown on the main home feed (no kinds/tagFilters override)
const showSavedFeedTabs = user && !kinds && !tagFilters;
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
// Extra tabs (Ditto, Community, saved feeds, hashtags) are only for the home feed.
const isKindSpecificPage = !!kinds;
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
return (
<main className="flex-1 min-w-0">
@@ -222,13 +235,13 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
{user ? (
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10 overflow-x-auto scrollbar-none">
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
{showDittoFeed && (
{!isKindSpecificPage && showDittoFeed && (
<TabButton label="Ditto" active={activeTab === 'ditto'} onClick={() => handleSetActiveTab('ditto')} />
)}
{showCommunityFeed && (
{!isKindSpecificPage && showCommunityFeed && (
<TabButton label={communityLabel} active={activeTab === 'communities'} onClick={() => handleSetActiveTab('communities')} />
)}
{showGlobalFeed && (
{(isKindSpecificPage || showGlobalFeed) && (
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
)}
{showSavedFeedTabs && savedFeeds.map((feed) => (
@@ -247,6 +260,19 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
onClick={() => handleSetActiveTab(`hashtag:${tag}`)}
/>
))}
{showSavedFeedTabs && geotags.map((tag) => (
<TabButton
key={`geotag:${tag}`}
label={tag}
active={activeTab === `geotag:${tag}`}
onClick={() => handleSetActiveTab(`geotag:${tag}`)}
>
<span className="flex items-center justify-center gap-1">
<MapPin className="size-3.5" />
{tag}
</span>
</TabButton>
))}
</div>
) : !kinds && (
<LandingHero
@@ -258,6 +284,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
{/* Feed content — saved feed tab gets its own stream */}
{activeHashtag ? (
<HashtagFeedContent tag={activeHashtag} />
) : activeGeotag ? (
<GeotagFeedContent tag={activeGeotag} />
) : activeSavedFeed ? (
<SavedFeedContent feed={activeSavedFeed} />
) : (
@@ -426,20 +454,54 @@ function HashtagFeedContent({ tag }: { tag: string }) {
);
}
function TabButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
/** Renders a feed of posts tagged with a specific geohash. */
function GeotagFeedContent({ tag }: { tag: string }) {
const { nostr } = useNostr();
const { muteItems } = useMuteList();
const { feedSettings } = useFeedSettings();
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
const kindsKey = [...kinds].sort().join(',');
const { data: events, isLoading } = useQuery<NostrEvent[]>({
queryKey: ['geotag-feed', tag, kindsKey],
queryFn: async ({ signal }) => {
const ditto = nostr.relay(DITTO_RELAY);
const filter = { kinds, limit: 40 } as Record<string, unknown>;
filter['#g'] = [tag];
return ditto.query([filter as Parameters<typeof ditto.query>[0][number]], {
signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]),
});
},
});
const filteredEvents = useMemo((): NostrEvent[] => {
if (!events) return [];
if (muteItems.length === 0) return events;
return events.filter((e) => !isEventMuted(e, muteItems));
}, [events, muteItems]);
if (isLoading && filteredEvents.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
);
}
if (filteredEvents.length === 0) {
return (
<FeedEmptyState message={`No posts found near ${tag}.`} />
);
}
return (
<button
onClick={onClick}
className={cn(
'flex-1 px-4 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 whitespace-nowrap',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 max-w-16 h-1 bg-primary rounded-full" />
)}
</button>
<div>
{filteredEvents.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
);
}
+1 -1
View File
@@ -181,7 +181,7 @@ export function FeedEditModal({
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="e.g. bitcoin"
className="bg-secondary/50 border-border focus-visible:ring-1 h-8 text-sm"
className="bg-secondary/50 border-border focus-visible:ring-1 h-8 text-base md:text-sm"
/>
</div>
+1 -1
View File
@@ -22,7 +22,7 @@ export function FeedEmptyState({
}: FeedEmptyStateProps) {
return (
<div className={cn('py-16 px-8 text-center space-y-3', className)}>
<p className="text-muted-foreground">{message}</p>
<p className="text-muted-foreground break-all">{message}</p>
{onSwitchToGlobal && (
<button
className="text-sm text-primary hover:underline"
+1
View File
@@ -55,6 +55,7 @@ export function FollowButton({ pubkey, className, size = 'sm' }: FollowButtonPro
return (
<Button
type="button"
size={size}
variant={isFollowing ? 'outline' : 'default'}
className={cn(
+19 -2
View File
@@ -1,7 +1,9 @@
import { MapPin, Mountain, Brain, Package, Eye, EyeOff } from 'lucide-react';
import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { ImageGallery } from '@/components/ImageGallery';
import { Badge } from '@/components/ui/badge';
import { ChestIcon } from '@/components/icons/ChestIcon';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -58,6 +60,13 @@ export function GeocacheContent({ event }: { event: NostrEvent }) {
const terrain = Number(getTag(event.tags, 'T') ?? 1);
const size = getTag(event.tags, 'S') ?? 'other';
const cacheType = getTag(event.tags, 't') ?? 'traditional';
// Take the longest g tag (most precise) and truncate to 5 chars
const geohash = getAllTags(event.tags, 'g')
.reduce<string | undefined>(
(longest, g) => (longest === undefined || g.length > longest.length ? g : longest),
undefined,
)
?.slice(0, 5);
const hint = getTag(event.tags, 'hint');
const images = getAllTags(event.tags, 'image').filter((url) => url.trim() !== '');
const description = event.content;
@@ -72,12 +81,12 @@ export function GeocacheContent({ event }: { event: NostrEvent }) {
{/* Cache name */}
{name && (
<div className="flex items-start gap-2 mb-2">
<MapPin className="size-4 text-primary mt-0.5 shrink-0" />
<ChestIcon className="size-4 text-primary mt-0.5 shrink-0" />
<span className="text-[15px] font-semibold leading-snug">{name}</span>
</div>
)}
{/* Badges row: type, size */}
{/* Badges row: type, size, geohash */}
<div className="flex flex-wrap gap-1.5 mb-3">
<Badge variant="secondary" className="text-[11px] gap-1 font-medium">
{TYPE_LABELS[cacheType] ?? cacheType}
@@ -86,6 +95,14 @@ export function GeocacheContent({ event }: { event: NostrEvent }) {
<Package className="size-3" />
{SIZE_LABELS[size] ?? size}
</Badge>
{geohash && (
<Link to={`/g/${geohash}`} onClick={(e) => e.stopPropagation()}>
<Badge variant="secondary" className="text-[11px] gap-1 font-medium hover:bg-secondary/80 transition-colors">
<MapPin className="size-3" />
{geohash}
</Badge>
</Link>
)}
</div>
{/* D/T ratings */}
+1 -1
View File
@@ -128,7 +128,7 @@ export function GifPicker({ onSelect }: GifPickerProps) {
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search GIFs..."
className="pl-8 pr-8 h-9 text-sm bg-muted/50 border-0 rounded-lg"
className="pl-8 pr-8 h-9 text-base md:text-sm bg-muted/50 border-0 rounded-lg"
/>
{query && (
<button
+2 -3
View File
@@ -799,16 +799,15 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
onMouseLeave={handleMouseUp}
style={{ cursor: scale.current > 1 ? 'grab' : 'default' }}
>
<div ref={wrapRef} style={{ transformOrigin: 'center center', willChange: 'transform' }}>
<div ref={wrapRef} style={{ transformOrigin: 'center center', willChange: 'transform', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img
ref={imgRef}
src={src}
alt=""
className={cn(
'max-w-full max-h-full object-contain select-none transition-opacity duration-300',
'block max-w-full max-h-full object-contain select-none transition-opacity duration-300',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
style={{ display: 'block', maxHeight: '100dvh' }}
onLoad={handleLoaded}
onError={onError}
draggable={false}
+1 -1
View File
@@ -513,7 +513,7 @@ function DownloadStep({
type={showKey ? "text" : "password"}
value={nsec}
readOnly
className="pr-10 font-mono text-sm"
className="pr-10 font-mono text-base md:text-sm"
/>
<Button
type="button"
+7 -89
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
UserPlus, LogOut, Check, Moon, Sun, Monitor, Palette, ChevronDown,
UserPlus, LogOut,
Loader2,
} from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
@@ -18,7 +18,7 @@ import { useOnboarding } from '@/hooks/useOnboarding';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useTheme } from '@/hooks/useTheme';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useAppContext } from '@/hooks/useAppContext';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
@@ -26,17 +26,12 @@ import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getSidebarItem, isItemActive } from '@/lib/sidebarItems';
import { themePresets } from '@/themes';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
DropdownMenuLabel, DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { useUserThemes } from '@/hooks/useUserThemes';
import { useUserStatus } from '@/hooks/useUserStatus';
import { usePublishStatus } from '@/hooks/usePublishStatus';
import { useToast } from '@/hooks/useToast';
import { Input } from '@/components/ui/input';
import type { Theme } from '@/contexts/AppContext';
export function LeftSidebar() {
const location = useLocation();
@@ -45,7 +40,7 @@ export function LeftSidebar() {
const currentUserAvatarShape = getAvatarShape(metadata);
const { currentUser, otherUsers, setLogin } = useLoggedInAccounts();
const { logout } = useLoginActions();
const { theme, setTheme, applyCustomTheme, customTheme } = useTheme();
const {
orderedItems, hiddenItems, updateSidebarOrder, addToSidebar, addDividerToSidebar, removeFromSidebar,
} = useFeedSettings();
@@ -93,30 +88,6 @@ export function LeftSidebar() {
navigate('/');
};
// ── Theme (for the popover inline display) ────────────────────────────────
const builtinThemeOptions: { value: Theme; label: string; icon: React.ReactNode }[] = [
{ value: 'system', label: 'System', icon: <Monitor className="size-4" /> },
{ value: 'light', label: 'Light', icon: <Sun className="size-4" /> },
{ value: 'dark', label: 'Dark', icon: <Moon className="size-4" /> },
];
const presetOptions = Object.entries(themePresets).filter(([, p]) => p.featured).slice(0, 5).map(([id, p]) => ({ id, label: p.label, emoji: p.emoji }));
const activePreset = theme === 'custom' && customTheme ? Object.entries(themePresets).find(([, p]) => JSON.stringify(p.colors) === JSON.stringify(customTheme)) : undefined;
const sidebarUserThemes = useUserThemes(user?.pubkey);
const activeUserTheme = theme === 'custom' && customTheme && !activePreset ? sidebarUserThemes.data?.find(t => JSON.stringify(t.colors) === JSON.stringify(customTheme)) : undefined;
const currentThemeLabel = (() => {
if (theme !== 'custom') return builtinThemeOptions.find(t => t.value === theme)?.label ?? theme;
if (activePreset) return activePreset[1].label;
if (activeUserTheme) return activeUserTheme.title;
return 'Custom';
})();
const currentThemeIcon = (() => {
const builtin = builtinThemeOptions.find(t => t.value === theme);
if (builtin) return builtin.icon;
if (activePreset) return <span className="text-sm leading-none">{activePreset[1].emoji}</span>;
return <Palette className="size-4" />;
})();
return (
<aside className="flex flex-col h-screen sticky top-0 py-3 px-4 w-[300px] shrink-0">
{/* Logo */}
@@ -233,7 +204,7 @@ export function LeftSidebar() {
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
placeholder="What are you up to?"
className="h-8 text-sm"
className="h-8 text-base md:text-sm"
maxLength={80}
autoFocus
onKeyDown={(e) => {
@@ -298,7 +269,7 @@ export function LeftSidebar() {
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
>
{userStatus.status ? (
<span className="truncate text-muted-foreground italic text-xs">{userStatus.status}</span>
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
) : (
<span className="text-muted-foreground">Set a status</span>
)}
@@ -326,59 +297,6 @@ export function LeftSidebar() {
</div>
)}
{/* Theme */}
<div className="border-b border-border py-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center justify-between w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
<div className="flex items-center gap-3"><Palette className="size-4 text-muted-foreground" /><span>Theme</span></div>
<div className="flex items-center gap-2 text-muted-foreground">{currentThemeIcon}<span className="text-xs">{currentThemeLabel}</span><ChevronDown className="size-4" /></div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="w-48 z-[270]">
{builtinThemeOptions.map((opt) => (
<DropdownMenuItem key={opt.value} onClick={() => setTheme(opt.value)} className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2">{opt.icon}<span>{opt.label}</span></div>
{theme === opt.value && <Check className="size-4 text-primary" />}
</DropdownMenuItem>
))}
{presetOptions.map((preset) => {
const p = themePresets[preset.id];
const isActive = theme === 'custom' && customTheme && JSON.stringify(customTheme.colors) === JSON.stringify(p.colors);
return (
<DropdownMenuItem key={preset.id} onClick={() => applyCustomTheme({ colors: p.colors, font: p.font, background: p.background })} className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2"><span className="text-sm leading-none">{preset.emoji}</span><span>{preset.label}</span></div>
{isActive && <Check className="size-4 text-primary" />}
</DropdownMenuItem>
);
})}
{sidebarUserThemes.data && sidebarUserThemes.data.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-muted-foreground">My Themes</DropdownMenuLabel>
{sidebarUserThemes.data.map((ut) => {
const isActive = theme === 'custom' && customTheme && JSON.stringify(customTheme.colors) === JSON.stringify(ut.colors);
return (
<DropdownMenuItem key={ut.identifier} onClick={() => applyCustomTheme({ colors: ut.colors, font: ut.font, background: ut.background ?? customTheme?.background })} className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2 min-w-0"><Palette className="size-3.5 text-primary shrink-0" /><span className="truncate">{ut.title}</span></div>
{isActive && <Check className="size-4 text-primary shrink-0" />}
</DropdownMenuItem>
);
})}
</>
)}
{customTheme && !activePreset && !activeUserTheme && (
<DropdownMenuItem onClick={() => { setTheme('custom'); }} className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2"><Palette className="size-4" /><span>Custom</span></div>
{theme === 'custom' && <Check className="size-4 text-primary" />}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => { setAccountPopoverOpen(false); navigate('/themes'); }} className="cursor-pointer text-muted-foreground">More...</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Actions */}
<div className="py-1">
<button onClick={() => { setAccountPopoverOpen(false); setLoginDialogOpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
+1 -1
View File
@@ -169,7 +169,7 @@ export function LiveStreamChat({ aTag, className }: LiveStreamChatProps) {
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Say something..."
className="flex-1 h-9 text-sm"
className="flex-1 h-9 text-base md:text-sm"
disabled={isSending}
maxLength={500}
/>
+6 -3
View File
@@ -22,6 +22,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getDisplayName } from '@/lib/getDisplayName';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { canZap } from '@/lib/canZap';
import { getEffectiveStreamStatus } from '@/lib/streamStatus';
import { cn } from '@/lib/utils';
/** Extract the first value of a tag by name. */
@@ -84,7 +85,8 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
const streamUrl = getTag(event.tags, 'streaming');
const recordingUrl = getTag(event.tags, 'recording');
const imageUrl = getTag(event.tags, 'image');
const status = getTag(event.tags, 'status');
const rawStatus = getTag(event.tags, 'status');
const status = getEffectiveStreamStatus(event);
const currentParticipants = getTag(event.tags, 'current_participants');
const starts = getTag(event.tags, 'starts');
const hashtags = event.tags.filter(([n]) => n === 't').map(([, v]) => v);
@@ -97,8 +99,9 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
const dTag = getTag(event.tags, 'd') || '';
const aTag = `30311:${event.pubkey}:${dTag}`;
// The URL to play: prefer streaming for live, recording for ended
const playUrl = status === 'ended' ? (recordingUrl || streamUrl) : streamUrl;
// The URL to play: use the raw status tag (not the staleness heuristic)
// so that streams marked live always try the streaming URL first.
const playUrl = rawStatus === 'ended' ? (recordingUrl || streamUrl) : streamUrl;
useSeoMeta({ title: `${title} - ${config.appName}` });
+3 -3
View File
@@ -64,7 +64,7 @@ function PageSkeleton() {
/** Inner component that reads layout options from the context store. */
function MainLayoutInner() {
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll } = useLayoutSnapshot();
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const { config } = useAppContext();
@@ -88,7 +88,7 @@ function MainLayoutInner() {
{/* Main content + right sidebar: inside Suspense so the left sidebar persists while lazy pages load */}
<Suspense fallback={<PageSkeleton />}>
<div className={cn("relative flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l border-r border-border bg-background/85", !noOverscroll && "pb-overscroll")}>
<div className={cn("relative flex-1 min-w-0 sidebar:border-l border-r border-border bg-background/85", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}>
<Outlet />
{showFAB && (
<div className="sticky bottom-fab sidebar:bottom-6 z-30 pointer-events-none flex justify-end pr-6">
@@ -98,7 +98,7 @@ function MainLayoutInner() {
</div>
)}
</div>
{rightSidebar ?? <RightSidebar />}
{rightSidebar !== null && (rightSidebar ?? <RightSidebar />)}
</Suspense>
</div>
+13 -3
View File
@@ -23,7 +23,12 @@ import { useIsMobile } from '@/hooks/useIsMobile';
export type MediaType = 'image' | 'video' | 'audio';
function detectType(url: string, mime?: string): MediaType {
/** Event kinds that are inherently video content (vines, horizontal video, vertical video). */
const VIDEO_KINDS = new Set([34236, 21, 22]);
/** Event kinds that are inherently audio content (music tracks, podcast episodes/trailers). */
const AUDIO_KINDS = new Set([36787, 34139, 30054, 30055, 1222]);
function detectType(url: string, mime?: string, eventKind?: number): MediaType {
if (mime) {
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
@@ -31,6 +36,11 @@ function detectType(url: string, mime?: string): MediaType {
}
if (/\.(mp4|webm|mov|qt|m3u8)(\?.*)?$/i.test(url)) return 'video';
if (/\.(mp3|ogg|flac|wav|aac|opus)(\?.*)?$/i.test(url)) return 'audio';
// Fall back to event kind for extensionless URLs (e.g. Blossom content-addressed URLs)
if (eventKind !== undefined) {
if (VIDEO_KINDS.has(eventKind)) return 'video';
if (AUDIO_KINDS.has(eventKind)) return 'audio';
}
return 'image';
}
@@ -153,7 +163,7 @@ export function eventToMediaItem(event: NostrEvent): MediaItem | null {
const imeta = parseImeta(event.tags);
if (imeta.length > 0) {
const first = imeta[0];
const firstType = detectType(first.url, first.mime);
const firstType = detectType(first.url, first.mime, event.kind);
return {
url: first.url,
type: firstType,
@@ -162,7 +172,7 @@ export function eventToMediaItem(event: NostrEvent): MediaItem | null {
alt: first.alt,
mime: first.mime,
allUrls: imeta.map((e) => e.url),
allTypes: imeta.map((e) => detectType(e.url, e.mime)),
allTypes: imeta.map((e) => detectType(e.url, e.mime, event.kind)),
allDims: imeta.map((e) => e.dim),
event,
hasMultiple: imeta.length > 1,
+18
View File
@@ -6,12 +6,30 @@ import { formatTime } from '@/lib/formatTime';
/** Audio file extensions used to detect audio URLs. */
const AUDIO_EXTENSIONS = /\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i;
/** Image file extensions used to detect image URLs. */
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i;
/** Video file extensions used to detect video URLs. */
const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|qt)(\?.*)?$/i;
/** Check whether a URL points to an audio file by extension. */
export function isAudioUrl(url: string): boolean {
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
return AUDIO_EXTENSIONS.test(url);
}
/** Check whether a URL points to an image file by extension. */
export function isImageUrl(url: string): boolean {
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
return IMAGE_EXTENSIONS.test(url);
}
/** Check whether a URL points to a video file by extension. */
export function isVideoUrl(url: string): boolean {
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
return VIDEO_EXTENSIONS.test(url);
}
interface MiniAudioPlayerProps {
src: string;
label?: string;
+16 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Bell, Search, User } from 'lucide-react';
import { Bell, Mail, Search, User } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { cn } from '@/lib/utils';
@@ -77,6 +77,21 @@ export function MobileBottomNav() {
</Link>
)}
{/* Messages */}
{user && (
<Link
to="/messages"
onClick={() => setSearchOpen(false)}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
location.pathname === '/messages' ? 'text-primary' : 'text-muted-foreground',
)}
>
<Mail className="size-5" />
<span className="text-[10px] font-medium">Messages</span>
</Link>
)}
{/* Profile */}
{user ? (
<Link
+3 -17
View File
@@ -6,7 +6,7 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import { SidebarThemeDropdown } from '@/components/SidebarThemeDropdown';
import { LoginArea } from '@/components/auth/LoginArea';
import { EmojifiedText } from '@/components/CustomEmoji';
import LoginDialog from '@/components/auth/LoginDialog';
@@ -142,7 +142,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
placeholder="What are you up to?"
className="h-8 text-sm"
className="h-8 text-base md:text-sm"
maxLength={80}
autoFocus
onKeyDown={(e) => {
@@ -207,7 +207,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
className="flex items-center gap-3 w-full px-3 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
>
{userStatus.status ? (
<span className="truncate text-muted-foreground italic text-xs">{userStatus.status}</span>
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
) : (
<span className="text-muted-foreground">Set a status</span>
)}
@@ -287,13 +287,6 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</div>
</nav>
{/* Theme */}
<div
className="flex items-center border-t border-border"
style={{ minHeight: '3.5rem', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<SidebarThemeDropdown userPubkey={user.pubkey} onNavigate={handleClose} className="flex items-center justify-between w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 rounded-full transition-colors" />
</div>
</div>
) : (
<div className="flex flex-col h-full relative">
@@ -335,13 +328,6 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</div>
</nav>
{/* Theme */}
<div
className="flex items-center border-t border-border"
style={{ minHeight: '3.5rem', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<SidebarThemeDropdown onNavigate={handleClose} className="flex items-center justify-between w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 rounded-full transition-colors" />
</div>
</div>
)}
</SheetContent>
+8 -7
View File
@@ -83,6 +83,7 @@ import { isSingleImagePost } from "@/lib/noteContent";
import { shareOrCopy } from "@/lib/share";
import { timeAgo } from "@/lib/timeAgo";
import { formatNumber } from "@/lib/formatNumber";
import { getEffectiveStreamStatus } from "@/lib/streamStatus";
import { cn } from "@/lib/utils";
interface NoteCardProps {
@@ -843,7 +844,7 @@ export function NoteCard({
(() => {
const cfg = KIND_HEADER_MAP[event.kind];
const isLive =
event.kind === 30311 && getTag(event.tags, "status") === "live";
event.kind === 30311 && getEffectiveStreamStatus(event) === "live";
return (
<EventActionHeader
pubkey={event.pubkey}
@@ -857,7 +858,7 @@ export function NoteCard({
}
action={
typeof cfg.action === "function"
? cfg.action(event.tags)
? cfg.action(event.tags, event)
: cfg.action
}
noun={cfg.noun}
@@ -1305,7 +1306,7 @@ function StreamContent({ event }: { event: NostrEvent }) {
const summary = getTag(event.tags, "summary");
const imageUrl = getTag(event.tags, "image");
const streamingUrl = getTag(event.tags, "streaming");
const status = getTag(event.tags, "status");
const status = getEffectiveStreamStatus(event);
const currentParticipants = getTag(event.tags, "current_participants");
const statusConfig = getStreamStatusConfig(status);
@@ -1491,8 +1492,8 @@ interface EventActionHeaderProps {
interface KindHeaderConfig {
icon: React.ComponentType<{ className?: string }>;
iconClassName?: string;
/** Static action string, or a function that computes it from the event's tags. */
action: string | ((tags: string[][]) => string);
/** Static action string, or a function that computes it from the event's tags (and optionally the full event). */
action: string | ((tags: string[][], event?: NostrEvent) => string);
noun?: string;
nounRoute?: string;
}
@@ -1549,8 +1550,8 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
30311: {
icon: Radio,
iconClassName: undefined, // computed dynamically below
action: (tags) =>
tags.find(([n]) => n === "status")?.[1] === "live"
action: (_tags, event) =>
event && getEffectiveStreamStatus(event) === "live"
? "is streaming"
: "streamed",
},
+1 -1
View File
@@ -651,7 +651,7 @@ export function NoteContent({
<Link
key={i}
to={`/t/${token.tag}`}
className="text-primary hover:underline"
className="text-primary hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{token.raw}
+18 -18
View File
@@ -77,7 +77,7 @@ function EditableTextarea({
e.target.style.height = e.target.scrollHeight + 'px';
}}
rows={1}
className={cn(editableBase, 'w-full min-w-0 py-1 resize-none overflow-hidden text-sm text-muted-foreground leading-relaxed', className)}
className={cn(editableBase, 'w-full min-w-0 py-1 resize-none overflow-hidden text-base md:text-sm text-muted-foreground leading-relaxed', className)}
/>
);
}
@@ -348,7 +348,7 @@ export function ProfileCard({
onChange={(e) => patch('nip05')(e.target.value)}
onBlur={() => setNip05Focused(false)}
size={Math.max((nip05?.length ?? 0) + 1, 4)}
className={cn(editableBase, 'py-0.5 h-6 text-sm text-muted-foreground border-primary bg-transparent')}
className={cn(editableBase, 'py-0.5 h-6 text-base md:text-sm text-muted-foreground border-primary bg-transparent')}
/>
) : (
<span
@@ -403,23 +403,23 @@ export function ProfileCard({
placeholder="https://yourwebsite.com"
value={(metadata.website as string) ?? ''}
onChange={(e) => patch('website')(e.target.value)}
className="h-8 text-sm"
/>
</div>
className="h-8 text-base md:text-sm"
/>
</div>
{extraFields.map((field, i) => (
<div key={i} className="grid grid-cols-[1fr,2fr,auto] gap-2 items-center">
<Input
placeholder="Label"
value={field.label}
onChange={(e) => updateField(i, 'label', e.target.value)}
className="h-8 text-sm"
/>
<Input
placeholder="Value or URL"
value={field.value}
onChange={(e) => updateField(i, 'value', e.target.value)}
className="h-8 text-sm"
{extraFields.map((field, i) => (
<div key={i} className="grid grid-cols-[1fr,2fr,auto] gap-2 items-center">
<Input
placeholder="Label"
value={field.label}
onChange={(e) => updateField(i, 'label', e.target.value)}
className="h-8 text-base md:text-sm"
/>
<Input
placeholder="Value or URL"
value={field.value}
onChange={(e) => updateField(i, 'value', e.target.value)}
className="h-8 text-base md:text-sm"
/>
<Button type="button" variant="ghost" size="icon" onClick={() => removeField(i)} className="h-8 w-8 text-destructive hover:text-destructive">
<Trash2 className="size-3.5" />
+1 -1
View File
@@ -112,7 +112,7 @@ function ProfileHoverCardBody({ pubkey }: { pubkey: string }) {
{/* NIP-38 user status */}
{userStatus && (
<p className="text-xs text-muted-foreground italic mt-2 truncate">
<p className="text-xs text-muted-foreground italic mt-2 truncate pr-1">
{statusUrl ? (
<a href={statusUrl} target="_blank" rel="noopener noreferrer" className="hover:underline" onClick={(e) => e.stopPropagation()}>
{userStatus}
+69 -23
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Check, Copy, QrCode, ExternalLink, Bitcoin, ShieldAlert, Mail } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
@@ -15,7 +16,8 @@ import type { AddrCoords } from '@/hooks/useEvent';
import QRCode from 'qrcode';
import { useAppContext } from '@/hooks/useAppContext';
import { getContentWarning } from '@/lib/contentWarning';
import { MiniAudioPlayer, isAudioUrl } from '@/components/MiniAudioPlayer';
import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer';
import { VideoPlayer } from '@/components/VideoPlayer';
import { parseDimToAspectRatio } from '@/components/MediaCollage';
/** Simple email regex for display purposes. */
@@ -66,6 +68,8 @@ interface ProfileRightSidebarProps {
mediaLoading?: boolean;
/** Called when a media tile is clicked. If provided, tiles don't navigate. */
onMediaClick?: (url: string) => void;
/** Override the root element's className (e.g. to show on mobile). */
className?: string;
}
interface MediaItem {
@@ -81,6 +85,8 @@ interface MediaItem {
blurhash?: string;
/** NIP-94 dim value from the imeta tag, e.g. "1280x720". */
dim?: string;
/** MIME type from the imeta `m` field, e.g. "video/mp4". */
mime?: string;
}
/** Extracts image URLs from content. */
@@ -95,8 +101,8 @@ function extractVideoUrls(content: string): string[] {
return content.match(regex) || [];
}
/** Extract url, blurhash, and dim from the first matching imeta tag for a given URL (or the first tag if no URL given). */
function extractImetaFields(event: NostrEvent, matchUrl?: string): { url?: string; blurhash?: string; dim?: string } {
/** Extract url, blurhash, dim, and mime from the first matching imeta tag for a given URL (or the first tag if no URL given). */
function extractImetaFields(event: NostrEvent, matchUrl?: string): { url?: string; blurhash?: string; dim?: string; mime?: string } {
const imetaTags = event.tags.filter(([name]) => name === 'imeta');
for (const imetaTag of imetaTags) {
const fields: Record<string, string> = {};
@@ -107,7 +113,7 @@ function extractImetaFields(event: NostrEvent, matchUrl?: string): { url?: strin
}
if (!fields.url) continue;
if (matchUrl && fields.url !== matchUrl) continue;
return { url: fields.url, blurhash: fields.blurhash, dim: fields.dim };
return { url: fields.url, blurhash: fields.blurhash, dim: fields.dim, mime: fields.m };
}
return {};
}
@@ -125,11 +131,11 @@ function extractMedia(events: NostrEvent[], cwPolicy: string): MediaItem[] {
// For media-native kinds (vines etc.), extract from imeta tags
if (event.kind !== 1) {
const { url, blurhash, dim } = extractImetaFields(event);
const { url, blurhash, dim, mime } = extractImetaFields(event);
if (url && !seen.has(url)) {
seen.add(url);
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
items.push({ url, blurhash, dim, eventId: event.id, authorPubkey: event.pubkey, kind: event.kind, dTag, hasContentWarning: hasCW });
items.push({ url, blurhash, dim, mime, eventId: event.id, authorPubkey: event.pubkey, kind: event.kind, dTag, hasContentWarning: hasCW });
}
continue;
}
@@ -140,8 +146,8 @@ function extractMedia(events: NostrEvent[], cwPolicy: string): MediaItem[] {
for (const url of [...images, ...videos]) {
if (!seen.has(url)) {
seen.add(url);
const { blurhash, dim } = extractImetaFields(event, url);
items.push({ url, blurhash, dim, eventId: event.id, authorPubkey: event.pubkey, hasContentWarning: hasCW });
const { blurhash, dim, mime } = extractImetaFields(event, url);
items.push({ url, blurhash, dim, mime, eventId: event.id, authorPubkey: event.pubkey, hasContentWarning: hasCW });
}
}
}
@@ -149,11 +155,22 @@ function extractMedia(events: NostrEvent[], cwPolicy: string): MediaItem[] {
return items.slice(0, 9);
}
/** Event kinds that are inherently video content. */
const VIDEO_KINDS = new Set([34236, 21, 22]);
/** Detect whether a media item is a video using mime type, file extension, or event kind. */
function isVideoItem(item: MediaItem): boolean {
if (item.mime?.startsWith('video/')) return true;
if (/\.(mp4|webm|mov|qt)(\?.*)?$/i.test(item.url)) return true;
if (item.kind !== undefined && VIDEO_KINDS.has(item.kind)) return true;
return false;
}
/** Single media tile with a blurhash/skeleton shown until the image loads. */
function MediaTile({ item }: { item: MediaItem }) {
const [loaded, setLoaded] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const isVideo = /\.(mp4|webm|mov|qt)(\?.*)?$/i.test(item.url);
const isVideo = isVideoItem(item);
useEffect(() => {
if (imgRef.current?.complete && imgRef.current.naturalWidth > 0) {
@@ -368,7 +385,7 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
);
}
// Audio file: render mini player
// Media fields: render inline players/previews based on file extension
const isUrl = field.value.startsWith('http://') || field.value.startsWith('https://');
if (isUrl && isAudioUrl(field.value)) {
@@ -380,6 +397,33 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
);
}
if (isUrl && isImageUrl(field.value)) {
return (
<div>
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
<a href={field.value} target="_blank" rel="noopener noreferrer" className="block">
<img
src={field.value}
alt={field.label || 'Profile image'}
className="w-full rounded-lg object-cover"
loading="lazy"
/>
</a>
</div>
);
}
if (isUrl && isVideoUrl(field.value)) {
return (
<div>
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
<div className="rounded-lg overflow-hidden">
<VideoPlayer src={field.value} />
</div>
</div>
);
}
// Regular field: label + linked value with favicon
return (
<div>
@@ -430,7 +474,7 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh
return rows;
}
export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLoadingProp, onMediaClick }: ProfileRightSidebarProps) {
export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLoadingProp, onMediaClick, className }: ProfileRightSidebarProps) {
const { config } = useAppContext();
const media = useMemo(
() => extractMedia(mediaEvents ?? [], config.contentWarningPolicy),
@@ -441,9 +485,9 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
return (
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3">
{/* Media Section */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<aside className={cn("w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
{/* Media Section — only shown when mediaEvents prop is provided */}
{mediaEvents !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<h2 className="text-xl font-bold mb-3">Media</h2>
{mediaLoading ? (
<div className="flex flex-col gap-0.5">
@@ -538,7 +582,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
) : (
<p className="text-sm text-muted-foreground">No media yet.</p>
)}
</section>
</section>}
{/* Profile Fields Section */}
{fields && fields.length > 0 && (
@@ -552,14 +596,16 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
</section>
)}
{/* Footer */}
<footer className="mt-auto pt-4 pb-4 text-left bg-background/85 rounded-xl p-3 -mx-1">
<p className="text-xs text-muted-foreground">
<a href="https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git" className="text-primary hover:underline" target="_blank" rel="noopener noreferrer">
Edit Ditto with Shakespeare
</a>
</p>
</footer>
{/* Footer — hidden when used as a fields-only preview */}
{mediaEvents !== undefined && (
<footer className="mt-auto pt-4 pb-4 text-left bg-background/85 rounded-xl p-3 -mx-1">
<p className="text-xs text-muted-foreground">
<a href="https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git" className="text-primary hover:underline" target="_blank" rel="noopener noreferrer">
Edit Ditto with Shakespeare
</a>
</p>
</footer>
)}
</aside>
);
}
+1 -1
View File
@@ -232,7 +232,7 @@ export function ProfileTabEditModal({
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. photography, travel..."
className="bg-secondary/50 border-border focus-visible:ring-1 h-9 text-sm"
className="bg-secondary/50 border-border focus-visible:ring-1 h-9 text-base md:text-sm"
/>
</div>
+1 -1
View File
@@ -352,7 +352,7 @@ export function RelayListManager() {
handleAddRelay();
}
}}
className="h-9 text-sm"
className="h-9 text-base md:text-sm"
/>
</div>
<Button
+5 -2
View File
@@ -32,6 +32,8 @@ interface ReplyComposeModalProps {
onSuccess?: () => void;
/** Pre-filled content for the compose box. */
initialContent?: string;
/** Open directly in poll mode. */
initialMode?: 'post' | 'poll';
/** Override the modal title. */
title?: string;
/** Override the compose box placeholder text. */
@@ -44,7 +46,7 @@ function extractImages(content: string): string[] {
return content.match(urlRegex) || [];
}
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSuccess, initialContent, title: titleOverride, placeholder: placeholderOverride }: ReplyComposeModalProps) {
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSuccess, initialContent, initialMode, title: titleOverride, placeholder: placeholderOverride }: ReplyComposeModalProps) {
const isUrl = event instanceof URL;
const isReply = !!event;
const isQuote = !!quotedEvent;
@@ -53,7 +55,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const isProfileRoot = !isUrl && event instanceof Object && 'kind' in event && event.kind === 0;
const title = titleOverride ?? (isUrl ? 'New comment' : isProfileRoot ? 'Comment on profile' : isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post');
const title = titleOverride ?? (initialMode === 'poll' ? 'New poll' : isUrl ? 'New comment' : isProfileRoot ? 'Comment on profile' : isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post');
const placeholder = placeholderOverride ?? (isUrl ? 'Write a comment...' : isReply ? "What's on your mind?" : isQuote ? 'Add a comment...' : "What's happening?");
const dialogContentRef = useCallback((node: HTMLElement | null) => {
@@ -136,6 +138,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
previewMode={previewMode}
onHasPreviewableContentChange={setHasPreviewableContent}
initialContent={initialContent}
initialMode={initialMode}
/>
</div>
</PortalContainerProvider>
+464
View File
@@ -0,0 +1,464 @@
import { useState, useCallback, useEffect } from 'react';
import { Globe, Radio, Loader2, X, ArrowRight, ArrowLeft, Flame } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import { useRequestToVanish } from '@/hooks/useRequestToVanish';
import { useAppContext } from '@/hooks/useAppContext';
import { useLoginActions } from '@/hooks/useLoginActions';
import { toast } from '@/hooks/useToast';
interface RequestToVanishDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
type VanishMode = 'global' | 'targeted';
type Step = 0 | 1 | 2;
const STEPS = ['Scope', 'Details', 'Confirm'] as const;
const CONFIRMATION_PHRASE = 'VANISH';
export function RequestToVanishDialog({ open, onOpenChange }: RequestToVanishDialogProps) {
const { config } = useAppContext();
const { mutateAsync: requestVanish, isPending } = useRequestToVanish();
const { logout } = useLoginActions();
const [step, setStep] = useState<Step>(0);
const [mode, setMode] = useState<VanishMode>('global');
const [reason, setReason] = useState('');
const [confirmText, setConfirmText] = useState('');
const userRelays = config.relayMetadata.relays.map((r) => r.url);
const isConfirmed = confirmText === CONFIRMATION_PHRASE;
const resetState = useCallback(() => {
setStep(0);
setMode('global');
setReason('');
setConfirmText('');
}, []);
// Reset when dialog closes.
useEffect(() => {
if (!open) resetState();
}, [open, resetState]);
const handleSubmit = async () => {
if (!isConfirmed) return;
try {
const relayUrls = mode === 'global' ? ['ALL_RELAYS'] : userRelays;
await requestVanish({ relayUrls, content: reason.trim() });
toast({
title: 'Request to vanish sent',
description: mode === 'global'
? 'Your request has been broadcast. Compliant relays will delete your data.'
: `Your request was sent to ${userRelays.length} relay(s).`,
});
onOpenChange(false);
await logout();
} catch {
toast({
title: 'Failed to send request',
description: 'Some relays may not have received the request. You can try again.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[440px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[90dvh] [&>button]:hidden">
{/* ── Header ── */}
<div className="relative overflow-hidden">
{/* Gradient backdrop */}
<div className="absolute inset-0 bg-gradient-to-b from-destructive/10 via-destructive/5 to-transparent" />
<div className="relative px-5 pt-5 pb-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-destructive/15 ring-1 ring-destructive/20 shrink-0">
<Flame className="size-5 text-destructive" />
</div>
<div>
<DialogTitle className="text-base font-bold">Request to Vanish</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
Permanently erase your data from relays
</DialogDescription>
</div>
</div>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1 -mt-0.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-4" />
</button>
</div>
{/* Step indicator */}
<div className="flex items-center gap-1.5 mt-4">
{STEPS.map((label, i) => (
<div key={label} className="flex items-center gap-1.5 flex-1">
<div className="flex-1 flex flex-col items-center gap-1">
<div className="w-full h-1 rounded-full overflow-hidden bg-muted/60">
<div
className={cn(
'h-full rounded-full transition-all duration-500 ease-out',
i <= step ? 'bg-destructive w-full' : 'w-0',
)}
/>
</div>
<span className={cn(
'text-[10px] font-medium transition-colors',
i <= step ? 'text-destructive' : 'text-muted-foreground/50',
)}>
{label}
</span>
</div>
</div>
))}
</div>
</div>
</div>
<Separator />
{/* ── Step Content ── */}
<div className="overflow-y-auto min-h-0 flex-1">
{step === 0 && <StepScope mode={mode} setMode={setMode} userRelays={userRelays} />}
{step === 1 && <StepDetails reason={reason} setReason={setReason} mode={mode} userRelays={userRelays} />}
{step === 2 && (
<StepConfirm
confirmText={confirmText}
setConfirmText={setConfirmText}
mode={mode}
relayCount={userRelays.length}
/>
)}
</div>
<Separator />
{/* ── Footer ── */}
<div className="flex items-center justify-between px-5 py-3.5">
{step > 0 ? (
<Button
variant="ghost"
size="sm"
onClick={() => setStep((s) => (s - 1) as Step)}
disabled={isPending}
className="gap-1.5 text-muted-foreground"
>
<ArrowLeft className="size-3.5" />
Back
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isPending}
className="text-muted-foreground"
>
Cancel
</Button>
)}
{step < 2 ? (
<Button
size="sm"
onClick={() => setStep((s) => (s + 1) as Step)}
className="gap-1.5"
>
Continue
<ArrowRight className="size-3.5" />
</Button>
) : (
<Button
size="sm"
onClick={handleSubmit}
disabled={!isConfirmed || isPending}
className="gap-1.5 bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-40"
>
{isPending ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Sending...
</>
) : (
<>
<Flame className="size-3.5" />
Vanish
</>
)}
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}
/* ───────────────────────── Step 0: Scope ───────────────────────── */
function StepScope({
mode,
setMode,
userRelays,
}: {
mode: VanishMode;
setMode: (m: VanishMode) => void;
userRelays: string[];
}) {
return (
<div className="px-5 py-5 space-y-4">
<div>
<h3 className="text-sm font-semibold">Choose scope</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Select which relays should delete your data. This determines the reach of your vanish request.
</p>
</div>
<div className="space-y-2">
<ScopeCard
selected={mode === 'global'}
onClick={() => setMode('global')}
icon={<Globe className="size-5" />}
title="All relays"
description="Request every relay on the network to delete your data. The event is broadcast as widely as possible."
badge="Recommended"
/>
<ScopeCard
selected={mode === 'targeted'}
onClick={() => setMode('targeted')}
icon={<Radio className="size-5" />}
title={`My relays only (${userRelays.length})`}
description="Request only your currently configured relays to delete your data."
/>
</div>
{/* Relay list preview for targeted mode */}
{mode === 'targeted' && userRelays.length > 0 && (
<div className="rounded-lg bg-muted/40 border border-border/50 px-3 py-2.5 space-y-1.5 animate-in fade-in-0 slide-in-from-top-1 duration-200">
<p className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Target relays</p>
<ul className="space-y-0.5">
{userRelays.map((url) => (
<li key={url} className="text-xs font-mono text-muted-foreground truncate">{url}</li>
))}
</ul>
</div>
)}
</div>
);
}
function ScopeCard({
selected,
onClick,
icon,
title,
description,
badge,
}: {
selected: boolean;
onClick: () => void;
icon: React.ReactNode;
title: string;
description: string;
badge?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'w-full text-left rounded-xl border-2 p-3.5 transition-all duration-200',
'hover:bg-secondary/30',
selected
? 'border-destructive/60 bg-destructive/[0.03] shadow-sm shadow-destructive/5'
: 'border-border/60 bg-transparent',
)}
>
<div className="flex items-start gap-3">
<div className={cn(
'flex size-9 items-center justify-center rounded-lg shrink-0 transition-colors',
selected ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground',
)}>
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{title}</span>
{badge && (
<span className="text-[10px] font-medium bg-destructive/10 text-destructive rounded-full px-2 py-0.5">
{badge}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{description}</p>
</div>
{/* Selection indicator */}
<div className={cn(
'size-4 rounded-full border-2 shrink-0 mt-0.5 transition-all duration-200 flex items-center justify-center',
selected ? 'border-destructive bg-destructive' : 'border-muted-foreground/30',
)}>
{selected && <div className="size-1.5 rounded-full bg-white" />}
</div>
</div>
</button>
);
}
/* ───────────────────────── Step 1: Details ───────────────────────── */
function StepDetails({
reason,
setReason,
mode,
userRelays,
}: {
reason: string;
setReason: (r: string) => void;
mode: VanishMode;
userRelays: string[];
}) {
return (
<div className="px-5 py-5 space-y-5">
{/* Summary of what will happen */}
<div className="rounded-xl bg-destructive/[0.04] border border-destructive/15 p-4 space-y-3">
<h3 className="text-sm font-semibold text-destructive flex items-center gap-2">
<Flame className="size-4" />
What will be deleted
</h3>
<ul className="space-y-2">
{[
'Your profile (kind 0) and metadata',
'All posts, replies, and reactions',
'Direct messages and gift wraps',
'Contact lists, relay lists, and settings',
'All other events published by your key',
].map((item) => (
<li key={item} className="flex items-start gap-2 text-xs text-muted-foreground leading-relaxed">
<span className="text-destructive/60 mt-0.5 shrink-0">&mdash;</span>
{item}
</li>
))}
</ul>
<p className="text-[11px] text-destructive/70 pt-1 border-t border-destructive/10">
{mode === 'global'
? 'This request will be sent to all relays on the network.'
: `This request will be sent to ${userRelays.length} relay(s).`}
</p>
</div>
{/* Reason */}
<div className="space-y-2">
<Label htmlFor="vanish-reason" className="text-sm font-medium">
Reason or legal notice
</Label>
<p className="text-xs text-muted-foreground leading-relaxed">
Optionally include a message for the relay operator. This is included in the event's content field.
</p>
<Textarea
id="vanish-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. GDPR Article 17 — Right to erasure"
className="resize-none text-sm"
rows={3}
/>
</div>
</div>
);
}
/* ───────────────────────── Step 2: Confirm ───────────────────────── */
function StepConfirm({
confirmText,
setConfirmText,
mode,
relayCount,
}: {
confirmText: string;
setConfirmText: (t: string) => void;
mode: VanishMode;
relayCount: number;
}) {
const isMatch = confirmText === CONFIRMATION_PHRASE;
return (
<div className="px-5 py-5 space-y-5">
{/* Final warning */}
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4 text-center space-y-2">
<div className="flex justify-center">
<div className="size-12 rounded-full bg-destructive/15 flex items-center justify-center">
<Flame className="size-6 text-destructive" />
</div>
</div>
<h3 className="text-sm font-bold text-destructive">This action is irreversible</h3>
<p className="text-xs text-muted-foreground leading-relaxed max-w-[280px] mx-auto">
Once sent, compliant relays will permanently delete your events.
Deletion requests (kind 5) against this event have no effect.
You will be logged out immediately.
</p>
</div>
{/* Scope summary */}
<div className="flex items-center gap-3 rounded-lg bg-muted/40 px-3.5 py-2.5">
{mode === 'global' ? (
<Globe className="size-4 text-muted-foreground shrink-0" />
) : (
<Radio className="size-4 text-muted-foreground shrink-0" />
)}
<span className="text-xs text-muted-foreground">
{mode === 'global'
? 'Targeting all relays on the network'
: `Targeting ${relayCount} configured relay(s)`}
</span>
</div>
{/* Confirmation input */}
<div className="space-y-2.5">
<Label htmlFor="vanish-confirm" className="text-sm font-medium">
Type{' '}
<span className="font-mono bg-destructive/10 text-destructive px-1.5 py-0.5 rounded text-xs">
{CONFIRMATION_PHRASE}
</span>{' '}
to confirm
</Label>
<Input
id="vanish-confirm"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value.toUpperCase())}
placeholder={CONFIRMATION_PHRASE}
className={cn(
'font-mono text-center text-lg tracking-widest transition-colors',
isMatch && 'border-destructive/50 ring-1 ring-destructive/20',
)}
autoComplete="off"
spellCheck={false}
/>
<p className={cn(
'text-center text-xs transition-opacity duration-300',
isMatch ? 'text-destructive opacity-100' : 'text-muted-foreground/40 opacity-0',
)}>
Confirmation accepted
</p>
</div>
</div>
);
}
+4
View File
@@ -235,6 +235,10 @@ export function RightSidebar() {
<a href="https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git" className="text-primary hover:underline" target="_blank" rel="noopener noreferrer">
Edit Ditto with Shakespeare
</a>
{' · '}
<Link to="/privacy" className="text-primary hover:underline">
Privacy
</Link>
</p>
</footer>
</aside>
+6 -6
View File
@@ -215,7 +215,7 @@ export function KindPicker({ value, options, onChange }: {
<div className="flex items-center gap-1.5 px-2.5 py-2 border-b border-border shrink-0">
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
<input
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
className="flex-1 text-base md:text-xs bg-transparent outline-none placeholder:text-muted-foreground"
placeholder="Search kinds..."
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -314,7 +314,7 @@ export function MultiKindPicker({ selectedKinds, options, onChange }: {
<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-sm bg-transparent outline-none placeholder:text-muted-foreground"
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)}
@@ -503,7 +503,7 @@ export function ListPackPicker({ lists, followPacks, value, onSelectPubkeys, cla
if (pubkeys.length > 0) onSelectPubkeys(pubkeys);
}}
>
<SelectTrigger className={cn('w-full bg-secondary/50 h-8 text-xs', className)}>
<SelectTrigger className={cn('w-full bg-secondary/50 h-8 text-base md:text-xs', className)}>
<SelectValue placeholder="Or choose a list..." />
</SelectTrigger>
<SelectContent>
@@ -577,7 +577,7 @@ export function AuthorFilterDropdown({ onCommit }: { onCommit: (pubkey: string,
placeholder="Search by name or npub..."
onSelect={handleSelect}
hideCountry
inputClassName="rounded-lg bg-secondary/50 border border-border focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 text-sm h-9"
inputClassName="rounded-lg bg-secondary/50 border border-border focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 text-base md:text-sm h-9"
className="w-full"
/>
);
@@ -708,7 +708,7 @@ export function SavedFeedFiltersEditor({
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-sm"
className="bg-secondary/50 border-border focus-visible:ring-1 h-8 text-base md:text-sm"
/>
</div>
<Separator />
@@ -784,7 +784,7 @@ export function SavedFeedFiltersEditor({
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-xs h-8"
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
/>
)}
</div>
+2 -2
View File
@@ -128,7 +128,7 @@ export function SidebarMoreMenu({
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
<Search className="size-5 shrink-0" />
<input value={addQuery} onChange={(e) => setAddQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
<input value={addQuery} onChange={(e) => setAddQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
</div>
<div className="h-px bg-border mb-1 shrink-0" />
{add.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => add.startScroll('up')} onMouseLeave={add.stopScroll} />}
@@ -162,7 +162,7 @@ export function SidebarMoreMenu({
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
<Search className="size-5 shrink-0" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
</div>
<div className="h-px bg-border mb-1 shrink-0" />
{main.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => main.startScroll('up')} onMouseLeave={main.stopScroll} />}
-117
View File
@@ -1,117 +0,0 @@
import { Palette, Sun, Moon, Monitor, Check, ChevronDown } from 'lucide-react';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
DropdownMenuLabel, DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { useTheme } from '@/hooks/useTheme';
import { useUserThemes } from '@/hooks/useUserThemes';
import { useNavigate } from 'react-router-dom';
import { themePresets } from '@/themes';
import type { Theme } from '@/contexts/AppContext';
interface SidebarThemeDropdownProps {
userPubkey?: string;
onNavigate?: () => void;
/** Extra classes on the trigger button */
className?: string;
}
export function SidebarThemeDropdown({ userPubkey, onNavigate, className }: SidebarThemeDropdownProps) {
const navigate = useNavigate();
const { theme, setTheme, applyCustomTheme, customTheme } = useTheme();
const userThemes = useUserThemes(userPubkey);
const builtinOptions: { value: Theme; label: string; icon: React.ReactNode }[] = [
{ value: 'system', label: 'System', icon: <Monitor className="size-4" /> },
{ value: 'light', label: 'Light', icon: <Sun className="size-4" /> },
{ value: 'dark', label: 'Dark', icon: <Moon className="size-4" /> },
];
const presetOptions = Object.entries(themePresets)
.filter(([, p]) => p.featured)
.slice(0, 5)
.map(([id, p]) => ({ id, label: p.label, emoji: p.emoji }));
const activePreset = theme === 'custom' && customTheme
? Object.entries(themePresets).find(([, p]) => JSON.stringify(p.colors) === JSON.stringify(customTheme.colors))
: undefined;
const activeUserTheme = theme === 'custom' && customTheme && !activePreset
? userThemes.data?.find(t => JSON.stringify(t.colors) === JSON.stringify(customTheme.colors))
: undefined;
const currentLabel = (() => {
if (theme !== 'custom') return builtinOptions.find(t => t.value === theme)?.label ?? theme;
if (activePreset) return activePreset[1].label;
if (activeUserTheme) return activeUserTheme.title;
return 'Custom';
})();
const currentIcon = (() => {
const builtin = builtinOptions.find(t => t.value === theme);
if (builtin) return builtin.icon;
if (activePreset) return <span className="text-sm leading-none">{activePreset[1].emoji}</span>;
return <Palette className="size-4" />;
})();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className={className ?? 'flex items-center justify-between w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 rounded-full transition-colors'}>
<div className="flex items-center gap-3">
<Palette className="size-4 text-muted-foreground" />
<span>Theme</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
{currentIcon}
<span className="text-xs">{currentLabel}</span>
<ChevronDown className="size-4" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="w-48 z-[270]">
{builtinOptions.map((opt) => (
<DropdownMenuItem key={opt.value} onClick={() => setTheme(opt.value)} className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2">{opt.icon}<span>{opt.label}</span></div>
{theme === opt.value && <Check className="size-4 text-primary" />}
</DropdownMenuItem>
))}
{presetOptions.map((preset) => {
const p = themePresets[preset.id];
const isActive = theme === 'custom' && customTheme && JSON.stringify(customTheme.colors) === JSON.stringify(p.colors);
return (
<DropdownMenuItem key={preset.id} onClick={() => applyCustomTheme({ colors: p.colors, font: p.font, background: p.background })} className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2"><span className="text-sm leading-none">{preset.emoji}</span><span>{preset.label}</span></div>
{isActive && <Check className="size-4 text-primary" />}
</DropdownMenuItem>
);
})}
{userThemes.data && userThemes.data.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-muted-foreground">My Themes</DropdownMenuLabel>
{userThemes.data.map((ut) => {
const isActive = theme === 'custom' && customTheme && JSON.stringify(customTheme.colors) === JSON.stringify(ut.colors);
return (
<DropdownMenuItem key={ut.identifier} onClick={() => applyCustomTheme({ colors: ut.colors, font: ut.font, background: ut.background ?? customTheme?.background })} className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2 min-w-0"><Palette className="size-3.5 text-primary shrink-0" /><span className="truncate">{ut.title}</span></div>
{isActive && <Check className="size-4 text-primary shrink-0" />}
</DropdownMenuItem>
);
})}
</>
)}
{customTheme && !activePreset && !activeUserTheme && (
<DropdownMenuItem onClick={() => { setTheme('custom'); }} className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2"><Palette className="size-4" /><span>Custom</span></div>
{theme === 'custom' && <Check className="size-4 text-primary" />}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => { onNavigate?.(); navigate('/themes'); }} className="cursor-pointer text-muted-foreground">
More...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { cn } from '@/lib/utils';
interface TabButtonProps {
/** Tab display label. */
label: string;
/** Whether this tab is currently selected. */
active: boolean;
/** Called when the tab is clicked. Scroll-to-top is handled internally. */
onClick: () => void;
/** Disable the button (e.g. when logged out). */
disabled?: boolean;
/** Extra classes forwarded to the `<button>`. */
className?: string;
/** Override the default indicator bar classes. */
indicatorClassName?: string;
/** Optional children rendered inside the button instead of the label text. */
children?: React.ReactNode;
}
/**
* Shared sticky-tab button used across all feed / profile pages.
*
* Behaviour (see gitlab#109):
* - Clicking the **active** tab smooth-scrolls to the top.
* - Switching to a **different** tab resets scroll position instantly.
*/
export function TabButton({ label, active, onClick, disabled, className, indicatorClassName, children }: TabButtonProps) {
const handleClick = () => {
if (active) {
// Re-tapping the active tab -> smooth scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
// Switching tabs -> jump to top instantly, then switch
window.scrollTo({ top: 0 });
onClick();
}
};
return (
<button
onClick={handleClick}
disabled={disabled}
className={cn(
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 px-4 whitespace-nowrap',
active ? 'text-foreground' : 'text-muted-foreground',
disabled && 'opacity-50 cursor-not-allowed hover:bg-transparent',
className,
)}
>
{children ?? label}
{active && (
<div className={cn('absolute bottom-0 left-1/2 -translate-x-1/2 h-1 bg-primary rounded-full w-3/4 max-w-16', indicatorClassName)} />
)}
</button>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useMemo, type ReactNode } from 'react';
import { ArrowLeft, Plus, Check, Loader2 } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { NoteCard } from '@/components/NoteCard';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { DITTO_RELAY } from '@/lib/appRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useInterests } from '@/hooks/useInterests';
import { useMuteList } from '@/hooks/useMuteList';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
interface TagFeedPageProps {
/** The tag value to filter by. */
tag: string;
/** The Nostr filter key, e.g. '#t' or '#g'. */
filterKey: '#t' | '#g';
/** Icon shown before the title in the header. */
icon?: ReactNode;
/** Title text displayed in the header. */
title: string;
/** Whether to show a follow/unfollow button (hashtags only). */
followable?: boolean;
/** Extra relay search param (e.g. 'sort:hot'). */
search?: string;
/** Empty state message. */
emptyMessage: string;
}
function FeedSkeleton() {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
export function TagFeedPage({
tag,
filterKey,
icon,
title,
followable = false,
search,
emptyMessage,
}: TagFeedPageProps) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { feedSettings } = useFeedSettings();
const { muteItems } = useMuteList();
const interestTagName = filterKey === '#g' ? 'g' : 't';
const { hasInterest, addInterest, removeInterest } = useInterests(interestTagName);
const isFollowing = followable ? hasInterest(tag) : false;
const interestPending = addInterest.isPending || removeInterest.isPending;
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
const kindsKey = [...kinds].sort().join(',');
const { data: events, isLoading } = useQuery<NostrEvent[]>({
queryKey: ['tag-feed', filterKey, tag, kindsKey],
queryFn: async ({ signal }) => {
const ditto = nostr.relay(DITTO_RELAY);
const tagFilter: NostrFilter = { kinds, limit: 40, ...(search ? { search } : {}) };
// NostrFilter uses `#${letter}` index signature — assign after construction to satisfy TS
(tagFilter as Record<string, unknown>)[filterKey] = [tag];
return ditto.query([tagFilter], {
signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]),
});
},
enabled: !!tag,
});
const filteredEvents = useMemo(() => {
if (!events || muteItems.length === 0) return events;
return events.filter((e) => !isEventMuted(e, muteItems));
}, [events, muteItems]);
return (
<main className="">
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-4 px-4 pt-4 pb-5 bg-background/80 backdrop-blur-md z-10')}>
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
{icon && <span className="text-muted-foreground shrink-0">{icon}</span>}
<h1 className="text-xl font-bold flex-1 truncate min-w-0">{title}</h1>
{followable && user && tag && (
<Button
size="sm"
variant={isFollowing ? 'outline' : 'default'}
className="rounded-full gap-1.5 shrink-0"
disabled={interestPending}
onClick={() => isFollowing ? removeInterest.mutate(tag) : addInterest.mutate(tag)}
>
{interestPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : isFollowing ? (
<><Check className="size-3.5" /> Following</>
) : (
<><Plus className="size-3.5" /> Follow</>
)}
</Button>
)}
</div>
{isLoading ? (
<FeedSkeleton />
) : filteredEvents && filteredEvents.length > 0 ? (
filteredEvents.map((event) => <NoteCard key={event.id} event={event} />)
) : (
<div className="py-16 text-center text-muted-foreground px-4">
<span className="break-all">{emptyMessage}</span>
</div>
)}
</main>
);
}
+1 -1
View File
@@ -415,7 +415,7 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
id='connectBunkerUri'
value={bunkerUri}
onChange={(e) => setBunkerUri(e.target.value)}
className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary text-sm'
className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary text-base md:text-sm'
placeholder='bunker://'
/>
{bunkerUri && !validateBunkerUri(bunkerUri) && (
+8 -1
View File
@@ -28,6 +28,12 @@ export interface LayoutOptions {
* that manage their own scroll containers.
*/
noOverscroll?: boolean;
/**
* If true, removes the max-width constraint on the center column so it
* expands to fill available space. Use with `rightSidebar: null` for
* full-width page layouts (e.g. messaging).
*/
noMaxWidth?: boolean;
}
type Listener = () => void;
@@ -95,7 +101,8 @@ export function useLayoutOptions(options: LayoutOptions): void {
prev.current.wrapperClassName !== options.wrapperClassName ||
prev.current.rightSidebar !== options.rightSidebar ||
prev.current.scrollContainer !== options.scrollContainer ||
prev.current.noOverscroll !== options.noOverscroll;
prev.current.noOverscroll !== options.noOverscroll ||
prev.current.noMaxWidth !== options.noMaxWidth;
if (changed) {
prev.current = options;
+9 -9
View File
@@ -10,7 +10,7 @@ import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import type { NostrEvent } from '@nostrify/nostrify';
export function useInterests() {
export function useInterests(tagName: 't' | 'g' = 't') {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
@@ -33,13 +33,13 @@ export function useInterests() {
staleTime: 60 * 1000,
});
/** All hashtags the user follows, normalized to lowercase. */
/** All interests for this tag type, normalized to lowercase. */
const hashtags: string[] = (interestsQuery.data?.tags ?? [])
.filter(([name]) => name === 't')
.filter(([name]) => name === tagName)
.map(([, value]) => value.toLowerCase())
.filter((v, i, arr) => arr.indexOf(v) === i); // deduplicate
/** Check if the user follows a specific hashtag. */
/** Check if the user follows a specific interest. */
function hasInterest(tag: string): boolean {
return hashtags.includes(tag.toLowerCase());
}
@@ -48,7 +48,7 @@ export function useInterests() {
queryClient.invalidateQueries({ queryKey: ['interests', user?.pubkey] });
};
/** Add a hashtag interest. */
/** Add an interest. */
const addInterest = useMutation({
mutationFn: async (tag: string) => {
if (!user) throw new Error('Must be logged in');
@@ -59,9 +59,9 @@ export function useInterests() {
const currentTags = existing?.tags ?? [];
// Don't add duplicates
if (currentTags.some(([n, v]) => n === 't' && v.toLowerCase() === normalized)) return;
if (currentTags.some(([n, v]) => n === tagName && v.toLowerCase() === normalized)) return;
const newTags = [...currentTags, ['t', normalized]];
const newTags = [...currentTags, [tagName, normalized]];
await publishEvent({
kind: 10015,
content: existing?.content ?? '',
@@ -71,7 +71,7 @@ export function useInterests() {
onSuccess: invalidate,
});
/** Remove a hashtag interest. */
/** Remove an interest. */
const removeInterest = useMutation({
mutationFn: async (tag: string) => {
if (!user) throw new Error('Must be logged in');
@@ -81,7 +81,7 @@ export function useInterests() {
if (!existing) return;
const newTags = existing.tags.filter(
([name, value]) => !(name === 't' && value.toLowerCase() === normalized),
([name, value]) => !(name === tagName && value.toLowerCase() === normalized),
);
await publishEvent({
kind: 10015,
+8 -106
View File
@@ -1,8 +1,6 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect } from 'react';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { LocalNotifications } from '@capacitor/local-notifications';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NPool } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useAppContext } from './useAppContext';
@@ -16,58 +14,24 @@ interface DittoNotificationPlugin {
const DittoNotification = registerPlugin<DittoNotificationPlugin>('DittoNotification');
/** Human-readable label for a notification event kind. */
function notificationTitle(event: NostrEvent): string {
switch (event.kind) {
case 7: return 'New reaction';
case 6:
case 16: return 'New repost';
case 9735: return 'New zap';
case 1111: return 'New comment';
case 1222:
case 1244: return 'New voice message';
default: return 'New mention';
}
}
/**
* Hook that manages device/browser notifications for the Nostr app.
* Manages the native Android notification service via Capacitor.
*
* Capacitor (native): passes user pubkey + relay URLs to the native Android
* notification service. Respects the user's notificationsEnabled setting.
* Passes user pubkey + relay URLs to the DittoNotification plugin so it can
* poll for events in the background. Respects the NIP-78 notificationsEnabled
* setting (defaults to on).
*
* Web/PWA: subscribes to Nostr events via a live relay subscription and
* fires browser Notification API notifications when the user has both
* granted browser permission and enabled notifications in their settings.
* Web Push (nostr-push) is handled separately by usePushNotifications +
* NotificationSettings this hook is Capacitor-only.
*/
export function useNativeNotifications(): void {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { settings } = useEncryptedSettings();
const notificationsEnabled = settings?.notificationsEnabled ?? true;
const notifPrefs = useMemo(() => settings?.notificationPreferences ?? {}, [settings?.notificationPreferences]);
// Track the subscription start time so we only surface events that arrive
// after the subscription is opened (avoids replaying historical events).
const subStartRef = useRef<number>(Math.floor(Date.now() / 1000));
// Keep a stable ref to the nostr object to avoid re-subscribing on every render.
const nostrRef = useRef<NPool>(nostr);
useEffect(() => { nostrRef.current = nostr; }, [nostr]);
// Keep a stable ref to per-type prefs so the async loop reads the latest
// values without triggering a reconnect on every preference change.
const notifPrefsRef = useRef(notifPrefs);
useEffect(() => { notifPrefsRef.current = notifPrefs; }, [notifPrefs]);
// Deduplicate: track event IDs that have already triggered a notification.
const seenIdsRef = useRef<Set<string>>(new Set());
// ── Capacitor path ────────────────────────────────────────────────────────
// Request native notification permission on first mount (native only).
// Request native notification permission on first mount.
useEffect(() => {
if (!Capacitor.isNativePlatform()) return;
@@ -88,7 +52,6 @@ export function useNativeNotifications(): void {
if (!Capacitor.isNativePlatform()) return;
if (!user || !notificationsEnabled) {
// Logged out or user disabled notifications — stop the native service.
DittoNotification.configure({});
return;
}
@@ -105,65 +68,4 @@ export function useNativeNotifications(): void {
relayUrls,
});
}, [user, config.relayMetadata, config.useAppRelays, notificationsEnabled]);
// ── Web / PWA path ────────────────────────────────────────────────────────
useEffect(() => {
// Only run on web (not native Capacitor).
if (Capacitor.isNativePlatform()) return;
// Need a logged-in user, notifications enabled in settings, and browser permission.
if (!user || !notificationsEnabled) return;
if (!('Notification' in window) || Notification.permission !== 'granted') return;
// Record when we opened the subscription so old events are ignored.
subStartRef.current = Math.floor(Date.now() / 1000);
const controller = new AbortController();
(async () => {
try {
const stream = nostrRef.current.req(
[{
kinds: [1, 6, 7, 16, 9735, 1111, 1222, 1244],
'#p': [user.pubkey],
since: subStartRef.current,
}],
{ signal: controller.signal },
);
for await (const msg of stream) {
if (msg[0] !== 'EVENT') continue;
const event: NostrEvent = msg[2];
// Skip own events
if (event.pubkey === user.pubkey) continue;
// Skip events older than when the sub opened (relay may send a burst)
if (event.created_at < subStartRef.current) continue;
// Deduplicate: skip if we've already shown a notification for this event
if (seenIdsRef.current.has(event.id)) continue;
seenIdsRef.current.add(event.id);
// Respect per-type preferences (default = enabled when not explicitly false)
const prefs = notifPrefsRef.current;
if (event.kind === 7 && prefs.reactions === false) continue;
if ((event.kind === 6 || event.kind === 16) && prefs.reposts === false) continue;
if (event.kind === 9735 && prefs.zaps === false) continue;
if (event.kind === 1 && prefs.mentions === false) continue;
if (event.kind === 1111 && prefs.comments === false) continue;
new Notification(notificationTitle(event), {
body: event.content.slice(0, 120) || undefined,
tag: event.id,
icon: '/favicon.ico',
});
}
} catch {
// Subscription closed or errored — ignore
}
})();
return () => {
controller.abort();
};
}, [user, notificationsEnabled]);
}
+12 -6
View File
@@ -288,13 +288,19 @@ export function useNotifications(): NotificationData {
}
}
// Build notification items
const items: NotificationItem[] = filtered.map((ev) => {
// Build notification items, filtering out reactions/reposts on posts the
// user didn't author (i.e. they were only tagged in them).
const items: NotificationItem[] = filtered.flatMap((ev) => {
const refId = (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244) ? getReferencedEventId(ev) : undefined;
return {
event: ev,
referencedEvent: refId ? referencedMap.get(refId) : undefined,
};
const referencedEvent = refId ? referencedMap.get(refId) : undefined;
// For reactions (7), reposts (6, 16), and zaps (9735), only notify if
// the referenced post was authored by the current user.
if (ev.kind === 7 || ev.kind === 6 || ev.kind === 16 || ev.kind === 9735) {
if (!referencedEvent || referencedEvent.pubkey !== user.pubkey) return [];
}
return [{ event: ev, referencedEvent }];
});
return { items, oldestTimestamp };
+9
View File
@@ -73,6 +73,15 @@ export function usePlayerControls({
const [volume, setVolume] = useState(1);
const prevVolumeRef = useRef(1);
// Sync initial muted/volume state from the media element (e.g. when the
// <video> has a `muted` attribute for autoplay).
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setIsMuted(media.muted);
setVolume(media.muted ? 0 : media.volume);
}, [mediaRef]);
const toggleMute = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
const media = mediaRef.current;
+231
View File
@@ -0,0 +1,231 @@
/**
* usePushNotifications
*
* Manages the Web Push notification lifecycle via nostr-push.
*
* - Registers the service worker and restores push state on mount.
* - enable(): fetches the VAPID key, subscribes to Web Push, and registers
* per-type subscriptions with nostr-push. Must be called from a user gesture
* AFTER Notification.requestPermission() has already been granted.
* - disable(): deletes server subscriptions and unsubscribes the browser.
*
* Uses an ephemeral device keypair (persisted in localStorage) to sign RPC
* events so the user's Nostr signer is never prompted.
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { NostrPushClient, serializePushSubscription, urlBase64ToUint8Array } from '@/lib/nostrPush';
import { NOTIFICATION_TEMPLATES } from '@/lib/notificationTemplates';
// ─── Config ───────────────────────────────────────────────────────────────────
const SERVER_PUBKEY: string = import.meta.env.VITE_NOSTR_PUSH_PUBKEY ?? '';
const DOMAIN = typeof window !== 'undefined' ? window.location.hostname : '';
/** Relays used for the RPC channel to nostr-push. */
const RPC_RELAYS = [
'wss://relay.ditto.pub/',
'wss://relay.primal.net/',
'wss://relay.damus.io/',
];
// localStorage keys
const VAPID_KEY_CACHE = 'ditto-push-vapid-key';
const SUBSCRIPTION_ID_KEY = 'ditto-push-subscription-id';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getOrCreateSubscriptionId(): string {
const existing = localStorage.getItem(SUBSCRIPTION_ID_KEY);
if (existing) return existing;
const id = crypto.randomUUID();
localStorage.setItem(SUBSCRIPTION_ID_KEY, id);
return id;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export interface UsePushNotificationsReturn {
/** Current browser permission state. */
permission: NotificationPermission;
/** Whether Web Push is currently active and registered. */
enabled: boolean;
/** Whether the browser and environment support Web Push. */
supported: boolean;
/** Subscribe and register with nostr-push. Caller must request permission first. */
enable: (userPubkey: string) => Promise<void>;
/** Unsubscribe from Web Push and delete server registrations. */
disable: () => Promise<void>;
}
export function usePushNotifications(): UsePushNotificationsReturn {
const supported =
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
!!SERVER_PUBKEY;
const [permission, setPermission] = useState<NotificationPermission>(
typeof Notification !== 'undefined' ? Notification.permission : 'default',
);
const [enabled, setEnabled] = useState(false);
const pushSubRef = useRef<PushSubscription | null>(null);
const clientRef = useRef<NostrPushClient | null>(null);
const swRegistrationRef = useRef<ServiceWorkerRegistration | null>(null);
// Pre-fetched VAPID key so enable() doesn't need an async network call
// before pushManager.subscribe() — browsers require that call to be
// synchronously reachable from the user gesture.
const vapidKeyRef = useRef<string | null>(null);
// ─── Register SW + restore state on mount ─────────────────────────────────
useEffect(() => {
if (!supported) return;
const client = new NostrPushClient(SERVER_PUBKEY, RPC_RELAYS);
clientRef.current = client;
navigator.serviceWorker
.register('/sw.js', { scope: '/' })
.then((reg) => {
swRegistrationRef.current = reg;
return navigator.serviceWorker.ready;
})
.then(async (reg) => {
// Pre-fetch and cache the VAPID key so it is ready before the user
// clicks "Enable". This keeps pushManager.subscribe() as the first
// async step inside enable(), satisfying the browser's user-gesture
// requirement (otherwise the intermediate network await breaks the
// activation chain and throws "DOMException: The operation is insecure").
let vapidKey = localStorage.getItem(VAPID_KEY_CACHE);
if (!vapidKey) {
try {
vapidKey = await client.getVapidKey(DOMAIN);
localStorage.setItem(VAPID_KEY_CACHE, vapidKey);
} catch (err) {
console.warn('[push] Failed to pre-fetch VAPID key:', err);
}
}
if (vapidKey) {
vapidKeyRef.current = vapidKey;
}
// Returning user: if permission is already granted and a browser push
// subscription exists, restore the enabled state silently.
if (Notification.permission === 'granted') {
const existing = await reg.pushManager.getSubscription();
if (existing) {
pushSubRef.current = existing;
setPermission('granted');
setEnabled(true);
}
}
})
.catch((err) => console.error('[push] SW registration failed:', err));
return () => {
clientRef.current?.destroy();
clientRef.current = null;
};
}, []);
// ─── enable() ─────────────────────────────────────────────────────────────
const enable = useCallback(async (userPubkey: string) => {
if (!supported) return;
// Caller must have already obtained permission (from a user gesture).
if (typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
console.warn('[push] enable() called but Notification.permission is', Notification.permission);
return;
}
const client = clientRef.current;
if (!client) {
console.warn('[push] NostrPushClient not initialized — service worker may still be loading');
return;
}
// Use the VAPID key pre-fetched on mount (already in vapidKeyRef and
// localStorage). Avoid any network round-trip here — an async await
// before pushManager.subscribe() breaks the user-gesture activation chain
// and causes "DOMException: The operation is insecure" in strict browsers.
let vapidPublicKey = vapidKeyRef.current ?? localStorage.getItem(VAPID_KEY_CACHE);
if (!vapidPublicKey) {
// Should rarely happen (pre-fetch failed on mount). Log a warning but
// still attempt the fetch; on browsers that enforce the gesture chain
// this may still throw the insecure-operation error.
console.warn('[push] VAPID key not pre-fetched; fetching now (may fail on strict browsers)');
vapidPublicKey = await client.getVapidKey(DOMAIN);
localStorage.setItem(VAPID_KEY_CACHE, vapidPublicKey);
vapidKeyRef.current = vapidPublicKey;
}
// Get or create the browser push subscription.
const reg = swRegistrationRef.current ?? await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
pushSubRef.current = sub;
// Register one subscription per notification type with nostr-push.
const baseId = getOrCreateSubscriptionId();
const serialized = serializePushSubscription(sub);
await Promise.all(NOTIFICATION_TEMPLATES.map((tmpl) =>
client.registerSubscription({
subscription_id: `${baseId}-${tmpl.id}`,
domain: DOMAIN,
filter: {
kinds: tmpl.kinds,
'#p': [userPubkey],
},
notification: {
title: tmpl.title,
body: tmpl.body,
icon: '/icon-192.png',
badge: '/icon-192.png',
},
push_subscription: serialized,
}),
));
setEnabled(true);
}, [supported]);
// ─── disable() ────────────────────────────────────────────────────────────
const disable = useCallback(async () => {
const client = clientRef.current;
const baseId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
if (client && baseId) {
await Promise.allSettled(
NOTIFICATION_TEMPLATES.map((tmpl) =>
client.deleteSubscription({
subscription_id: `${baseId}-${tmpl.id}`,
domain: DOMAIN,
}).catch((err) => console.error(`[push] Failed to delete ${tmpl.id}:`, err)),
),
);
}
const pushSub = pushSubRef.current;
if (pushSub) {
try {
await pushSub.unsubscribe();
} catch { /* ignore */ }
pushSubRef.current = null;
}
setEnabled(false);
}, []);
return { permission, enabled, supported, enable, disable };
}
+63
View File
@@ -0,0 +1,63 @@
import { useNostr } from '@nostrify/react';
import { useMutation } from '@tanstack/react-query';
import { useAppContext } from './useAppContext';
import { useCurrentUser } from './useCurrentUser';
/**
* Hook to publish a NIP-62 Request to Vanish (kind 62) event.
*
* - Targeted: sends to specific relays listed in `relay` tags.
* - Global: sends to ALL_RELAYS and broadcasts to as many relays as possible.
*
* After publishing, the user should be logged out since the identity is being erased.
*/
export function useRequestToVanish() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
return useMutation({
mutationFn: async ({ relayUrls, content }: { relayUrls: string[]; content: string }) => {
if (!user) throw new Error('User is not logged in');
const isGlobal = relayUrls.includes('ALL_RELAYS');
const tags: string[][] = relayUrls.map((url) => ['relay', url]);
const event = await user.signer.signEvent({
kind: 62,
content,
tags,
created_at: Math.floor(Date.now() / 1000),
});
if (isGlobal) {
// For global vanish, broadcast to as many relays as possible.
// Send to the user's configured relays via the default pool.
await nostr.event(event, { signal: AbortSignal.timeout(10_000) });
// Also send directly to each configured relay individually for redundancy.
const relaySet = new Set(
config.relayMetadata.relays.map((r) => r.url),
);
const directSends = [...relaySet].map((url) =>
nostr.relay(url).event(event, { signal: AbortSignal.timeout(10_000) }).catch(() => {
// Swallow individual relay errors — best-effort delivery.
}),
);
await Promise.allSettled(directSends);
} else {
// For targeted vanish, send to each specified relay.
const sends = relayUrls.map((url) =>
nostr.relay(url).event(event, { signal: AbortSignal.timeout(10_000) }).catch(() => {
// Swallow individual relay errors — best-effort delivery.
}),
);
await Promise.allSettled(sends);
}
return event;
},
});
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function useThemeFeed(tab: 'follows' | 'global' = 'global') {
const followsReady = tab !== 'follows' || (!!user && followList !== undefined);
return useInfiniteQuery({
queryKey: ['theme-feed', tab, user?.pubkey ?? '', followList?.length ?? 0],
queryKey: ['theme-feed', tab, user?.pubkey ?? ''],
queryFn: async ({ pageParam }) => {
const signal = AbortSignal.timeout(5000);
const baseUntil = pageParam as number | undefined;
+33 -2
View File
@@ -113,7 +113,7 @@ export const FAQ_CATEGORIES: FAQCategory[] = [
question: 'Can I download this on the App Store or Google Play?',
answer: [
'This site works as a web app right from your browser \u2014 no download needed! You can also "Add to Home Screen" on your phone to get an app-like experience.',
'Native app store releases are planned for the future \u2014 stay tuned!',
'On Android, you can download Ditto from [Zap Store](https://zapstore.dev/apps/pub.ditto.app), a community-driven app store for the Nostr ecosystem. iOS support is planned for the future \u2014 stay tuned!',
],
},
{
@@ -128,7 +128,7 @@ export const FAQ_CATEGORIES: FAQCategory[] = [
id: 'nostr-app-store',
question: 'Is there a Nostr-specific app store?',
answer: [
'Yes! [Zap Store](https://zapstore.dev/) is a community-driven app store built specifically for the Nostr ecosystem. You can discover and download Nostr apps, and the apps are verified by the community rather than a corporation.',
'Yes! [Zap Store](https://zapstore.dev/) is a community-driven app store built specifically for the Nostr ecosystem. You can discover and download Nostr apps, and the apps are verified by the community rather than a corporation. Ditto is listed there \u2014 [get it on Zap Store](https://zapstore.dev/apps/pub.ditto.app).',
'You can also browse a directory of Nostr apps at [nostrapps.com](https://nostrapps.com/).',
],
},
@@ -224,6 +224,37 @@ export const FAQ_CATEGORIES: FAQCategory[] = [
],
},
// ── Profile & Identity ───────────────────────────────────────────────────
{
id: 'profile-identity',
label: 'Profile & Identity',
items: [
{
id: 'profile-fields',
question: 'What are profile fields?',
answer: [
'Profile fields let you add extra info to your profile sidebar — like links, wallet addresses, music, photos, videos, and more. They\'re a way to express yourself and share what matters to you.',
'You can add fields from the profile settings page. Each field has a **label** (what it\'s called) and a **value** (the content). For media fields, you can upload files directly and they\'ll render as players or embeds on your profile.',
],
},
{
id: 'profile-fields-music',
question: 'What audio formats can I upload for music fields?',
answer: [
'You can upload audio files in these formats: **MP3**, **OGG**, **WAV**, **FLAC**, **AAC**, **M4A**, and **Opus**. They\'ll appear as a mini audio player on your profile sidebar.',
],
},
{
id: 'profile-fields-media',
question: 'What image and video formats are supported?',
answer: [
'For images: **JPG**, **PNG**, **GIF**, **WebP**, **SVG**, and **AVIF**. For video: **MP4**, **WebM**, and **MOV**.',
'Images will display as linked thumbnails, and videos will be embedded inline on your profile.',
],
},
],
},
// ── Why is this different from Big Tech? ────────────────────────────────
{
id: 'big-tech',
+224
View File
@@ -0,0 +1,224 @@
/**
* nostr-push RPC client
*
* Sends RPC calls to the nostr-push server via encrypted Nostr events
* (kind 25742, NIP-44) and awaits a confirmation response.
*
* Uses an ephemeral keypair (generated once, persisted in localStorage)
* to avoid prompting the user's signer for every RPC call.
*
* Protocol:
* Request: kind 25742, tags [["p", serverPubkey]]
* content: nip44Encrypt(serverPubkey, JSON.stringify({ method, params, request_id }))
*
* Response: kind 25742, tags [["p", clientPubkey]], authored by serverPubkey
* content: nip44Encrypt(clientPubkey, JSON.stringify({ request_id, success, error? }))
*/
import { nip44, generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
import { SimplePool } from 'nostr-tools';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
// ─── Ephemeral device key ─────────────────────────────────────────────────────
const DEVICE_KEY_STORAGE = 'ditto-push-device-key';
/**
* Get or generate a persistent ephemeral key for this device.
* Used to sign nostr-push RPC events without prompting the user's signer.
*/
function getDeviceSecretKey(): Uint8Array {
const stored = localStorage.getItem(DEVICE_KEY_STORAGE);
if (stored) {
return hexToBytes(stored);
}
const sk = generateSecretKey();
localStorage.setItem(DEVICE_KEY_STORAGE, bytesToHex(sk));
return sk;
}
// ─── Types ────────────────────────────────────────────────────────────────────
export interface WebPushSubscription {
type: 'web';
endpoint: string;
p256dh_key: string;
auth_key: string;
}
export interface RegisterSubscriptionParams {
subscription_id: string;
domain: string;
filter: {
kinds?: number[];
authors?: string[];
'#p'?: string[];
'#t'?: string[];
since?: number;
until?: number;
};
notification: {
title: string;
body: string;
icon?: string;
badge?: string;
};
push_subscription: WebPushSubscription;
}
export interface DeleteSubscriptionParams {
subscription_id: string;
domain: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Convert a browser PushSubscription to the flat structure nostr-push expects. */
export function serializePushSubscription(sub: PushSubscription): WebPushSubscription {
const keys = sub.toJSON().keys;
if (!keys?.p256dh || !keys?.auth) {
throw new Error('PushSubscription is missing p256dh or auth keys');
}
return {
type: 'web',
endpoint: sub.endpoint,
p256dh_key: keys.p256dh,
auth_key: keys.auth,
};
}
/** Convert a base64url string to a Uint8Array (for applicationServerKey). */
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
// ─── Client ───────────────────────────────────────────────────────────────────
/** How long to wait for a server response before giving up. */
const RESPONSE_TIMEOUT_MS = 15_000;
export class NostrPushClient {
private pool: SimplePool;
private secretKey: Uint8Array;
private publicKey: string;
constructor(
/** The nostr-push server's pubkey (hex). */
private readonly serverPubkey: string,
/** Relays to publish RPC calls to. */
private readonly relays: string[],
) {
this.pool = new SimplePool();
this.secretKey = getDeviceSecretKey();
this.publicKey = getPublicKey(this.secretKey);
}
/**
* Get the VAPID public key for a domain.
* Must be called before pushManager.subscribe() so the browser gets
* the correct applicationServerKey for this domain.
*/
async getVapidKey(domain: string): Promise<string> {
const result = await this.send('get_vapid_key', { domain });
const vapidKey = (result as { vapid_public_key?: string })?.vapid_public_key;
if (!vapidKey) throw new Error('nostr-push: server did not return a VAPID key');
return vapidKey;
}
/** Register (or replace) a push subscription. */
async registerSubscription(params: RegisterSubscriptionParams): Promise<void> {
await this.send('register_subscription', params);
}
/** Delete a push subscription by its ID. */
async deleteSubscription(params: DeleteSubscriptionParams): Promise<void> {
await this.send('delete_subscription', params);
}
/** Close the relay pool. */
destroy(): void {
this.pool.close(this.relays);
}
// ─── Internal ──────────────────────────────────────────────────────────────
private async send(
method: string,
params: object,
): Promise<unknown> {
const request_id = crypto.randomUUID();
// NIP-44 encrypt the payload to the server
const conversationKey = nip44.v2.utils.getConversationKey(this.secretKey, this.serverPubkey);
const encryptedContent = nip44.v2.encrypt(
JSON.stringify({ method, params, request_id }),
conversationKey,
);
const event = finalizeEvent(
{
kind: 25742,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', this.serverPubkey]],
content: encryptedContent,
},
this.secretKey,
);
// Subscribe for the response BEFORE publishing so we don't miss a fast reply.
const responsePromise = new Promise<unknown>((resolve, reject) => {
const timeout = setTimeout(() => {
sub.close();
reject(new Error(`nostr-push: RPC timeout (${method})`));
}, RESPONSE_TIMEOUT_MS);
const responseConversationKey = nip44.v2.utils.getConversationKey(this.secretKey, this.serverPubkey);
const sub = this.pool.subscribeMany(
this.relays,
[{
kinds: [25742],
authors: [this.serverPubkey],
'#p': [this.publicKey],
since: Math.floor(Date.now() / 1000) - 5,
}],
{
onevent: (responseEvent) => {
try {
const decrypted = nip44.v2.decrypt(responseEvent.content, responseConversationKey);
const response = JSON.parse(decrypted) as {
request_id: string;
success: boolean;
error?: string;
result?: unknown;
};
if (response.request_id !== request_id) return;
clearTimeout(timeout);
sub.close();
if (response.success) {
resolve(response.result);
} else {
reject(new Error(response.error ?? 'RPC call failed'));
}
} catch {
// Ignore events we can't decrypt
}
},
},
);
});
// Publish to relays — at least one must accept
await Promise.any(this.relays.map((url) => this.pool.publish([url], event))).catch(() => {
throw new Error('nostr-push: failed to publish RPC event to any relay');
});
return responsePromise;
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* nostr-push notification templates.
*
* Each entry becomes a separate server-side subscription with its own filter
* and notification template. Templates use nostr-push's variable substitution:
* {{author_name}}, {{content}}, {{amount}}.
*/
export interface NotificationTemplate {
/** Suffix appended to the base subscription ID to make it unique. */
id: string;
/** Nostr event kinds this subscription watches. */
kinds: number[];
/** Notification title template. */
title: string;
/** Notification body template. */
body: string;
}
/**
* Notification subscriptions to register with nostr-push.
* Each entry becomes a separate subscription with its own filter and template.
*/
export const NOTIFICATION_TEMPLATES: NotificationTemplate[] = [
{
id: 'reactions',
kinds: [7],
title: '{{author_name}} Reacted!',
body: '{{content}}',
},
{
id: 'reposts',
kinds: [6, 16],
title: '{{author_name}} Reposted!',
body: '{{content}}',
},
{
id: 'zaps',
kinds: [9735],
title: '{{amount}} sats!',
body: '{{author_name}} zapped you',
},
{
id: 'mentions',
kinds: [1],
title: '{{author_name}} Mentioned You!',
body: '{{content}}',
},
{
id: 'comments',
kinds: [1111],
title: '{{author_name}} Commented!',
body: '{{content}}',
},
];
+8
View File
@@ -14,6 +14,7 @@ import {
Earth,
Film,
HelpCircle,
Mail,
MessageSquare,
MessageSquareMore,
Mic,
@@ -81,6 +82,13 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
icon: Bell,
requiresAuth: true,
},
{
id: "messages",
label: "Messages",
path: "/messages",
icon: Mail,
requiresAuth: true,
},
{ id: "search", label: "Search", path: "/search", icon: Search },
{ id: "trends", label: "Trends", path: "/trends", icon: TrendingUp },
{
+34
View File
@@ -0,0 +1,34 @@
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Staleness threshold in seconds.
*
* NIP-53 suggests 1 hour, but in practice relays often serve older versions
* of addressable events (different `created_at` depending on query filters),
* which causes false positives at short thresholds. 24 hours is lenient
* enough to avoid misclassifying genuinely live streams while still catching
* streams that were abandoned without setting `status=ended`.
*/
const STALE_THRESHOLD_SECONDS = 24 * 3600; // 24 hours
/**
* Returns the effective stream status for a kind 30311 event, applying
* a staleness heuristic inspired by NIP-53: a `status=live` event whose
* `created_at` is older than 24 hours is treated as `ended`.
*
* When no status tag is present the event is treated as `ended`.
*/
export function getEffectiveStreamStatus(event: NostrEvent): string {
const status = event.tags.find(([n]) => n === 'status')?.[1] ?? 'ended';
if (status === 'live') {
const now = Math.floor(Date.now() / 1000);
if (now - event.created_at > STALE_THRESHOLD_SECONDS) {
return 'ended';
}
}
return status;
}
+1 -1
View File
@@ -426,7 +426,7 @@ export function AIChatPage() {
<div className="flex items-center gap-2">
{/* Model selector */}
<Select value={selectedModel} onValueChange={setSelectedModel} disabled={modelsLoading}>
<SelectTrigger className="w-full sidebar:w-44 h-8 text-xs">
<SelectTrigger className="w-full sidebar:w-44 h-8 text-base md:text-xs">
<SelectValue placeholder={modelsLoading ? 'Loading models...' : 'Select model'} />
</SelectTrigger>
<SelectContent>
+2 -23
View File
@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, BookMarked, Loader2, Search, X } from 'lucide-react';
import { useSeoMeta } from '@unhead/react';
import { TabButton } from '@/components/TabButton';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { PullToRefresh } from '@/components/PullToRefresh';
@@ -17,7 +18,6 @@ import { usePrefetchBookSummaries } from '@/hooks/useBookSummary';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useAppContext } from '@/hooks/useAppContext';
import { cn } from '@/lib/utils';
import type { ExtraKindDef } from '@/lib/extraKinds';
type FeedTab = 'follows' | 'global';
@@ -244,7 +244,7 @@ function BookSearchBar() {
if (debouncedQuery.length >= 2) setDropdownOpen(true);
}}
onKeyDown={handleKeyDown}
className="pl-9 pr-9 h-9 text-sm"
className="pl-9 pr-9 h-9 text-base md:text-sm"
/>
{query && (
<button
@@ -325,24 +325,3 @@ function BookSearchResultItem({ book, onSelect }: { book: BookSearchResult; onSe
</button>
);
}
// ---------------------------------------------------------------------------
// Tab Button
// ---------------------------------------------------------------------------
function TabButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={cn(
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 bg-primary rounded-full" />
)}
</button>
);
}
+1 -20
View File
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { PullToRefresh } from '@/components/PullToRefresh';
import { KindInfoButton } from '@/components/KindInfoButton';
import { NoteCard } from '@/components/NoteCard';
import { TabButton } from '@/components/TabButton';
import { useFeed } from '@/hooks/useFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
@@ -20,7 +21,6 @@ import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { getExtraKindDef } from '@/lib/extraKinds';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
type FeedTab = 'follows' | 'global';
@@ -183,22 +183,3 @@ function EventCardSkeleton() {
</div>
);
}
// ─── TabButton ────────────────────────────────────────────────────────────────
function TabButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={cn(
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 bg-primary rounded-full" />
)}
</button>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { useSeoMeta } from '@unhead/react';
import { MapPin } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { TagFeedPage } from '@/components/TagFeedPage';
import { useAppContext } from '@/hooks/useAppContext';
export function GeotagPage() {
const { config } = useAppContext();
const { geohash } = useParams<{ geohash: string }>();
useSeoMeta({
title: `${geohash} | ${config.appName}`,
description: `Posts near geohash ${geohash}`,
});
if (!geohash) return null;
return (
<TagFeedPage
tag={geohash}
filterKey="#g"
icon={<MapPin className="size-5" />}
title={geohash}
followable
emptyMessage={`No posts found near ${geohash}.`}
/>
);
}
+11 -94
View File
@@ -1,110 +1,27 @@
import { useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Plus, Check, Loader2 } from 'lucide-react';
import { Link, useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { NoteCard } from '@/components/NoteCard';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { DITTO_RELAY } from '@/lib/appRelays';
import { useParams } from 'react-router-dom';
import { TagFeedPage } from '@/components/TagFeedPage';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useInterests } from '@/hooks/useInterests';
import { useMuteList } from '@/hooks/useMuteList';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
export function HashtagPage() {
const { config } = useAppContext();
const { tag } = useParams<{ tag: string }>();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { feedSettings } = useFeedSettings();
const { muteItems } = useMuteList();
const { hasInterest, addInterest, removeInterest } = useInterests();
const isFollowing = tag ? hasInterest(tag) : false;
const interestPending = addInterest.isPending || removeInterest.isPending;
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
const kindsKey = [...kinds].sort().join(',');
useSeoMeta({
title: `#${tag} | ${config.appName}`,
description: `Posts tagged with #${tag}`,
});
const { data: events, isLoading } = useQuery<NostrEvent[]>({
queryKey: ['hashtag', tag ?? '', kindsKey],
queryFn: async ({ signal }) => {
if (!tag) return [];
const ditto = nostr.relay(DITTO_RELAY);
const results = await ditto.query(
[{ kinds, '#t': [tag.toLowerCase()], search: 'sort:hot', limit: 40 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
);
return results;
},
enabled: !!tag,
});
const filteredEvents = useMemo(() => {
if (!events || muteItems.length === 0) return events;
return events.filter((e) => !isEventMuted(e, muteItems));
}, [events, muteItems]);
if (!tag) return null;
return (
<main className="">
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-4 px-4 pt-4 pb-5 bg-background/80 backdrop-blur-md z-10')}>
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-xl font-bold flex-1">#{tag}</h1>
{user && tag && (
<Button
size="sm"
variant={isFollowing ? 'outline' : 'default'}
className="rounded-full gap-1.5 shrink-0"
disabled={interestPending}
onClick={() => isFollowing ? removeInterest.mutate(tag) : addInterest.mutate(tag)}
>
{interestPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : isFollowing ? (
<><Check className="size-3.5" /> Following</>
) : (
<><Plus className="size-3.5" /> Follow</>
)}
</Button>
)}
</div>
{isLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
) : filteredEvents && filteredEvents.length > 0 ? (
filteredEvents.map((event) => <NoteCard key={event.id} event={event} />)
) : (
<div className="py-16 text-center text-muted-foreground">
No posts found with #{tag}.
</div>
)}
</main>
<TagFeedPage
tag={tag.toLowerCase()}
filterKey="#t"
title={`#${tag}`}
followable
search="sort:hot"
emptyMessage={`No posts found with #${tag}.`}
/>
);
}
+12 -1
View File
@@ -1,5 +1,5 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, HelpCircle } from 'lucide-react';
import { ArrowLeft, HelpCircle, Shield } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useAppContext } from '@/hooks/useAppContext';
@@ -44,6 +44,17 @@ export function HelpPage() {
{/* FAQ accordion sections */}
<HelpFAQSection className="px-4 pb-8" />
{/* Privacy policy link */}
<div className="px-4 pb-8">
<Link
to="/privacy"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Shield className="size-4" />
<span>Privacy Policy</span>
</Link>
</div>
</main>
);
}
+13 -24
View File
@@ -39,6 +39,7 @@ import { shareOrCopy } from '@/lib/share';
import { getRepostKind } from '@/lib/feedUtils';
import { DITTO_RELAY } from '@/lib/appRelays';
import { toast } from '@/hooks/useToast';
import { TabButton } from '@/components/TabButton';
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
import type { UserList } from '@/hooks/useUserLists';
@@ -524,45 +525,33 @@ export function ListDetailPage() {
</div>
)}
{/* Tab bar */}
<div className="flex border-b border-border">
<button
className={cn(
'flex-1 py-2.5 text-sm font-medium text-center transition-colors relative',
activeTab === 'feed'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
<TabButton
label="Feed"
active={activeTab === 'feed'}
onClick={() => setActiveTab('feed')}
className="py-2.5"
indicatorClassName="left-1/4 right-1/4 w-auto h-0.5"
>
<span className="flex items-center justify-center gap-1.5">
<Rss className="size-4" />
Feed
</span>
{activeTab === 'feed' && (
<div className="absolute bottom-0 left-1/4 right-1/4 h-0.5 bg-primary rounded-full" />
)}
</button>
<button
className={cn(
'flex-1 py-2.5 text-sm font-medium text-center transition-colors relative',
activeTab === 'members'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
</TabButton>
<TabButton
label="Members"
active={activeTab === 'members'}
onClick={() => setActiveTab('members')}
className="py-2.5"
indicatorClassName="left-1/4 right-1/4 w-auto h-0.5"
>
<span className="flex items-center justify-center gap-1.5">
<Users className="size-4" />
Members
<span className="text-xs text-muted-foreground">({list.pubkeys.length})</span>
</span>
{activeTab === 'members' && (
<div className="absolute bottom-0 left-1/4 right-1/4 h-0.5 bg-primary rounded-full" />
)}
</button>
</TabButton>
</div>
</div>
+1 -1
View File
@@ -8,7 +8,7 @@ const Messages = () => {
description: 'Private encrypted messaging on Nostr',
});
useLayoutOptions({ noOverscroll: true });
useLayoutOptions({ noOverscroll: true, rightSidebar: null, noMaxWidth: true });
return (
<div className="bg-background">
+43 -9
View File
@@ -7,6 +7,8 @@ import { Switch } from '@/components/ui/switch';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { toast } from '@/hooks/useToast';
type NotificationPrefKey = 'reactions' | 'reposts' | 'zaps' | 'mentions' | 'comments';
@@ -120,22 +122,32 @@ export function NotificationSettings() {
const { user } = useCurrentUser();
const { config } = useAppContext();
const { settings, updateSettings } = useEncryptedSettings();
const {
enabled: pushHookEnabled,
enable: enablePush,
disable: disablePush,
} = usePushNotifications();
const [permission, setPermission] = useState<NotificationPermission>('default');
// Local UI state — initialized from settings once loaded, then updated
// synchronously on every toggle (fire-and-forget persist in background).
const [pushEnabled, setPushEnabled] = useState<boolean>(true);
const isNative = Capacitor.isNativePlatform();
// Web: toggle reflects actual browser push subscription (from the hook).
// Native: toggle reflects NIP-78 persisted preference.
const [nativePushEnabled, setNativePushEnabled] = useState<boolean>(() => isNative);
const [prefs, setPrefs] = useState<NonNullable<NonNullable<typeof settings>['notificationPreferences']>>({});
const initializedRef = useRef(false);
// Populate local state from settings on first load
useEffect(() => {
if (initializedRef.current || settings === null || settings === undefined) return;
initializedRef.current = true;
setPushEnabled(settings.notificationsEnabled ?? true);
if (isNative) {
setNativePushEnabled(settings.notificationsEnabled ?? true);
}
setPrefs(settings.notificationPreferences ?? {});
}, [settings]);
const pushEnabled = isNative ? nativePushEnabled : pushHookEnabled;
useSeoMeta({
title: `Notifications | Settings | ${config.appName}`,
description: 'Configure your notification preferences',
@@ -148,15 +160,38 @@ export function NotificationSettings() {
}, []);
const handleTogglePush = async (enabled: boolean) => {
if (enabled && !Capacitor.isNativePlatform()) {
if (enabled && !isNative) {
if (!('Notification' in window)) return;
const result = await Notification.requestPermission();
setPermission(result);
if (result !== 'granted') return;
// Register with nostr-push from this click handler (iOS requires
// requestPermission + pushManager.subscribe from a user gesture).
if (user) {
try {
await enablePush(user.pubkey);
} catch (err) {
console.error('[push] Registration failed:', err);
toast({ title: 'Failed to enable notifications', description: 'Please try again.' });
return; // Don't persist enabled=true if registration failed
}
}
updateSettings.mutateAsync({ notificationsEnabled: true }).catch(() => {});
return;
}
setPushEnabled(enabled);
if (!enabled && !isNative) {
await disablePush().catch((err) => console.error('[push] Failed to disable:', err));
updateSettings.mutateAsync({ notificationsEnabled: false }).catch(() => {});
return;
}
// Native path — toggle drives NIP-78 setting directly.
setNativePushEnabled(enabled);
updateSettings.mutateAsync({ notificationsEnabled: enabled }).catch(() => {
setPushEnabled(!enabled); // roll back on failure
setNativePushEnabled(!enabled); // roll back on failure
});
};
@@ -180,7 +215,6 @@ export function NotificationSettings() {
return <Navigate to="/settings" replace />;
}
const isNative = Capacitor.isNativePlatform();
const isSupported = isNative || 'Notification' in window;
const isDenied = !isNative && permission === 'denied';
+7 -11
View File
@@ -11,6 +11,7 @@ import { useAppContext } from '@/hooks/useAppContext';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { NoteCard } from '@/components/NoteCard';
import { TabButton } from '@/components/TabButton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useEvent } from '@/hooks/useEvent';
@@ -108,19 +109,14 @@ export function NotificationsPage() {
{/* Tab bar */}
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
{tabs.map(({ key, label }) => (
<button
<TabButton
key={key}
label={label}
active={activeTab === key}
onClick={() => setActiveTab(key)}
className={cn(
'flex-1 py-3.5 sidebar:py-5 text-sm font-medium sidebar:font-semibold transition-colors relative hover:bg-secondary/40',
activeTab === key ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{activeTab === key && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 sidebar:h-[3px] bg-primary rounded-full" />
)}
</button>
className="sidebar:py-5 sidebar:font-semibold"
indicatorClassName="sidebar:h-[3px]"
/>
))}
</div>
+1 -21
View File
@@ -23,8 +23,8 @@ import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { KindInfoButton } from '@/components/KindInfoButton';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { TabButton } from '@/components/TabButton';
import { getExtraKindDef } from '@/lib/extraKinds';
import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils';
import { MediaCollage, MediaCollageSkeleton, eventToMediaItem } from '@/components/MediaCollage';
@@ -33,26 +33,6 @@ const photosDef = getExtraKindDef('photos')!;
type FeedTab = 'follows' | 'global';
// ── Tab button ────────────────────────────────────────────────────────────────
function TabButton({ label, active, onClick, disabled }: {
label: string; active: boolean; onClick: () => void; disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 disabled:opacity-50',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && <div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 bg-primary rounded-full" />}
</button>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export function PhotosFeedPage() {
+1 -1
View File
@@ -600,7 +600,7 @@ function EventNotFound({
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
placeholder="wss://relay.example.com"
className="flex-1 font-mono text-xs h-9"
className="flex-1 font-mono text-base md:text-xs h-9"
disabled={isRetrying}
onKeyDown={(e) => {
if (e.key === "Enter") handleRetry(relayUrl);
+153
View File
@@ -0,0 +1,153 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Shield } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
export function PrivacyPolicyPage() {
const { config } = useAppContext();
useLayoutOptions({});
useSeoMeta({
title: `Privacy Policy | ${config.appName}`,
description: `Privacy policy for ${config.appName} — how your data is handled on the Nostr network`,
});
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
{/* Page header */}
<div className="sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
<div className="flex items-center gap-4 px-4 pt-4 pb-3">
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div className="flex items-center gap-2">
<Shield className="size-5" />
<h1 className="text-xl font-bold">Privacy Policy</h1>
</div>
</div>
</div>
<article className="px-4 pb-8 space-y-6 text-sm text-foreground/90 leading-relaxed">
<p className="text-xs text-muted-foreground">Last updated: March 18, 2026</p>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">Overview</h2>
<p>
{config.appName} is a client application for the <strong>Nostr protocol</strong>, an open, decentralized
communication network. This privacy policy explains how {config.appName} handles your data and what
information is shared when you use the app.
</p>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">How Nostr Works</h2>
<p>
Nostr is a decentralized protocol. When you publish content, it is sent to one or more <strong>relays</strong> (independent
servers) that you choose. {config.appName} does not operate these relays and has no control over data
stored on them. Content published to Nostr relays is <strong>public by default</strong> and may be visible to anyone.
</p>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">Data We Collect</h2>
<p>{config.appName} is designed to minimize data collection. Here is what the app accesses:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>
<strong>Public key:</strong> Your Nostr public key is used to identify your account. It is not considered private information on the Nostr network.
</li>
<li>
<strong>Relay connections:</strong> The app connects to Nostr relays on your behalf to fetch and publish events. Relay operators may log connection metadata such as your IP address.
</li>
<li>
<strong>Local storage:</strong> Preferences, account information, and cached data are stored locally in your browser. This data does not leave your device unless you explicitly publish it.
</li>
<li>
<strong>Published events:</strong> Any content you publish (posts, reactions, profile updates, etc.) is sent to your configured relays and becomes part of the public Nostr network.
</li>
</ul>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">Private Keys</h2>
<p>
{config.appName} supports signing via browser extensions (NIP-07) and other external signers. When using
these methods, your private key is managed by the signer and is <strong>never</strong> accessed or stored
by {config.appName}. We strongly recommend using a browser extension or hardware signer to protect your
private key.
</p>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">Direct Messages</h2>
<p>
Direct messages on Nostr are encrypted between sender and recipient using the NIP-04 or NIP-44
encryption standards. While message content is encrypted, metadata such as the sender and recipient
public keys and timestamps are visible on relays.
</p>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">File Uploads</h2>
<p>
When you upload files (images, videos, etc.), they are sent to Blossom-compatible file servers. These
servers are operated by third parties and may have their own privacy policies. Uploaded files are
generally publicly accessible via their URLs.
</p>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">Analytics</h2>
<p>
{config.appName} may use privacy-friendly analytics (such as Plausible) to understand general usage
patterns. These analytics do not use cookies, do not track individual users, and do not collect
personal information.
</p>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">Third-Party Services</h2>
<p>The app may interact with the following third-party services:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li><strong>Nostr relays:</strong> For reading and publishing events</li>
<li><strong>Blossom servers:</strong> For file uploads and media hosting</li>
<li><strong>Lightning Network / NWC:</strong> For processing zap payments, if you choose to use them</li>
<li><strong>NIP-05 providers:</strong> For verifying Nostr addresses</li>
</ul>
<p>
Each of these services is operated independently and may have its own data handling practices.
</p>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">Data Removal</h2>
<p>
Because Nostr is a decentralized protocol, {config.appName} cannot guarantee the deletion of content
once it has been published to relays. You can request deletion by publishing a delete event (NIP-09),
but individual relays are not obligated to honor these requests. To clear local data, you can clear
your browser's storage for this site.
</p>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">Changes to This Policy</h2>
<p>
We may update this privacy policy from time to time. Changes will be reflected on this page with an
updated date. Continued use of {config.appName} after changes constitutes acceptance of the revised policy.
</p>
</section>
<section className="space-y-2">
<h2 className="text-base font-bold text-foreground">Contact</h2>
<p>
If you have questions about this privacy policy, you can reach the team behind {config.appName} at{' '}
<a href="https://soapbox.pub" className="text-primary hover:underline" target="_blank" rel="noopener noreferrer">
soapbox.pub
</a>.
</p>
</section>
</article>
</main>
);
}
+35 -23
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useInView } from 'react-intersection-observer';
import { useNostr } from '@nostrify/react';
@@ -6,6 +6,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Zap, Flame, MoreHorizontal, Share2, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Users, Pin, X, QrCode, Check, Copy, Loader2, Download, Palette, Pencil, Trash2, Eye, EyeOff, RefreshCw, MessageSquare, Globe, Mail, Plus, GripVertical, ListPlus, Award } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape, isEmoji, emojiAvatarBorderStyle } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
@@ -52,7 +53,8 @@ import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { PullToRefresh } from '@/components/PullToRefresh';
import { ReportDialog } from '@/components/ReportDialog';
import { AddToListDialog } from '@/components/AddToListDialog';
import { MiniAudioPlayer, isAudioUrl } from '@/components/MiniAudioPlayer';
import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer';
import { VideoPlayer } from '@/components/VideoPlayer';
import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme';
import { usePublishTheme } from '@/hooks/usePublishTheme';
@@ -84,6 +86,7 @@ import { FontPicker } from '@/components/FontPicker';
import { BackgroundPicker } from '@/components/BackgroundPicker';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { formatNumber } from '@/lib/formatNumber';
import { TabButton } from '@/components/TabButton';
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
import type { AddrCoords } from '@/hooks/useEvent';
import type { FeedItem } from '@/lib/feedUtils';
@@ -362,25 +365,6 @@ function FollowingListModal({ pubkeys, open, onOpenChange, displayName }: Follow
);
}
// ----- Tab Button -----
function TabButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={cn(
'flex-1 px-4 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 whitespace-nowrap',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 max-w-16 h-1 bg-primary rounded-full" />
)}
</button>
);
}
type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
function SortableTabChip({
@@ -635,6 +619,33 @@ function ProfileFieldInline({ field }: { field: { label: string; value: string }
return <MiniAudioPlayer src={field.value} label={field.label || undefined} />;
}
if (isUrl && isImageUrl(field.value)) {
return (
<div className="min-w-0">
{field.label && <div className="text-sm text-muted-foreground mb-1">{field.label}</div>}
<a href={field.value} target="_blank" rel="noopener noreferrer" className="block">
<img
src={field.value}
alt={field.label || 'Profile image'}
className="w-full max-w-sm rounded-lg object-cover"
loading="lazy"
/>
</a>
</div>
);
}
if (isUrl && isVideoUrl(field.value)) {
return (
<div className="min-w-0">
{field.label && <div className="text-sm text-muted-foreground mb-1">{field.label}</div>}
<div className="rounded-lg overflow-hidden max-w-sm">
<VideoPlayer src={field.value} />
</div>
</div>
);
}
if (isUrl) {
return (
<div className="flex items-center gap-1.5 min-w-0">
@@ -1187,7 +1198,7 @@ export function ProfilePage() {
);
const effectiveProfileBackground = profileThemeColors ? profileThemeBackground : undefined;
useEffect(() => {
useLayoutEffect(() => {
if (!effectiveProfileColors) return;
// Inject the profile theme's CSS vars onto :root
@@ -1761,7 +1772,7 @@ export function ProfilePage() {
{feedSettings.showUserStatuses !== false && profileStatus.status && (
<div className="absolute -top-2 left-[calc(100%+8px)] z-10 max-w-[280px] md:max-w-[360px] animate-in fade-in slide-in-from-left-1 duration-300">
<div className="relative bg-background/90 backdrop-blur-sm border border-border rounded-xl px-3 py-1.5 shadow-lg">
<p className="text-xs md:text-sm text-foreground italic truncate">
<p className="text-xs md:text-sm text-foreground italic truncate pr-1">
{profileStatus.url ? (
<a href={profileStatus.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
{profileStatus.status}
@@ -1931,6 +1942,7 @@ export function ProfilePage() {
setActiveTab(tabId);
if (tab.label === 'Media') setSidebarMediaUrl(null);
}}
className="flex-initial shrink-0 px-4"
/>
);
})}
+364 -62
View File
@@ -1,6 +1,10 @@
import { useSeoMeta } from '@unhead/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Loader2, Plus, Trash2, ChevronDown, GripVertical, Type, Wallet, Image, Upload } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowLeft, Loader2, Plus, Trash2, ChevronDown, GripVertical,
Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, AlertTriangle,
} from 'lucide-react';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { Link, Navigate } from 'react-router-dom';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -18,6 +22,7 @@ import {
import { CSS } from '@dnd-kit/utilities';
import { ProfileCard } from '@/components/ProfileCard';
import { ProfileRightSidebar } from '@/components/ProfileRightSidebar';
import { IntroImage } from '@/components/IntroImage';
import { HelpTip } from '@/components/HelpTip';
import { ImageCropDialog } from '@/components/ImageCropDialog';
@@ -25,16 +30,12 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useProfileMedia } from '@/hooks/useProfileMedia';
import { useToast } from '@/hooks/useToast';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
@@ -56,8 +57,11 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { isValidAvatarShape } from '@/lib/avatarShape';
// ── Constants ─────────────────────────────────────────────────────────────────
const WALLET_TICKERS = [
'$BTC', '$ETH', '$SOL', '$XMR', '$LTC', '$DOGE', '$ADA', '$DOT', '$XRP', '$MATIC',
] as const;
@@ -65,6 +69,108 @@ const WALLET_TICKERS = [
/** Bare tickers used only for detection (strips leading $). */
const BARE_TICKERS = WALLET_TICKERS.map((t) => t.slice(1));
// ── Field preset templates ────────────────────────────────────────────────────
interface FieldPreset {
id: string;
label: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
/** Default label to pre-fill when adding this field type. */
defaultLabel: string;
/** The form field type. */
type: 'text' | 'wallet' | 'media';
/** File accept attribute for the file picker (media types only). */
accept?: string;
/** Human-readable format list shown in tooltips. */
formatHint?: string;
/** Placeholder for the value input. */
valuePlaceholder?: string;
}
const FIELD_PRESETS: FieldPreset[] = [
{
id: 'music',
label: 'Music',
description: 'Upload a song or audio clip',
icon: Music,
defaultLabel: '\u{1F3B6}',
type: 'media',
accept: 'audio/*',
formatHint: 'MP3, OGG, WAV, FLAC, AAC, M4A, Opus',
valuePlaceholder: 'Upload audio or paste direct file link',
},
{
id: 'photo',
label: 'Photo',
description: 'Upload an image',
icon: ImageIcon,
defaultLabel: '\u{1F4F8}',
type: 'media',
accept: 'image/*',
formatHint: 'JPG, PNG, GIF, WebP, SVG, AVIF',
valuePlaceholder: 'Upload image or paste direct file link',
},
{
id: 'video',
label: 'Video',
description: 'Upload a video clip',
icon: Film,
defaultLabel: '\u{1F3AC}',
type: 'media',
accept: 'video/*',
formatHint: 'MP4, WebM, MOV',
valuePlaceholder: 'Upload video or paste direct file link',
},
{
id: 'email',
label: 'Email',
description: 'Contact email address',
icon: Mail,
defaultLabel: 'Email',
type: 'text',
valuePlaceholder: 'you@example.com',
},
{
id: 'wallet',
label: 'Wallet',
description: 'Cryptocurrency wallet address',
icon: Wallet,
defaultLabel: '$BTC',
type: 'wallet',
valuePlaceholder: 'Address',
},
{
id: 'link',
label: 'Link',
description: 'Link to any website or profile',
icon: Link2,
defaultLabel: '',
type: 'text',
valuePlaceholder: 'https://...',
},
];
/** The "Custom" preset — always shown last, separated by a divider. */
const CUSTOM_PRESET: FieldPreset = {
id: 'custom',
label: 'Custom',
description: 'Create any custom field',
icon: Pencil,
defaultLabel: '',
type: 'text',
valuePlaceholder: 'Value or URL',
};
/** Find a preset's format hint from its accept filter. */
function getFormatHintForAccept(accept: string | undefined): string | undefined {
if (!accept) return undefined;
const preset = FIELD_PRESETS.find((p) => p.accept === accept);
return preset?.formatHint;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Infer the field type from stored label/value when loading from existing data. */
function inferFieldType(label: string, value: string): 'text' | 'wallet' | 'media' {
const bare = label.replace(/^\$/, '').toUpperCase();
@@ -76,11 +182,80 @@ function inferFieldType(label: string, value: string): 'text' | 'wallet' | 'medi
return 'text';
}
/** Extension patterns for each media accept category. */
const AUDIO_EXT = /\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i;
const IMAGE_EXT = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i;
const VIDEO_EXT = /\.(mp4|webm|mov|qt)(\?.*)?$/i;
/**
* Check whether a pasted URL matches the expected file type for a media field.
* Returns a warning message if the URL looks wrong, or undefined if it's fine.
* Only warns when the value looks like a URL empty/non-URL values return undefined.
*/
function getMediaMismatchWarning(value: string, accept: string | undefined): string | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
// Only check if it looks like a URL
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) return undefined;
// Blossom-style URLs (hex hash path) are always fine — type can't be determined from URL
if (/^https?:\/\/.+\/[0-9a-f]{64}(\.\w+)?$/i.test(trimmed)) return undefined;
// Check if URL has a recognizable file extension at all
const hasAudioExt = AUDIO_EXT.test(trimmed);
const hasImageExt = IMAGE_EXT.test(trimmed);
const hasVideoExt = VIDEO_EXT.test(trimmed);
const hasKnownExt = hasAudioExt || hasImageExt || hasVideoExt;
if (accept === 'audio/*') {
if (hasKnownExt && !hasAudioExt) {
return 'This URL doesn\u2019t point to an audio file. Upload an audio file or use a direct link ending in .mp3, .ogg, .wav, etc.';
}
if (!hasKnownExt) {
return 'This URL may not work as an audio player. For best results, upload a file using the button or paste a direct link to an audio file.';
}
}
if (accept === 'image/*') {
if (hasKnownExt && !hasImageExt) {
return 'This URL doesn\u2019t point to an image. Upload an image or use a direct link ending in .jpg, .png, .webp, etc.';
}
if (!hasKnownExt) {
return 'This URL may not display as an image. For best results, upload a file using the button or paste a direct link to an image file.';
}
}
if (accept === 'video/*') {
if (hasKnownExt && !hasVideoExt) {
return 'This URL doesn\u2019t point to a video. Upload a video or use a direct link ending in .mp4, .webm, .mov, etc.';
}
if (!hasKnownExt) {
return 'This URL may not display as a video. For best results, upload a file using the button or paste a direct link to a video file.';
}
}
return undefined;
}
/** Infer a file-accept filter from an existing field's value URL. */
function inferAcceptFromValue(value: string): string | undefined {
if (/\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i.test(value)) return 'audio/*';
if (/\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(value)) return 'image/*';
if (/\.(mp4|webm|mov|qt)(\?.*)?$/i.test(value)) return 'video/*';
return undefined;
}
// ── Schema ────────────────────────────────────────────────────────────────────
const formSchema = n.metadata().extend({
fields: z.array(z.object({
label: z.string(),
value: z.string(),
type: z.enum(['text', 'wallet', 'media']),
/** Client-side only — file accept filter for the file picker (not persisted). */
accept: z.string().optional(),
/** Client-side only — placeholder text for the value input (not persisted). */
placeholder: z.string().optional(),
})).optional(),
shape: z.string().optional(),
});
@@ -100,6 +275,9 @@ interface SortableFieldRowProps {
id: string;
index: number;
type: 'text' | 'wallet' | 'media';
accept?: string;
valuePlaceholder?: string;
isUploading?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
control: any;
onRemove: () => void;
@@ -107,10 +285,12 @@ interface SortableFieldRowProps {
onTickerChange: (ticker: string) => void;
}
function SortableFieldRow({ id, index, type, control, onRemove, onMediaPick, onTickerChange }: SortableFieldRowProps) {
function SortableFieldRow({ id, index, type, accept, valuePlaceholder, isUploading: fieldUploading, control, onRemove, onMediaPick, onTickerChange }: SortableFieldRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition };
const formatHint = type === 'media' ? getFormatHintForAccept(accept) : undefined;
return (
<div
ref={setNodeRef}
@@ -165,30 +345,56 @@ function SortableFieldRow({ id, index, type, control, onRemove, onMediaPick, onT
/>
)}
{/* Value column — media gets upload button, others get text input */}
{/* Value column — media gets upload button with tooltip, others get text input */}
{type === 'media' ? (
<FormField
control={control}
name={`fields.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-1.5">
<FormControl>
<Input placeholder="URL" {...field} className="h-9 flex-1 min-w-0" readOnly={false} />
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={onMediaPick}
>
<Upload className="size-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const mismatchWarning = getMediaMismatchWarning(field.value, accept);
return (
<FormItem>
<div className="flex gap-1.5">
<FormControl>
<Input placeholder={valuePlaceholder || 'Upload file or paste direct file link'} {...field} className="h-9 flex-1 min-w-0" readOnly={false} />
</FormControl>
{fieldUploading ? (
<div className="flex items-center justify-center h-9 w-9 shrink-0">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={onMediaPick}
>
<Upload className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-52">
{formatHint ? (
<span>Choose file to upload<br /><span className="text-muted-foreground">{formatHint}</span></span>
) : (
<span>Choose a media file to upload</span>
)}
</TooltipContent>
</Tooltip>
)}
</div>
{mismatchWarning && (
<p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-500 mt-1 leading-snug">
<AlertTriangle className="size-3.5 shrink-0 mt-0.5" />
<span>{mismatchWarning}</span>
</p>
)}
<FormMessage />
</FormItem>
);
}}
/>
) : (
<FormField
@@ -228,8 +434,31 @@ export function ProfileSettings() {
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
// Fetch media events for the sidebar preview (same query as profile page)
const {
data: mediaData,
isPending: mediaPending,
} = useProfileMedia(user?.pubkey);
const mediaEvents = useMemo(() => {
if (!mediaData?.pages) return [];
const seen = new Set<string>();
const events: import('@nostrify/nostrify').NostrEvent[] = [];
for (const page of mediaData.pages) {
for (const event of page.events) {
if (!seen.has(event.id)) {
seen.add(event.id);
events.push(event);
}
}
}
return events;
}, [mediaData?.pages]);
const [cropState, setCropState] = useState<CropState | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showMobilePreview, setShowMobilePreview] = useState(false);
const [uploadingFieldIndex, setUploadingFieldIndex] = useState<number>(-1);
useSeoMeta({
title: `Profile | Settings | ${config.appName}`,
@@ -237,7 +466,7 @@ export function ProfileSettings() {
});
// Parse existing custom fields from raw event
const parseFields = (): Array<{ label: string; value: string; type: 'text' | 'wallet' | 'media' }> => {
const parseFields = (): Array<{ label: string; value: string; type: 'text' | 'wallet' | 'media'; accept?: string }> => {
if (!event) return [];
try {
const parsed = JSON.parse(event.content);
@@ -250,7 +479,8 @@ export function ProfileSettings() {
const label = type === 'wallet' && !f[0].startsWith('$')
? `$${f[0].toUpperCase()}`
: f[0];
return { label, value: f[1], type };
const accept = type === 'media' ? inferAcceptFromValue(f[1]) : undefined;
return { label, value: f[1], type, accept };
});
}
} catch { /* ignore */ }
@@ -292,11 +522,16 @@ export function ProfileSettings() {
move(oldIndex, newIndex);
}, [fields, move]);
// Media field upload
// Media field upload — dynamic accept attribute per field
const mediaInputRef = useRef<HTMLInputElement>(null);
const pendingMediaIndex = useRef<number>(-1);
const handleMediaPick = (index: number) => {
pendingMediaIndex.current = index;
// Dynamically set the accept attribute based on the field's preset
const fieldAccept = form.getValues(`fields.${index}.accept`);
if (mediaInputRef.current) {
mediaInputRef.current.accept = fieldAccept || 'image/*,video/*,audio/*';
}
mediaInputRef.current?.click();
};
const handleMediaFileChosen = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -305,12 +540,15 @@ export function ProfileSettings() {
e.target.value = '';
const index = pendingMediaIndex.current;
if (index < 0) return;
setUploadingFieldIndex(index);
try {
const [[, url]] = await uploadFile(file);
form.setValue(`fields.${index}.value`, url, { shouldDirty: true });
toast({ title: 'Uploaded', description: 'Media file uploaded' });
} catch {
toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' });
} finally {
setUploadingFieldIndex(-1);
}
};
@@ -346,6 +584,24 @@ export function ProfileSettings() {
shape: watched.shape,
};
// Live sidebar preview fields — computed from watched form values
const previewFields = useMemo(() => {
const result: Array<{ label: string; value: string }> = [];
// Add website if present
if (watched.website?.trim()) {
result.push({ label: 'Website', value: watched.website.trim() });
}
// Add custom fields that have both label and value
if (watched.fields) {
for (const f of watched.fields) {
if (f.label.trim() && f.value.trim()) {
result.push({ label: f.label, value: f.value });
}
}
}
return result;
}, [watched.website, watched.fields]);
// Card onChange: patch individual fields
const handleCardChange = (patch: Partial<NostrMetadata>) => {
for (const [k, v] of Object.entries(patch)) {
@@ -395,6 +651,17 @@ export function ProfileSettings() {
setCropState(null);
};
// Handle adding a field from a preset
const handleAddPreset = (preset: FieldPreset) => {
append({
label: preset.defaultLabel,
value: '',
type: preset.type,
accept: preset.accept,
placeholder: preset.valuePlaceholder,
});
};
const onSubmit = async (values: FormValues) => {
if (!user) return;
try {
@@ -424,6 +691,11 @@ export function ProfileSettings() {
}
};
// Inject live sidebar preview into the app's right sidebar slot
useLayoutOptions({
rightSidebar: <ProfileRightSidebar fields={previewFields} mediaEvents={mediaEvents} mediaLoading={mediaPending} />,
});
if (!user) return <Navigate to="/settings" replace />;
const busy = isPending || isUploading;
@@ -438,7 +710,7 @@ export function ProfileSettings() {
className="hidden"
onChange={handleFileChosen}
/>
{/* Hidden file input for media fields */}
{/* Hidden file input for media fields — accept is set dynamically */}
<input
ref={mediaInputRef}
type="file"
@@ -502,13 +774,17 @@ export function ProfileSettings() {
{isUploading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Uploading image
Uploading
</div>
)}
{/* Profile fields */}
<div>
<h2 className="text-sm font-medium py-2">Profile Fields</h2>
<h2 className="text-sm font-medium py-2 flex items-center gap-1">
Profile Fields
<HelpTip faqId="profile-fields" iconSize="size-3.5" />
</h2>
<div className="space-y-3 pt-1">
{/* Website — always first */}
<FormField
@@ -551,6 +827,9 @@ export function ProfileSettings() {
id={field.id}
index={index}
type={form.watch(`fields.${index}.type`) ?? 'text'}
accept={form.watch(`fields.${index}.accept`)}
valuePlaceholder={form.watch(`fields.${index}.placeholder`)}
isUploading={uploadingFieldIndex === index}
control={form.control}
onRemove={() => remove(index)}
onMediaPick={() => handleMediaPick(index)}
@@ -560,36 +839,59 @@ export function ProfileSettings() {
</SortableContext>
</DndContext>
{/* Add field dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs w-full"
>
<Plus className="size-3 mr-1" /> Add Field
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
<DropdownMenuItem onClick={() => append({ label: '', value: '', type: 'text' })}>
<Type className="size-4 mr-2" />
Text
</DropdownMenuItem>
<DropdownMenuItem onClick={() => append({ label: '$BTC', value: '', type: 'wallet' })}>
<Wallet className="size-4 mr-2" />
Wallet Address
</DropdownMenuItem>
<DropdownMenuItem onClick={() => append({ label: '', value: '', type: 'media' })}>
<Image className="size-4 mr-2" />
Media
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Add field — visible pill buttons */}
<div className="flex flex-wrap gap-1.5 pt-1">
{[...FIELD_PRESETS, CUSTOM_PRESET].map((preset) => {
const Icon = preset.icon;
return (
<Tooltip key={preset.id}>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 rounded-full px-3 text-xs gap-1.5"
onClick={() => handleAddPreset(preset)}
>
<Plus className="size-3 text-muted-foreground" />
<Icon className="size-3.5 text-muted-foreground" />
{preset.label}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{preset.description}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
</div>
{/* Mobile sidebar preview — visible only below xl where the real sidebar is hidden */}
<div className="xl:hidden">
<Collapsible open={showMobilePreview} onOpenChange={setShowMobilePreview}>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" className="w-full justify-between px-0 h-auto hover:bg-transparent hover:text-foreground">
<span className="text-sm font-medium flex items-center gap-1.5">
<Eye className="size-3.5" />
Profile Fields Preview
</span>
<ChevronDown className="size-4 text-muted-foreground transition-transform duration-200 [[data-state=open]_&]:rotate-180" strokeWidth={4} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="rounded-xl border bg-card/50 overflow-hidden">
<ProfileRightSidebar
fields={previewFields}
className="relative w-full flex flex-col h-auto max-h-[60vh] overflow-y-auto"
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
{/* Advanced */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
+8 -26
View File
@@ -45,14 +45,13 @@ import { ListPackPicker } from '@/components/SavedFeedFiltersEditor';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { TabButton } from '@/components/TabButton';
import { getNostrIdentifierPath } from '@/lib/nostrIdentifier';
import { cn, STICKY_HEADER_CLASS, parseKindFilter } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
import { nip19 } from 'nostr-tools';
type TabType = 'posts' | 'accounts';
const VALID_TABS: TabType[] = ['posts', 'accounts'];
@@ -416,8 +415,8 @@ export function SearchPage() {
{/* Tabs — sticky at top */}
<div className={cn(STICKY_HEADER_CLASS, 'bg-background/80 backdrop-blur-md z-10 border-b border-border')}>
<div className="flex">
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} className="sidebar:py-5" />
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} className="sidebar:py-5" />
</div>
</div>
@@ -476,7 +475,7 @@ export function SearchPage() {
value={saveFeedLabel}
onChange={(e) => setSaveFeedLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveFeed(); }}
className="bg-secondary/50 border-border focus-visible:ring-1 text-sm"
className="bg-secondary/50 border-border focus-visible:ring-1 text-base md:text-sm"
autoFocus
/>
<div className="space-y-1">
@@ -633,7 +632,7 @@ export function SearchPage() {
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Media</span>
<Select value={mediaType} onValueChange={(v) => setMediaType(v)}>
<SelectTrigger className="w-full bg-secondary/50 h-8 text-xs">
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -648,7 +647,7 @@ export function SearchPage() {
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Protocol <HelpTip faqId="vs-mastodon-bluesky" iconSize="size-3" /></span>
<Select value={platform} onValueChange={(v) => setPlatform(v)}>
<SelectTrigger className="w-full bg-secondary/50 h-8 text-xs">
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -665,7 +664,7 @@ export function SearchPage() {
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Language</span>
<Select value={language} onValueChange={(v) => setLanguage(v)}>
<SelectTrigger className="w-full bg-secondary/50 h-8 text-xs">
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -692,7 +691,7 @@ export function SearchPage() {
placeholder="e.g. 1, 30023"
value={customKindText}
onChange={(e) => setCustomKindText(e.target.value)}
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-xs h-8"
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
/>
)}
@@ -815,23 +814,6 @@ export function SearchPage() {
/* ── Shared sub-components ── */
function TabButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={cn(
'flex-1 py-3.5 sidebar:py-5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 bg-primary rounded-full" />
)}
</button>
);
}
function AccountItem({ profile, isFollowed }: { profile: { pubkey: string; metadata: Record<string, unknown>; event?: { tags: string[][] } }; isFollowed: boolean }) {
const npub = useMemo(() => nip19.npubEncode(profile.pubkey), [profile.pubkey]);
const metadata = profile.metadata as { name?: string; nip05?: string; picture?: string; about?: string; bot?: boolean };
+1 -24
View File
@@ -13,12 +13,12 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { ThemeSelector } from '@/components/ThemeSelector';
import { TabButton } from '@/components/TabButton';
import { useThemeFeed } from '@/hooks/useThemeFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTheme } from '@/hooks/useTheme';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
type ThemesTab = 'my-themes' | 'follows' | 'global';
@@ -186,29 +186,6 @@ export function ThemesPage() {
);
}
// ---------------------------------------------------------------------------
// Tab Button
// ---------------------------------------------------------------------------
function TabButton({ label, active, onClick, disabled }: { label: string; active: boolean; onClick: () => void; disabled?: boolean }) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40',
active ? 'text-foreground' : 'text-muted-foreground',
disabled && 'opacity-50 cursor-not-allowed hover:bg-transparent',
)}
>
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 bg-primary rounded-full" />
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Skeleton
// ---------------------------------------------------------------------------
+61 -29
View File
@@ -1,25 +1,29 @@
import { useSeoMeta } from '@unhead/react';
import { useAppContext } from '@/hooks/useAppContext';
import { Flame, TrendingUp, Swords, Loader2 } from 'lucide-react';
import { useState, useMemo, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { Link } from 'react-router-dom';
import { NoteCard } from '@/components/NoteCard';
import { Skeleton } from '@/components/ui/skeleton';
import { useTrendingTags, useInfiniteSortedPosts, type SortMode } from '@/hooks/useTrending';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { cn } from '@/lib/utils';
import { useSeoMeta } from "@unhead/react";
import { Flame, Loader2, Swords, TrendingUp } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useInView } from "react-intersection-observer";
import { Link } from "react-router-dom";
import { NoteCard } from "@/components/NoteCard";
import { Skeleton } from "@/components/ui/skeleton";
import { useAppContext } from "@/hooks/useAppContext";
import { useMuteList } from "@/hooks/useMuteList";
import {
type SortMode,
useInfiniteSortedPosts,
useTrendingTags,
} from "@/hooks/useTrending";
import { isEventMuted } from "@/lib/muteHelpers";
import { cn } from "@/lib/utils";
export function TrendsPage() {
const { config } = useAppContext();
useSeoMeta({
title: `Trends | ${config.appName}`,
description: 'Trending hashtags and posts on Nostr',
description: "Trending hashtags and posts on Nostr",
});
const [trendSort, setTrendSort] = useState<SortMode>('hot');
const [trendSort, setTrendSort] = useState<SortMode>("hot");
const { data: trends, isLoading: trendsLoading } = useTrendingTags(true);
const {
@@ -35,18 +39,21 @@ export function TrendsPage() {
// Flatten, deduplicate, and filter muted posts from paginated sorted results
const sortedPosts = useMemo(() => {
const seen = new Set<string>();
return sortedData?.pages.flat().filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
return true;
}) ?? [];
return (
sortedData?.pages.flat().filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
if (muteItems.length > 0 && isEventMuted(event, muteItems))
return false;
return true;
}) ?? []
);
}, [sortedData?.pages, muteItems]);
// Intersection observer for infinite scroll on sorted posts
const { ref: sortedScrollRef, inView: sortedInView } = useInView({
threshold: 0,
rootMargin: '400px',
rootMargin: "400px",
});
useEffect(() => {
@@ -78,7 +85,10 @@ export function TrendsPage() {
) : trends && trends.tags.length > 0 ? (
<div className="flex flex-wrap gap-2 px-4 pb-4">
{trends.tags.slice(0, 5).map((trend, index) => (
<TrendItem key={index} trend={{ tag: trend.tag, count: trend.uses }} />
<TrendItem
key={index}
trend={{ tag: trend.tag, count: trend.accounts }}
/>
))}
</div>
) : (
@@ -87,9 +97,24 @@ export function TrendsPage() {
{/* Sort sub-tabs */}
<div className="flex border-b border-border">
<SortTabButton icon={<Flame className="size-4" />} label="Hot" active={trendSort === 'hot'} onClick={() => setTrendSort('hot')} />
<SortTabButton icon={<TrendingUp className="size-4" />} label="Rising" active={trendSort === 'rising'} onClick={() => setTrendSort('rising')} />
<SortTabButton icon={<Swords className="size-4" />} label="Controversial" active={trendSort === 'controversial'} onClick={() => setTrendSort('controversial')} />
<SortTabButton
icon={<Flame className="size-4" />}
label="Hot"
active={trendSort === "hot"}
onClick={() => setTrendSort("hot")}
/>
<SortTabButton
icon={<TrendingUp className="size-4" />}
label="Rising"
active={trendSort === "rising"}
onClick={() => setTrendSort("rising")}
/>
<SortTabButton
icon={<Swords className="size-4" />}
label="Controversial"
active={trendSort === "controversial"}
onClick={() => setTrendSort("controversial")}
/>
</div>
{/* Sorted posts — infinite scroll */}
@@ -121,7 +146,12 @@ export function TrendsPage() {
);
}
function SortTabButton({ icon, label, active, onClick }: {
function SortTabButton({
icon,
label,
active,
onClick,
}: {
icon: React.ReactNode;
label: string;
active: boolean;
@@ -131,8 +161,8 @@ function SortTabButton({ icon, label, active, onClick }: {
<button
onClick={onClick}
className={cn(
'flex-1 py-2.5 flex items-center justify-center gap-1.5 text-sm font-medium transition-colors relative hover:bg-secondary/40',
active ? 'text-foreground' : 'text-muted-foreground',
"flex-1 py-2.5 flex items-center justify-center gap-1.5 text-sm font-medium transition-colors relative hover:bg-secondary/40",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{icon}
@@ -152,7 +182,9 @@ function TrendItem({ trend }: { trend: { tag: string; count: number } }) {
>
#{trend.tag}
{trend.count > 0 && (
<span className="text-xs text-muted-foreground font-normal">{trend.count}</span>
<span className="text-xs text-muted-foreground font-normal">
{trend.count}
</span>
)}
</Link>
);
+1 -1
View File
@@ -107,7 +107,7 @@ function ListRow({ list, onDelete }: { list: UserList; onDelete: (list: UserList
if (e.key === 'Enter') handleRename();
if (e.key === 'Escape') { setEditing(false); setRenameValue(list.title); }
}}
className="h-7 text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
className="h-7 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
autoFocus
/>
<Button size="icon" variant="ghost" className="size-7 shrink-0" onClick={handleRename}>
+177 -50
View File
@@ -45,7 +45,9 @@ import { useVideoThumbnail } from '@/components/VideoPlayer';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { getExtraKindDef } from '@/lib/extraKinds';
import { timeAgo } from '@/lib/timeAgo';
import { TabButton } from '@/components/TabButton';
import { cn } from '@/lib/utils';
import { getEffectiveStreamStatus } from '@/lib/streamStatus';
import type { FeedItem } from '@/lib/feedUtils';
// Reuse the real VineCard — no re-implementation
@@ -193,26 +195,6 @@ function useDragScroll<T extends HTMLElement>() {
return { ref, onMouseDown, onMouseMove, onMouseUp, onMouseLeave };
}
// ── Tab button ────────────────────────────────────────────────────────────────
function TabButton({ label, active, onClick, disabled }: {
label: string; active: boolean; onClick: () => void; disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 disabled:opacity-50',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && <div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 bg-primary rounded-full" />}
</button>
);
}
// ── Video grid card (kind 21) — YouTube-style ────────────────────────────────
function VideoGridCard({ event }: { event: NostrEvent }) {
@@ -312,38 +294,128 @@ function VideoSkeleton() {
);
}
// ── Live streams — targeted query: status=live only, limit 10 ────────────────
// ── Live streams — fetch all statuses, classify with NIP-53 staleness heuristic
function useLiveStreams(tab: FeedTab) {
type StreamTab = 'live' | 'planned' | 'past';
interface ClassifiedStreams {
live: NostrEvent[];
planned: NostrEvent[];
past: NostrEvent[];
}
/**
* Fetch ALL streams globally (no author filter) so every tab sees the same
* event versions from the relay. The follows tab filters client-side.
*
* We use a large limit because kind 30311 is addressable relays store at
* most one event per pubkey+d-tag, so 200 means ~200 unique streams.
* Not all relays support filtering by custom tags like `#status`, so we
* fetch broadly and classify entirely client-side.
*/
function useAllStreams(): { data: NostrEvent[]; isLoading: boolean } {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const followedPubkeys = followData?.pubkeys ?? [];
return useQuery<NostrEvent[]>({
queryKey: ['live-streams', tab, user?.pubkey, followedPubkeys.join(',')],
const query = useQuery<NostrEvent[]>({
queryKey: ['all-streams'],
queryFn: async ({ signal }) => {
if (tab === 'follows' && followedPubkeys.length === 0 && !user) return [];
const base: Record<string, unknown> = { kinds: [30311], '#status': ['live'], limit: 10 };
if (tab === 'follows') {
const authors = user ? [...followedPubkeys, user.pubkey] : followedPubkeys;
base.authors = authors;
}
const events = await nostr.query(
[base as { kinds: number[]; limit: number }],
[{ kinds: [30311], limit: 200 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
return events.filter((e) => getTag(e.tags, 'status') === 'live');
// Deduplicate addressable events: keep the newest per pubkey+d-tag.
const best = new Map<string, NostrEvent>();
for (const e of events) {
const d = e.tags.find(([n]) => n === 'd')?.[1] ?? '';
const key = `${e.pubkey}:${d}`;
const existing = best.get(key);
if (!existing || e.created_at > existing.created_at) {
best.set(key, e);
}
}
return Array.from(best.values());
},
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
});
return { data: query.data ?? [], isLoading: query.isLoading };
}
/** Classify a list of stream events into live / planned / past buckets. */
function classifyStreams(events: NostrEvent[]): ClassifiedStreams {
const buckets: ClassifiedStreams = { live: [], planned: [], past: [] };
const byNewest = (a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at;
for (const e of events) {
const status = getEffectiveStreamStatus(e);
if (status === 'live') buckets.live.push(e);
else if (status === 'planned') buckets.planned.push(e);
else buckets.past.push(e);
}
buckets.live.sort(byNewest);
buckets.planned.sort(byNewest);
buckets.past.sort(byNewest);
return buckets;
}
/**
* Returns classified streams for the given tab.
* Global: all streams. Follows: only streams from followed authors.
*/
function useClassifiedStreams(tab: FeedTab): { data: ClassifiedStreams; isLoading: boolean } {
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const followedPubkeys = followData?.pubkeys;
const { data: allEvents, isLoading } = useAllStreams();
const classified = useMemo<ClassifiedStreams>(() => {
if (tab === 'global') return classifyStreams(allEvents);
// Follows tab — filter to followed authors + self, client-side.
// Check both the event publisher AND p-tag participants, because
// streaming services (e.g. streamstr.net) publish kind 30311 on behalf
// of the streamer, who appears in a p tag with role "host".
if (!followedPubkeys || !user) return { live: [], planned: [], past: [] };
const authorSet = new Set([...followedPubkeys, user.pubkey]);
return classifyStreams(allEvents.filter((e) => {
if (authorSet.has(e.pubkey)) return true;
return e.tags.some(([name, pk]) => name === 'p' && authorSet.has(pk));
}));
}, [allEvents, tab, followedPubkeys, user]);
return { data: classified, isLoading };
}
function StreamBadge({ status }: { status: string }) {
switch (status) {
case 'live':
return (
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-red-600 text-white px-1.5 py-0.5 rounded">
<span className="size-1.5 rounded-full bg-white animate-pulse" />LIVE
</span>
);
case 'planned':
return (
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-blue-600/90 text-white px-1.5 py-0.5 rounded">
PLANNED
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-black/60 text-white/80 px-1.5 py-0.5 rounded">
ENDED
</span>
);
}
}
function LiveStreamCard({ event }: { event: NostrEvent }) {
const title = getTag(event.tags, 'title') || 'Untitled Stream';
const imageUrl = getTag(event.tags, 'image');
const viewers = getTag(event.tags, 'current_participants');
const effectiveStatus = getEffectiveStreamStatus(event);
const naddrId = useMemo(() => {
const d = getTag(event.tags, 'd') || '';
@@ -368,16 +440,17 @@ function LiveStreamCard({ event }: { event: NostrEvent }) {
{imageUrl ? (
<img src={imageUrl} alt={title} className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]" loading="lazy" />
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-red-950/40 to-muted">
<Radio className="size-5 text-red-400/60" />
<div className={cn(
'w-full h-full flex items-center justify-center bg-gradient-to-br to-muted',
effectiveStatus === 'live' ? 'from-red-950/40' : effectiveStatus === 'planned' ? 'from-blue-950/40' : 'from-muted-foreground/10',
)}>
<Radio className={cn('size-5', effectiveStatus === 'live' ? 'text-red-400/60' : 'text-muted-foreground/40')} />
</div>
)}
<div className="absolute top-1.5 left-1.5">
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-red-600 text-white px-1.5 py-0.5 rounded">
<span className="size-1.5 rounded-full bg-white animate-pulse" />LIVE
</span>
<StreamBadge status={effectiveStatus} />
</div>
{viewers && (
{viewers && effectiveStatus === 'live' && (
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-0.5 bg-black/70 text-white text-[10px] px-1 py-0.5 rounded">
<Eye className="size-2.5" />{viewers}
</div>
@@ -390,16 +463,69 @@ function LiveStreamCard({ event }: { event: NostrEvent }) {
}
function LiveStreamsStrip({ tab }: { tab: FeedTab }) {
const { data: liveEvents = [] } = useLiveStreams(tab);
const { data: streams } = useClassifiedStreams(tab);
const [streamTab, setStreamTab] = useState<StreamTab>('live');
const drag = useDragScroll<HTMLDivElement>();
if (liveEvents.length === 0) return null;
const totalCount = streams.live.length + streams.planned.length + streams.past.length;
if (totalCount === 0) return null;
// Auto-select first non-empty tab if current tab is empty
const activeTab = streams[streamTab].length > 0
? streamTab
: streams.live.length > 0 ? 'live'
: streams.planned.length > 0 ? 'planned'
: 'past';
const activeEvents = streams[activeTab];
return (
<div className="px-4 pt-3 pb-4">
<p className="flex items-center gap-1.5 text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2.5">
<span className="size-1.5 rounded-full bg-red-500 animate-pulse shrink-0" />
Live now
</p>
{/* Stream tab pills */}
<div className="flex items-center gap-1.5 mb-2.5">
{streams.live.length > 0 && (
<button
onClick={() => setStreamTab('live')}
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold transition-colors',
activeTab === 'live'
? 'bg-red-600 text-white'
: 'bg-secondary/60 text-muted-foreground hover:bg-secondary hover:text-foreground',
)}
>
<span className="size-1.5 rounded-full bg-current animate-pulse shrink-0" />
Live ({streams.live.length})
</button>
)}
{streams.planned.length > 0 && (
<button
onClick={() => setStreamTab('planned')}
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold transition-colors',
activeTab === 'planned'
? 'bg-blue-600 text-white'
: 'bg-secondary/60 text-muted-foreground hover:bg-secondary hover:text-foreground',
)}
>
Planned ({streams.planned.length})
</button>
)}
{streams.past.length > 0 && (
<button
onClick={() => setStreamTab('past')}
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold transition-colors',
activeTab === 'past'
? 'bg-muted text-foreground'
: 'bg-secondary/60 text-muted-foreground hover:bg-secondary hover:text-foreground',
)}
>
Past ({streams.past.length})
</button>
)}
</div>
{/* Horizontal scroll of stream cards */}
<div
ref={drag.ref}
className="flex gap-3 overflow-x-auto pb-1 cursor-grab"
@@ -409,7 +535,7 @@ function LiveStreamsStrip({ tab }: { tab: FeedTab }) {
onMouseUp={drag.onMouseUp}
onMouseLeave={drag.onMouseLeave}
>
{liveEvents.map((e) => <LiveStreamCard key={e.id} event={e} />)}
{activeEvents.map((e) => <LiveStreamCard key={e.id} event={e} />)}
</div>
</div>
);
@@ -629,8 +755,9 @@ export function VideosFeedPage() {
const [shortsPlayerIndex, setShortsPlayerIndex] = useState<number | null>(null);
const [showAllVideos, setShowAllVideos] = useState(false);
const { data: liveEvents = [] } = useLiveStreams(feedTab);
const initialVideoCount = liveEvents.length > 0 ? 4 : 6;
const { data: streams } = useClassifiedStreams(feedTab);
const hasStreams = streams.live.length + streams.planned.length + streams.past.length > 0;
const initialVideoCount = hasStreams ? 4 : 6;
const visibleVideos = showAllVideos ? normalVideos : normalVideos.slice(0, initialVideoCount);
const showSkeleton = isPending || (isLoading && !rawData);
+3 -21
View File
@@ -45,13 +45,12 @@ import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Skeleton } from '@/components/ui/skeleton';
import { CommentsSheet } from '@/components/CommentsSheet';
import { getDisplayName } from '@/lib/getDisplayName';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { canZap } from '@/lib/canZap';
import { formatNumber } from '@/lib/formatNumber';
import { TabButton } from '@/components/TabButton';
import { cn } from '@/lib/utils';
const VINE_KIND = 34236;
@@ -788,26 +787,9 @@ function VinesTabBar({ tab, onTabChange, hasUser }: VinesTabBarProps) {
return (
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10 shrink-0">
{hasUser && (
<VinesTabButton label="Follows" active={tab === 'follows'} onClick={() => onTabChange('follows')} />
<TabButton label="Follows" active={tab === 'follows'} onClick={() => onTabChange('follows')} className="sidebar:py-5 sidebar:font-semibold" indicatorClassName="sidebar:h-[3px]" />
)}
<VinesTabButton label="Global" active={tab === 'global'} onClick={() => onTabChange('global')} />
<TabButton label="Global" active={tab === 'global'} onClick={() => onTabChange('global')} className="sidebar:py-5 sidebar:font-semibold" indicatorClassName="sidebar:h-[3px]" />
</div>
);
}
function VinesTabButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={cn(
'flex-1 py-3.5 sidebar:py-5 text-center text-sm font-medium sidebar:font-semibold transition-colors relative hover:bg-secondary/40',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 sidebar:h-[3px] bg-primary rounded-full" />
)}
</button>
);
}
+5
View File
@@ -4,6 +4,11 @@
declare module '@fontsource-variable/*';
declare module '@fontsource/comic-relief/*';
interface ImportMetaEnv {
/** Hex pubkey of the nostr-push server for Web Push notifications. */
readonly VITE_NOSTR_PUSH_PUBKEY?: string;
}
/**
* Build-time configuration injected by Vite from ditto.json.
* `null` when no config file was provided at build time.
+82 -4
View File
@@ -1,10 +1,10 @@
import process from "node:process";
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import react from "@vitejs/plugin-react";
import type { Plugin } from "vite";
import { defineConfig } from "vitest/config";
import { defineConfig, loadEnv, type Plugin } from "vite";
import { DittoConfigSchema } from "./src/lib/schemas";
@@ -92,6 +92,78 @@ function mergePublicDir(externalDir: string): Plugin {
};
}
/**
* All static routes in the application (no dynamic params).
* Used to generate a sitemap.xml at build time.
*/
const staticRoutes = [
'/',
'/feed',
'/notifications',
'/search',
'/trends',
'/settings',
'/settings/profile',
'/settings/feed',
'/settings/content',
'/settings/wallet',
'/settings/notifications',
'/settings/advanced',
'/settings/magic',
'/settings/network',
'/lists',
'/events',
'/photos',
'/videos',
'/vines',
'/music',
'/podcasts',
'/polls',
'/treasures',
'/colors',
'/packs',
'/webxdc',
'/articles',
'/decks',
'/emojis',
'/development',
'/themes',
'/bookmarks',
'/ai-chat',
'/world',
'/badges',
'/books',
'/help',
];
/**
* Vite plugin that generates a sitemap.xml in the build output.
* Set the PUBLIC_URL env var (e.g. "https://ditto.pub") to enable.
* Skipped when PUBLIC_URL is not set.
*/
function generateSitemap(origin: string): Plugin {
return {
name: 'ditto:sitemap',
writeBundle(options) {
const outDir = options.dir ?? path.resolve('dist');
const urls = staticRoutes
.map((route) => ` <url>\n <loc>${new URL(route, origin).href}</loc>\n </url>`)
.join('\n');
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
urls,
'</urlset>',
'',
].join('\n');
fs.writeFileSync(path.join(outDir, 'sitemap.xml'), xml);
},
};
}
const dittoConfig = loadDittoConfig();
const publicDir = process.env.PUBLIC_DIR;
@@ -106,7 +178,11 @@ function getVersion(): string {
// https://vitejs.dev/config/
export default defineConfig(() => ({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const publicUrl = env.PUBLIC_URL;
return {
server: {
host: "::",
port: 8080,
@@ -114,6 +190,7 @@ export default defineConfig(() => ({
plugins: [
react(),
...(publicDir ? [mergePublicDir(publicDir)] : []),
...(publicUrl ? [generateSitemap(publicUrl)] : []),
],
define: {
__DITTO_CONFIG__: JSON.stringify(dittoConfig ?? null),
@@ -138,4 +215,5 @@ export default defineConfig(() => ({
"@": path.resolve(__dirname, "./src"),
},
},
}));
};
});