Merge branch 'main' into feat-blobbi
This commit is contained in:
+3
-1
@@ -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 |
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -55,6 +55,7 @@ export function FollowButton({ pubkey, className, size = 'sm' }: FollowButtonPro
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size={size}
|
||||
variant={isFollowing ? 'outline' : 'default'}
|
||||
className={cn(
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}` });
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -352,7 +352,7 @@ export function RelayListManager() {
|
||||
handleAddRelay();
|
||||
}
|
||||
}}
|
||||
className="h-9 text-sm"
|
||||
className="h-9 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">—</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}}',
|
||||
},
|
||||
];
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -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
@@ -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"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user