diff --git a/.env.example b/.env.example index 3d3a5f29..d28fed4b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ VITE_SENTRY_DSN="https://********************************@*****************.example.com/****************" VITE_PLAUSIBLE_DOMAIN="example.tld" -VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event" \ No newline at end of file +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="" \ No newline at end of file diff --git a/android/app/src/main/java/pub/ditto/app/NostrPoller.java b/android/app/src/main/java/pub/ditto/app/NostrPoller.java index b5c94212..30ed0b94 100644 --- a/android/app/src/main/java/pub/ditto/app/NostrPoller.java +++ b/android/app/src/main/java/pub/ditto/app/NostrPoller.java @@ -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 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 referencedMap = refIdsNeeded.isEmpty() + ? new HashMap<>() + : fetchEventsByIds(new ArrayList<>(refIdsNeeded), relayUrl, httpClient); + + // Filter out reactions/reposts/zaps on posts the user didn't author. + List 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 fetchEventsByIds(List ids, String relayUrl, OkHttpClient httpClient) { + Map 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) { diff --git a/android/app/src/main/java/pub/ditto/app/NotificationRelayService.java b/android/app/src/main/java/pub/ditto/app/NotificationRelayService.java index 33f517ce..ab01a2bf 100644 --- a/android/app/src/main/java/pub/ditto/app/NotificationRelayService.java +++ b/android/app/src/main/java/pub/ditto/app/NotificationRelayService.java @@ -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); diff --git a/public/og-image.jpg b/public/og-image.jpg index ab8003d3..5f435436 100644 Binary files a/public/og-image.jpg and b/public/og-image.jpg differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..30a233ba --- /dev/null +++ b/public/sw.js @@ -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()); +}); diff --git a/src/App.tsx b/src/App.tsx index d4352738..8f9fd2ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { - - - - - - - + + + + + + + + + diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index f863115d..7546337d 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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 ( + <> + setComposeOpen(true)} + /> + + + ); +} + /** 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() { } /> } /> } /> + } /> } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> @@ -110,16 +133,7 @@ export function AppRouter() { } /> } /> } /> - - } - /> + } /> } /> } /> } /> } /> + } /> } /> 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} /> + + +
+
+
+

Request to Vanish

+

+ 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). +

+
+ +
+
+
+ + + + + )} ); } diff --git a/src/components/BlossomSettings.tsx b/src/components/BlossomSettings.tsx index ea09b9c9..f9fe30b6 100644 --- a/src/components/BlossomSettings.tsx +++ b/src/components/BlossomSettings.tsx @@ -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" /> + )} + + {/* Question */} +