From 52e42fcd6ebf3e9e175da5bdcda00a566eb93960 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 11:01:04 -0500 Subject: [PATCH] Replace hardcoded 'Ditto' with appConfig.appName and appConfig.appId User-facing display strings now read from config.appName so forks can rebrand without code changes, and localStorage keys are namespaced by config.appId so forks running on the same origin don't clobber each other's preferences. Module-level cache-key constants that previously hardcoded 'ditto:' have been refactored into hook-scoped reads from config.appId (via a new getStorageKey() helper). The helpContent FAQ template now uses {appName} placeholders substituted at read-time through getFAQCategories(appName)/getFAQItem(appName, id). --- src/components/ContentSettings.tsx | 41 +++++---- src/components/DittoLogo.tsx | 9 +- src/components/Feed.tsx | 13 +-- src/components/HelpFAQSection.tsx | 9 +- src/components/HelpTip.tsx | 4 +- src/components/MuteListRecoveryDialog.tsx | 4 +- src/components/NostrSync.tsx | 21 +++-- src/components/NsitePreviewDialog.tsx | 2 +- src/components/VersionCheck.tsx | 13 +-- src/components/WeatherStationCard.tsx | 5 +- src/components/WebxdcEmbed.tsx | 4 +- .../letter/LetterPreferencesSection.tsx | 4 +- src/components/letter/StationeryPicker.tsx | 6 +- src/hooks/useCuratorFollowList.ts | 17 ++-- src/hooks/useFeed.ts | 7 +- src/hooks/useFeedTab.ts | 7 +- src/hooks/useFollowActions.ts | 19 ++-- src/hooks/useInitialSync.ts | 15 ++-- src/hooks/useMuteList.ts | 26 +++--- src/lib/helpContent.ts | 88 ++++++++++++++----- src/lib/iframeSubdomain.ts | 16 ++-- src/lib/storageKey.ts | 16 ++++ src/pages/AIChatPage.tsx | 15 ++-- src/pages/CSAEPolicyPage.tsx | 18 ++-- src/pages/FollowPage.tsx | 7 +- src/pages/HelpPage.tsx | 2 +- src/pages/ProfilePage.tsx | 5 +- 27 files changed, 251 insertions(+), 142 deletions(-) create mode 100644 src/lib/storageKey.ts diff --git a/src/components/ContentSettings.tsx b/src/components/ContentSettings.tsx index caad70c9..ef0b1870 100644 --- a/src/components/ContentSettings.tsx +++ b/src/components/ContentSettings.tsx @@ -29,6 +29,7 @@ import { buildKindOptions } from '@/lib/feedFilterUtils'; import { genUserName } from '@/lib/genUserName'; import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds'; import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems'; +import { getStorageKey } from '@/lib/storageKey'; import type { SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext'; import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds'; @@ -246,43 +247,44 @@ function FeedTabsSection() { const { toast } = useToast(); const { updateSettings } = useEncryptedSettings(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { feedSettings, updateFeedSettings } = useFeedSettings(); const [communityDomain, setCommunityDomain] = useState(''); const [isDownloading, setIsDownloading] = useState(false); const [community, setCommunity] = useState<{ domain: string; userCount: number; label: string } | null>(() => { - const stored = localStorage.getItem('ditto:community'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'community')); return stored ? JSON.parse(stored) : null; }); const [showDittoFeed, setShowDittoFeed] = useState(() => { - const stored = localStorage.getItem('ditto:showDittoFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showDittoFeed')); return stored !== null ? stored === 'true' : true; // Default to true }); const [showGlobalFeed, setShowGlobalFeed] = useState(() => { - const stored = localStorage.getItem('ditto:showGlobalFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showGlobalFeed')); return stored !== null ? stored === 'true' : false; // Default to false }); const [showCommunityFeed, setShowCommunityFeed] = useState(() => { - const stored = localStorage.getItem('ditto:showCommunityFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showCommunityFeed')); return stored !== null ? stored === 'true' : false; // Default to false }); const handleToggleDittoFeed = async (checked: boolean) => { setShowDittoFeed(checked); - localStorage.setItem('ditto:showDittoFeed', String(checked)); + localStorage.setItem(getStorageKey(config.appId, 'showDittoFeed'), String(checked)); toast({ - title: checked ? 'Ditto feed enabled' : 'Ditto feed disabled', + title: checked ? `${config.appName} feed enabled` : `${config.appName} feed disabled`, description: checked - ? 'The Ditto feed tab will appear in your navigation' - : 'The Ditto feed tab will be hidden', + ? `The ${config.appName} feed tab will appear in your navigation` + : `The ${config.appName} feed tab will be hidden`, }); }; const handleToggleGlobalFeed = async (checked: boolean) => { setShowGlobalFeed(checked); - localStorage.setItem('ditto:showGlobalFeed', String(checked)); + localStorage.setItem(getStorageKey(config.appId, 'showGlobalFeed'), String(checked)); if (user) { await updateSettings.mutateAsync({ showGlobalFeed: checked }); } @@ -296,7 +298,7 @@ function FeedTabsSection() { const handleToggleCommunityFeed = async (checked: boolean) => { setShowCommunityFeed(checked); - localStorage.setItem('ditto:showCommunityFeed', String(checked)); + localStorage.setItem(getStorageKey(config.appId, 'showCommunityFeed'), String(checked)); if (user) { await updateSettings.mutateAsync({ showCommunityFeed: checked }); } @@ -351,14 +353,14 @@ function FeedTabsSection() { // Store in localStorage (single community only) const newCommunity = { domain, userCount, label }; setCommunity(newCommunity); - localStorage.setItem('ditto:community', JSON.stringify(newCommunity)); + localStorage.setItem(getStorageKey(config.appId, 'community'), JSON.stringify(newCommunity)); // Store the actual JSON data for later use - localStorage.setItem('ditto:communityData', JSON.stringify(data)); + localStorage.setItem(getStorageKey(config.appId, 'communityData'), JSON.stringify(data)); // Auto-enable the Community feed tab setShowCommunityFeed(true); - localStorage.setItem('ditto:showCommunityFeed', 'true'); + localStorage.setItem(getStorageKey(config.appId, 'showCommunityFeed'), 'true'); // Sync to encrypted settings if (user) { @@ -388,12 +390,12 @@ function FeedTabsSection() { const handleRemoveCommunity = async () => { setCommunity(null); - localStorage.removeItem('ditto:community'); - localStorage.removeItem('ditto:communityData'); + localStorage.removeItem(getStorageKey(config.appId, 'community')); + localStorage.removeItem(getStorageKey(config.appId, 'communityData')); // Also disable the community feed tab setShowCommunityFeed(false); - localStorage.setItem('ditto:showCommunityFeed', 'false'); + localStorage.setItem(getStorageKey(config.appId, 'showCommunityFeed'), 'false'); if (user) { await updateSettings.mutateAsync({ communityData: undefined, showCommunityFeed: false }); @@ -447,8 +449,8 @@ function FeedTabsSection() {
- -

Show trending and curated content from the Ditto relay

+ +

Show trending and curated content from the {config.appName} relay

setNewHashtag(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }} diff --git a/src/components/DittoLogo.tsx b/src/components/DittoLogo.tsx index 6bd03cb8..26eeb462 100644 --- a/src/components/DittoLogo.tsx +++ b/src/components/DittoLogo.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; +import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { cn } from '@/lib/utils'; @@ -107,9 +108,10 @@ function getPixelArtMask(): Promise { return maskPromise; } -/** The Ditto logo rendered from the custom SVG asset. Occasionally appears pixelated for logged-in users. */ +/** The app logo rendered from the custom SVG asset. Occasionally appears pixelated for logged-in users. */ export function DittoLogo({ className, size = 40 }: DittoLogoProps) { const { user } = useCurrentUser(); + const { config } = useAppContext(); if (isPixelated && user) { return ; @@ -118,7 +120,7 @@ export function DittoLogo({ className, size = 40 }: DittoLogoProps) { return (
(null); const [ready, setReady] = useState(!!maskCache); + const { config } = useAppContext(); useEffect(() => { const el = ref.current; @@ -164,7 +167,7 @@ function PixelatedLogo({ className, size = 40 }: DittoLogoProps) {
{ - const stored = localStorage.getItem('ditto:showGlobalFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showGlobalFeed')); return stored !== null ? stored === 'true' : false; })(); const showDittoFeed = (() => { - const stored = localStorage.getItem('ditto:showDittoFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showDittoFeed')); return stored !== null ? stored === 'true' : true; })(); const showCommunityFeed = (() => { - const stored = localStorage.getItem('ditto:showCommunityFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showCommunityFeed')); return stored !== null ? stored === 'true' : false; })(); const communityLabel = (() => { try { - const stored = localStorage.getItem('ditto:community'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'community')); if (stored) { const community = JSON.parse(stored); return community.label || 'Community'; @@ -247,7 +250,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee handleSetActiveTab('follows')} /> {!isKindSpecificPage && showDittoFeed && ( - handleSetActiveTab('ditto')} /> + handleSetActiveTab('ditto')} /> )} {!isKindSpecificPage && showCommunityFeed && ( handleSetActiveTab('communities')} /> diff --git a/src/components/HelpFAQSection.tsx b/src/components/HelpFAQSection.tsx index 3f2d5cad..c67b9677 100644 --- a/src/components/HelpFAQSection.tsx +++ b/src/components/HelpFAQSection.tsx @@ -6,7 +6,8 @@ import { AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; -import { FAQ_CATEGORIES, type FAQCategory, type FAQItem } from '@/lib/helpContent'; +import { useAppContext } from '@/hooks/useAppContext'; +import { getFAQCategories, type FAQCategory, type FAQItem } from '@/lib/helpContent'; // ── Inline markup renderer ──────────────────────────────────────────────────── @@ -87,8 +88,10 @@ interface HelpFAQSectionProps { * */ export function HelpFAQSection({ categories, items, hideHeadings, className }: HelpFAQSectionProps) { + const { config } = useAppContext(); + const filteredCategories = useMemo(() => { - let cats: FAQCategory[] = FAQ_CATEGORIES; + let cats: FAQCategory[] = getFAQCategories(config.appName); // Filter to specific categories if (categories) { @@ -106,7 +109,7 @@ export function HelpFAQSection({ categories, items, hideHeadings, className }: H } return cats; - }, [categories, items]); + }, [categories, items, config.appName]); if (filteredCategories.length === 0) return null; diff --git a/src/components/HelpTip.tsx b/src/components/HelpTip.tsx index a7317adc..b9c3ceb4 100644 --- a/src/components/HelpTip.tsx +++ b/src/components/HelpTip.tsx @@ -2,6 +2,7 @@ import { HelpCircle } from 'lucide-react'; import { Link } from 'react-router-dom'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useAppContext } from '@/hooks/useAppContext'; import { getFAQItem } from '@/lib/helpContent'; /** @@ -62,7 +63,8 @@ interface HelpTipProps { * */ export function HelpTip({ faqId, iconSize = 'size-4', className }: HelpTipProps) { - const item = getFAQItem(faqId); + const { config } = useAppContext(); + const item = getFAQItem(config.appName, faqId); if (!item) return null; return ( diff --git a/src/components/MuteListRecoveryDialog.tsx b/src/components/MuteListRecoveryDialog.tsx index 91fd2713..b750d83f 100644 --- a/src/components/MuteListRecoveryDialog.tsx +++ b/src/components/MuteListRecoveryDialog.tsx @@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { ScrollArea } from '@/components/ui/scroll-area'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; +import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; @@ -302,6 +303,7 @@ function SnapshotSkeleton() { function MuteHistoryContent({ onClose }: { onClose: () => void }) { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { mutateAsync: publishEvent } = useNostrPublish(); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -369,7 +371,7 @@ function MuteHistoryContent({ onClose }: { onClose: () => void }) { // Update the local mute cache with the restored items const summary = summaries?.get(event.id); if (summary && user) { - setCachedMuteItems(user.pubkey, summary.items); + setCachedMuteItems(config.appId, user.pubkey, summary.items); } toast({ diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index 748022b3..5ceb9f4f 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -7,6 +7,7 @@ import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useEncryptedSettings, setLocalSettingsSync } from "@/hooks/useEncryptedSettings"; import { isSyncDone } from "@/hooks/useInitialSync"; import { parseBlossomServerList } from "@/lib/appBlossom"; +import { getStorageKey } from "@/lib/storageKey"; import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent"; import type { ThemeConfig } from "@/themes"; @@ -213,7 +214,7 @@ export function NostrSync() { // Only reset theme/sidebar for real account switches, not fresh signups. // During signup, isSyncDone returns false and the onboarding // questionnaire owns theme state until it saves settings. - if (isSyncDone(user.pubkey)) { + if (isSyncDone(config.appId, user.pubkey)) { updateConfig((current) => { let changed = false; const updates = { ...current }; @@ -402,17 +403,19 @@ export function NostrSync() { // Sync feed tab settings (stored directly in localStorage, not AppConfig) if (encryptedSettings.showGlobalFeed !== undefined) { - const current = localStorage.getItem("ditto:showGlobalFeed"); + const key = getStorageKey(config.appId, "showGlobalFeed"); + const current = localStorage.getItem(key); const incoming = String(encryptedSettings.showGlobalFeed); if (current !== incoming) { - localStorage.setItem("ditto:showGlobalFeed", incoming); + localStorage.setItem(key, incoming); } } if (encryptedSettings.showCommunityFeed !== undefined) { - const current = localStorage.getItem("ditto:showCommunityFeed"); + const key = getStorageKey(config.appId, "showCommunityFeed"); + const current = localStorage.getItem(key); const incoming = String(encryptedSettings.showCommunityFeed); if (current !== incoming) { - localStorage.setItem("ditto:showCommunityFeed", incoming); + localStorage.setItem(key, incoming); } } if (encryptedSettings.communityData) { @@ -421,12 +424,13 @@ export function NostrSync() { label: encryptedSettings.communityData.label, userCount: encryptedSettings.communityData.userCount, }; - const currentRaw = localStorage.getItem("ditto:community"); + const communityKey = getStorageKey(config.appId, "community"); + const currentRaw = localStorage.getItem(communityKey); const incoming = JSON.stringify(community); if (currentRaw !== incoming) { - localStorage.setItem("ditto:community", incoming); + localStorage.setItem(communityKey, incoming); localStorage.setItem( - "ditto:communityData", + getStorageKey(config.appId, "communityData"), JSON.stringify({ names: encryptedSettings.communityData.nip05 }), ); } @@ -444,6 +448,7 @@ export function NostrSync() { updateConfig, recentlyWritten, seededTimestamp, + config.appId, ]); // Sync active profile theme (kind 16767) on pageload when autoShareTheme is enabled. diff --git a/src/components/NsitePreviewDialog.tsx b/src/components/NsitePreviewDialog.tsx index fbe5dd21..23f8a4d5 100644 --- a/src/components/NsitePreviewDialog.tsx +++ b/src/components/NsitePreviewDialog.tsx @@ -202,7 +202,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha // a private HMAC-SHA256 subdomain so the raw identifier is never exposed as // a sandbox origin (preventing cross-app localStorage/IndexedDB collisions). const nsiteSubdomain = getNsiteSubdomain(event); - const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]); + const previewSubdomain = useMemo(() => deriveIframeSubdomain(config.appId, 'nsite', nsiteSubdomain), [config.appId, nsiteSubdomain]); // Build the manifest and server list from the event (memoised per event identity) const manifest = useRef>(new Map()); diff --git a/src/components/VersionCheck.tsx b/src/components/VersionCheck.tsx index e7fd9b17..113dcc86 100644 --- a/src/components/VersionCheck.tsx +++ b/src/components/VersionCheck.tsx @@ -3,9 +3,9 @@ import { Link } from 'react-router-dom'; import { toast } from '@/hooks/useToast'; import { ToastAction } from '@/components/ui/toast'; +import { useAppContext } from '@/hooks/useAppContext'; import { parseChangelog } from '@/lib/changelog'; - -const STORAGE_KEY = 'ditto:app-version'; +import { getStorageKey } from '@/lib/storageKey'; /** Fetch the first changelog item for the given version (or the latest entry). */ async function fetchChangelogExcerpt(version: string): Promise { @@ -31,12 +31,15 @@ async function fetchChangelogExcerpt(version: string): Promise { const currentVersion = import.meta.env.VERSION; if (!currentVersion) return; - const storedVersion = localStorage.getItem(STORAGE_KEY); - localStorage.setItem(STORAGE_KEY, currentVersion); + const storageKey = getStorageKey(config.appId, 'app-version'); + const storedVersion = localStorage.getItem(storageKey); + localStorage.setItem(storageKey, currentVersion); if (storedVersion && storedVersion !== currentVersion) { // Show the toast immediately, then enrich it with a changelog excerpt. @@ -64,7 +67,7 @@ export function VersionCheck() { } }); } - }, []); + }, [config.appId]); return null; } diff --git a/src/components/WeatherStationCard.tsx b/src/components/WeatherStationCard.tsx index 8c4de608..091dfc0d 100644 --- a/src/components/WeatherStationCard.tsx +++ b/src/components/WeatherStationCard.tsx @@ -10,9 +10,11 @@ import { Wind, } from 'lucide-react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useAppContext } from '@/hooks/useAppContext'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useWeatherStation } from '@/hooks/useWeatherStation'; import { timeAgo } from '@/lib/timeAgo'; +import { getStorageKey } from '@/lib/storageKey'; import { formatWeatherSensorValue, parseWeatherStationRef, type WeatherUnitSystem } from '@/lib/weatherStation'; function weatherSensorIcon(sensorKey: string) { @@ -34,9 +36,10 @@ interface WeatherStationCardProps { } export function WeatherStationCard({ value, compact = false }: WeatherStationCardProps) { + const { config } = useAppContext(); const stationRef = parseWeatherStationRef(value); const { data, isPending } = useWeatherStation(value); - const [units, setUnits] = useLocalStorage('ditto:weather-units', 'normal'); + const [units, setUnits] = useLocalStorage(getStorageKey(config.appId, 'weather-units'), 'normal'); if (!stationRef) { return ( diff --git a/src/components/WebxdcEmbed.tsx b/src/components/WebxdcEmbed.tsx index 0ae5491c..6f3d0f79 100644 --- a/src/components/WebxdcEmbed.tsx +++ b/src/components/WebxdcEmbed.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Webxdc, type WebxdcHandle } from '@/components/Webxdc'; import { GameControls } from '@/components/GameControls'; import { useCenterColumn } from '@/contexts/LayoutContext'; +import { useAppContext } from '@/hooks/useAppContext'; import { useWebxdc } from '@/hooks/useWebxdc'; import { deriveIframeSubdomain } from '@/lib/iframeSubdomain'; import { cn } from '@/lib/utils'; @@ -57,10 +58,11 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro const centerColumn = useCenterColumn(); const columnRect = useElementRect(launched ? centerColumn : null); + const { config } = useAppContext(); // Derive a private, stable subdomain from a device-local seed + the identifier. const identifier = uuid ?? url; - const iframeId = deriveIframeSubdomain('webxdc', identifier); + const iframeId = deriveIframeSubdomain(config.appId, 'webxdc', identifier); const handleClose = useCallback(() => { setLaunched(false); diff --git a/src/components/letter/LetterPreferencesSection.tsx b/src/components/letter/LetterPreferencesSection.tsx index d0161c84..7972e1ef 100644 --- a/src/components/letter/LetterPreferencesSection.tsx +++ b/src/components/letter/LetterPreferencesSection.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { ArrowLeft, Sparkles, RotateCcw } from 'lucide-react'; import { SubHeaderBar } from '@/components/SubHeaderBar'; +import { useAppContext } from '@/hooks/useAppContext'; import { useLetterPreferences } from '@/hooks/useLetterPreferences'; import { useThemeStationery } from '@/hooks/useThemeStationery'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -22,6 +23,7 @@ function toSerializable(s: Stationery): Stationery { export function LetterPreferencesSection() { const { user } = useCurrentUser(); + const { config } = useAppContext(); const navigate = useNavigate(); const { prefs, updatePrefs, resetStationery, isThemeDefault } = useLetterPreferences(); const themeStationery = useThemeStationery(); @@ -122,7 +124,7 @@ export function LetterPreferencesSection() { Using your{' '} - Ditto theme + {config.appName} theme {' '}as stationery diff --git a/src/components/letter/StationeryPicker.tsx b/src/components/letter/StationeryPicker.tsx index 5972d354..8e93617a 100644 --- a/src/components/letter/StationeryPicker.tsx +++ b/src/components/letter/StationeryPicker.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { Skeleton } from '@/components/ui/skeleton'; import { Switch } from '@/components/ui/switch'; +import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { Dialog, @@ -244,6 +245,7 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps) const [infoOpen, setInfoOpen] = useState(false); const { user } = useCurrentUser(); + const { config } = useAppContext(); const followListData = useFollowList(); const followPubkeyArray = followListData.data?.pubkeys; const followList = useMemo(() => new Set(followPubkeyArray ?? []), [followPubkeyArray]); @@ -410,11 +412,11 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps) {isThemesTab && ( <> - Ditto themes + {config.appName} themes

- Ditto themes are UI themes shared by the community. Letters borrows their colors and fonts to style your letter. + {config.appName} themes are UI themes shared by the community. Letters borrows their colors and fonts to style your letter.

({ queryKey: ['curator-follow-list', curatorPubkey], @@ -57,12 +56,12 @@ export function useCuratorFollowList() { // Include the curator themselves const allPubkeys = [...new Set([curatorPubkey, ...pubkeys])]; - setCached(allPubkeys); + setCached(cacheKey, allPubkeys); return allPubkeys; }, enabled: !!curatorPubkey, staleTime: 10 * 60 * 1000, // 10 minutes gcTime: 60 * 60 * 1000, // 1 hour - placeholderData: getCached(), + placeholderData: getCached(cacheKey), }); } diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts index db403046..83db5f00 100644 --- a/src/hooks/useFeed.ts +++ b/src/hooks/useFeed.ts @@ -1,5 +1,6 @@ import { useNostr } from '@nostrify/react'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { useAppContext } from './useAppContext'; import { useCurrentUser } from './useCurrentUser'; import { useFeedSettings } from './useFeedSettings'; import { useFollowList } from './useFollowActions'; @@ -8,6 +9,7 @@ import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { getPaginationCursor, parseRepostContent, isRepostKind, type FeedItem } from '@/lib/feedUtils'; import { isReplyEvent } from '@/lib/nostrEvents'; import { setProfileCached } from '@/lib/profileCache'; +import { getStorageKey } from '@/lib/storageKey'; import type { NostrEvent } from '@nostrify/nostrify'; const PAGE_SIZE = 15; @@ -43,6 +45,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use const { nostr } = useNostr(); const queryClient = useQueryClient(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { data: followData } = useFollowList(); const followList = followData?.pubkeys; const { feedSettings } = useFeedSettings(); @@ -65,7 +68,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use const communityPubkeys = (() => { if (tab !== 'communities') return []; try { - const dataStr = localStorage.getItem('ditto:communityData'); + const dataStr = localStorage.getItem(getStorageKey(config.appId, 'communityData')); if (!dataStr) return []; const data = JSON.parse(dataStr); @@ -116,7 +119,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use // Get the community domain for verification let communityDomain = ''; try { - const communityStr = localStorage.getItem('ditto:community'); + const communityStr = localStorage.getItem(getStorageKey(config.appId, 'community')); if (communityStr) { const community = JSON.parse(communityStr); communityDomain = community.domain; diff --git a/src/hooks/useFeedTab.ts b/src/hooks/useFeedTab.ts index 46fc00e9..e987c123 100644 --- a/src/hooks/useFeedTab.ts +++ b/src/hooks/useFeedTab.ts @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react'; +import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; - -const STORAGE_PREFIX = 'ditto:feed-tab:'; +import { getStorageKey } from '@/lib/storageKey'; /** * Manages the active feed tab for a specific feed page, persisting @@ -18,7 +18,8 @@ export function useFeedTab( validTabs?: readonly T[], ): [T, (tab: T) => void] { const { user } = useCurrentUser(); - const key = STORAGE_PREFIX + feedId; + const { config } = useAppContext(); + const key = getStorageKey(config.appId, `feed-tab:${feedId}`); const [activeTab, setActiveTab] = useState(() => { const defaultTab = (user ? 'follows' : 'ditto') as T; diff --git a/src/hooks/useFollowActions.ts b/src/hooks/useFollowActions.ts index 31731bf5..4d188169 100644 --- a/src/hooks/useFollowActions.ts +++ b/src/hooks/useFollowActions.ts @@ -3,7 +3,9 @@ import { useNostr } from '@nostrify/react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; +import { useAppContext } from './useAppContext'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { getStorageKey } from '@/lib/storageKey'; import type { NostrEvent } from '@nostrify/nostrify'; // --------------------------------------------------------------------------- @@ -17,13 +19,10 @@ export interface FollowListData { pubkeys: string[]; } -/** localStorage key for cached follow list pubkeys. */ -const FOLLOW_CACHE_KEY = 'ditto:followListCache'; - /** Read cached follow pubkeys from localStorage for a given user. */ -function getCachedFollowList(pubkey: string): FollowListData | undefined { +function getCachedFollowList(cacheKey: string, pubkey: string): FollowListData | undefined { try { - const raw = localStorage.getItem(FOLLOW_CACHE_KEY); + const raw = localStorage.getItem(cacheKey); if (!raw) return undefined; const cached = JSON.parse(raw); // Only use cache if it belongs to the same user @@ -35,9 +34,9 @@ function getCachedFollowList(pubkey: string): FollowListData | undefined { } /** Persist follow pubkeys to localStorage. */ -function setCachedFollowList(pubkey: string, pubkeys: string[]): void { +function setCachedFollowList(cacheKey: string, pubkey: string, pubkeys: string[]): void { try { - localStorage.setItem(FOLLOW_CACHE_KEY, JSON.stringify({ pubkey, pubkeys })); + localStorage.setItem(cacheKey, JSON.stringify({ pubkey, pubkeys })); } catch { // Storage full or unavailable — non-critical } @@ -54,6 +53,8 @@ function setCachedFollowList(pubkey: string, pubkeys: string[]): void { export function useFollowList() { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { config } = useAppContext(); + const cacheKey = getStorageKey(config.appId, 'followListCache'); return useQuery({ queryKey: ['follow-list', user?.pubkey ?? ''], @@ -67,12 +68,12 @@ export function useFollowList() { const pubkeys = event.tags .filter(([name]) => name === 'p') .map(([, pk]) => pk); - setCachedFollowList(user.pubkey, pubkeys); + setCachedFollowList(cacheKey, user.pubkey, pubkeys); return { event, pubkeys }; }, enabled: !!user, staleTime: 5 * 60 * 1000, - placeholderData: user ? getCachedFollowList(user.pubkey) : undefined, + placeholderData: user ? getCachedFollowList(cacheKey, user.pubkey) : undefined, }); } diff --git a/src/hooks/useInitialSync.ts b/src/hooks/useInitialSync.ts index 4a0144d5..6196f580 100644 --- a/src/hooks/useInitialSync.ts +++ b/src/hooks/useInitialSync.ts @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { parseBlossomServerList } from "@/lib/appBlossom"; import { EncryptedSettingsSchema } from "@/lib/schemas"; +import { getStorageKey } from "@/lib/storageKey"; import { useAppContext } from "./useAppContext"; import { useCurrentUser } from "./useCurrentUser"; import { type EncryptedSettings, setLocalSettingsSync } from "./useEncryptedSettings"; @@ -34,9 +35,9 @@ const SYNC_TIMEOUT_MS = 8000; * Uses a localStorage flag so the sync screen only shows once per user * (not on every page refresh or new session while logged in). */ -export function isSyncDone(pubkey: string): boolean { +export function isSyncDone(appId: string, pubkey: string): boolean { try { - return localStorage.getItem(`ditto:sync-done:${pubkey}`) === "1"; + return localStorage.getItem(getStorageKey(appId, `sync-done:${pubkey}`)) === "1"; } catch { return false; } @@ -52,7 +53,7 @@ export function useInitialSync() { // for users who already completed it or who are logged out. const [phase, setPhase] = useState(() => { if (!user) return "idle"; - if (isSyncDone(user.pubkey)) return "complete"; + if (isSyncDone(config.appId, user.pubkey)) return "complete"; return "idle"; }); const syncAttempted = useRef(false); @@ -60,11 +61,11 @@ export function useInitialSync() { const markSyncComplete = useCallback(() => { if (!user) return; try { - localStorage.setItem(`ditto:sync-done:${user.pubkey}`, "1"); + localStorage.setItem(getStorageKey(config.appId, `sync-done:${user.pubkey}`), "1"); } catch { // localStorage may not be available } - }, [user]); + }, [user, config.appId]); // Reset when user changes useEffect(() => { @@ -75,7 +76,7 @@ export function useInitialSync() { } // Skip sync if already completed for this user - if (isSyncDone(user.pubkey)) { + if (isSyncDone(config.appId, user.pubkey)) { setPhase("complete"); return; } @@ -322,7 +323,7 @@ export function useInitialSync() { } queryClient.setQueryData(["muteItems", muteEvent.id], items); - setCachedMuteItems(user.pubkey, items); + setCachedMuteItems(config.appId, user.pubkey, items); foundSettings = true; } diff --git a/src/hooks/useMuteList.ts b/src/hooks/useMuteList.ts index 6b10fc17..77d0ba7e 100644 --- a/src/hooks/useMuteList.ts +++ b/src/hooks/useMuteList.ts @@ -5,20 +5,24 @@ import { nip19 } from 'nostr-tools'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; +import { useAppContext } from './useAppContext'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { getStorageKey } from '@/lib/storageKey'; export interface MuteListItem { type: 'pubkey' | 'hashtag' | 'word' | 'thread'; value: string; } -/** localStorage key for cached mute list items. */ -const MUTE_CACHE_KEY = 'ditto:muteListCache'; +/** Build the localStorage key for cached mute list items. */ +export function getMuteCacheKey(appId: string): string { + return getStorageKey(appId, 'muteListCache'); +} /** Read cached mute items from localStorage for a given user. */ -function getCachedMuteItems(pubkey: string): MuteListItem[] | undefined { +function getCachedMuteItems(cacheKey: string, pubkey: string): MuteListItem[] | undefined { try { - const raw = localStorage.getItem(MUTE_CACHE_KEY); + const raw = localStorage.getItem(cacheKey); if (!raw) return undefined; const cached = JSON.parse(raw); if (cached.pubkey !== pubkey || !Array.isArray(cached.items)) return undefined; @@ -29,9 +33,9 @@ function getCachedMuteItems(pubkey: string): MuteListItem[] | undefined { } /** Persist decrypted mute items to localStorage. */ -export function setCachedMuteItems(pubkey: string, items: MuteListItem[]): void { +export function setCachedMuteItems(appId: string, pubkey: string, items: MuteListItem[]): void { try { - localStorage.setItem(MUTE_CACHE_KEY, JSON.stringify({ pubkey, items })); + localStorage.setItem(getMuteCacheKey(appId), JSON.stringify({ pubkey, items })); } catch { // Storage full or unavailable — non-critical } @@ -144,11 +148,13 @@ async function getAllMuteItems( export function useMuteList() { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const queryClient = useQueryClient(); const { mutateAsync: publishEvent } = useNostrPublish(); + const cacheKey = getMuteCacheKey(config.appId); // Placeholder from localStorage so mutes apply immediately on page load - const cachedItems = user ? getCachedMuteItems(user.pubkey) : undefined; + const cachedItems = user ? getCachedMuteItems(cacheKey, user.pubkey) : undefined; // Query the current mute list const query = useQuery({ @@ -181,7 +187,7 @@ export function useMuteList() { const items = await getAllMuteItems(event, user.signer, user.pubkey); // Persist to localStorage for next page load - setCachedMuteItems(user.pubkey, items); + setCachedMuteItems(config.appId, user.pubkey, items); return items; }, @@ -216,7 +222,7 @@ export function useMuteList() { : [...currentItems, { ...item, value: normalizedValue }]; // Update localStorage immediately so it survives page refresh - setCachedMuteItems(user.pubkey, newItems); + setCachedMuteItems(config.appId, user.pubkey, newItems); await updateMuteList(newItems, prev); }, @@ -240,7 +246,7 @@ export function useMuteList() { ); // Update localStorage immediately so it survives page refresh - setCachedMuteItems(user.pubkey, newItems); + setCachedMuteItems(config.appId, user.pubkey, newItems); await updateMuteList(newItems, prev); }, diff --git a/src/lib/helpContent.ts b/src/lib/helpContent.ts index c54a5461..800db1a7 100644 --- a/src/lib/helpContent.ts +++ b/src/lib/helpContent.ts @@ -2,9 +2,12 @@ * Structured FAQ content for the Help section. * * This module is the single source of truth for all Help/FAQ data. - * Any page can import `FAQ_CATEGORIES` or use `getFAQItems()` to render - * a full FAQ or a filtered subset (e.g. only "payments" questions on a - * wallet settings page). + * Any page can call `getFAQCategories(appName)` or `getFAQItems(appName)` to + * render a full FAQ or a filtered subset (e.g. only "payments" questions on + * a wallet settings page). + * + * Author-visible strings containing the app name are stored with the + * `{appName}` placeholder and substituted at read-time by the helpers. */ // ── Types ───────────────────────────────────────────────────────────────────── @@ -31,7 +34,12 @@ export interface FAQCategory { // ── Data ────────────────────────────────────────────────────────────────────── -export const FAQ_CATEGORIES: FAQCategory[] = [ +/** + * Raw FAQ template content. Strings may contain the literal `{appName}` + * placeholder, which is substituted at read-time by `getFAQCategories()` + * and friends. + */ +const FAQ_TEMPLATE: FAQCategory[] = [ // ── Getting Started ───────────────────────────────────────────────────── { id: 'getting-started', @@ -39,10 +47,10 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ items: [ { id: 'what-is-ditto', - question: 'What is Ditto?', + question: 'What is {appName}?', answer: [ - 'Ditto is a social media platform built on Nostr \u2014 a new kind of open, decentralized network. Think of Ditto as the app you\'re using right now to connect with people, post, and discover content.', - 'Because Ditto is built on Nostr, your account isn\'t locked to this site. You own your identity and can take it to any other Nostr app. Learn more at [soapbox.pub/ditto](https://soapbox.pub/ditto).', + '{appName} is a social media platform built on Nostr \u2014 a new kind of open, decentralized network. Think of {appName} as the app you\'re using right now to connect with people, post, and discover content.', + 'Because {appName} is built on Nostr, your account isn\'t locked to this site. You own your identity and can take it to any other Nostr app. Learn more at [soapbox.pub/ditto](https://soapbox.pub/ditto).', ], }, { @@ -55,9 +63,9 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ }, { id: 'login-other-apps', - question: 'Can I log into other Nostr apps with my Ditto account?', + question: 'Can I log into other Nostr apps with my {appName} account?', answer: [ - 'Yes! Your Ditto account **is** a Nostr account. You can use the same keys to log into any Nostr app \u2014 Primal, Damus, Amethyst, Coracle, and many more. Your posts, followers, and profile carry over everywhere.', + 'Yes! Your {appName} account **is** a Nostr account. You can use the same keys to log into any Nostr app \u2014 Primal, Damus, Amethyst, Coracle, and many more. Your posts, followers, and profile carry over everywhere.', 'Explore the full range of Nostr apps at [nostrapps.com](https://nostrapps.com/).', ], }, @@ -87,9 +95,9 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ }, { id: 'cost-to-use', - question: 'Does Ditto cost anything?', + question: 'Does {appName} cost anything?', answer: [ - '**Nope!** Ditto is completely free to use. Zaps (tips) are optional and just for fun. There are no premium tiers, no paywalls, no hidden fees.', + '**Nope!** {appName} is completely free to use. Zaps (tips) are optional and just for fun. There are no premium tiers, no paywalls, no hidden fees.', ], }, { @@ -113,7 +121,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.', - '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!', + 'On Android, you can download {appName} 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 +136,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. Ditto is listed there \u2014 [get it on Zap Store](https://zapstore.dev/apps/pub.ditto.app).', + '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. {appName} 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/).', ], }, @@ -298,14 +306,14 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ question: 'What does "open source" mean, and why does it matter?', answer: [ 'Open source means the code that powers this app is publicly available for anyone to read, verify, and improve. There are no hidden algorithms, no secret data collection, and no backdoors.', - 'Anyone can check exactly what the software does. It\'s the digital equivalent of a restaurant with a glass kitchen \u2014 nothing to hide. You can browse the [Ditto source code](https://gitlab.com/soapbox-pub/ditto) yourself, or if you want to try editing Ditto, you can jump right in with [Shakespeare](https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git).', + 'Anyone can check exactly what the software does. It\'s the digital equivalent of a restaurant with a glass kitchen \u2014 nothing to hide. You can browse the [{appName} source code](https://gitlab.com/soapbox-pub/ditto) yourself, or if you want to try editing {appName}, you can jump right in with [Shakespeare](https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git).', ], }, { id: 'self-host', - question: 'Can I self-host Ditto?', + question: 'Can I self-host {appName}?', answer: [ - 'Yes! Because Ditto is open source, anyone can run their own instance. You get full control over your server, your data, and your community.', + 'Yes! Because {appName} is open source, anyone can run their own instance. You get full control over your server, your data, and your community.', 'If you\'re interested, check out the [self-hosting guide](https://about.ditto.pub/self-hosting) to get started.', ], }, @@ -314,7 +322,7 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ question: 'Who made this?', answer: [ 'This platform is built by [Soapbox](https://soapbox.pub), a team of developers who believe social media should be owned by its users, not corporations.', - 'Soapbox builds open-source tools for the Nostr ecosystem, including Ditto (the server that powers this site). You can learn more about the team and their mission at [soapbox.pub](https://soapbox.pub).', + 'Soapbox builds open-source tools for the Nostr ecosystem, including {appName} (the server that powers this site). You can learn more about the team and their mission at [soapbox.pub](https://soapbox.pub).', ], }, ], @@ -323,19 +331,51 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ // ── Helpers ─────────────────────────────────────────────────────────────────── +/** Replace all occurrences of `{appName}` in a string with the resolved value. */ +function substitute(str: string, appName: string): string { + return str.replaceAll('{appName}', appName); +} + +/** Substitute placeholders in a single FAQ item. */ +function substituteItem(item: FAQItem, appName: string): FAQItem { + return { + ...item, + question: substitute(item.question, appName), + answer: item.answer.map((p) => substitute(p, appName)), + }; +} + +/** Substitute placeholders in a single category (questions + answers). */ +function substituteCategory(cat: FAQCategory, appName: string): FAQCategory { + return { + ...cat, + label: substitute(cat.label, appName), + description: cat.description ? substitute(cat.description, appName) : undefined, + items: cat.items.map((i) => substituteItem(i, appName)), + }; +} + +/** + * Return the full list of FAQ categories with `{appName}` placeholders + * resolved to the given `appName`. + */ +export function getFAQCategories(appName: string): FAQCategory[] { + return FAQ_TEMPLATE.map((c) => substituteCategory(c, appName)); +} + /** Flat list of every FAQ item, optionally filtered by category ID. */ -export function getFAQItems(categoryId?: string): FAQItem[] { +export function getFAQItems(appName: string, categoryId?: string): FAQItem[] { const cats = categoryId - ? FAQ_CATEGORIES.filter((c) => c.id === categoryId) - : FAQ_CATEGORIES; - return cats.flatMap((c) => c.items); + ? FAQ_TEMPLATE.filter((c) => c.id === categoryId) + : FAQ_TEMPLATE; + return cats.flatMap((c) => c.items).map((i) => substituteItem(i, appName)); } /** Look up a single FAQ item by its ID across all categories. */ -export function getFAQItem(itemId: string): FAQItem | undefined { - for (const cat of FAQ_CATEGORIES) { +export function getFAQItem(appName: string, itemId: string): FAQItem | undefined { + for (const cat of FAQ_TEMPLATE) { const found = cat.items.find((i) => i.id === itemId); - if (found) return found; + if (found) return substituteItem(found, appName); } return undefined; } diff --git a/src/lib/iframeSubdomain.ts b/src/lib/iframeSubdomain.ts index 18090df2..9d56a492 100644 --- a/src/lib/iframeSubdomain.ts +++ b/src/lib/iframeSubdomain.ts @@ -3,20 +3,20 @@ import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; import { hexToBase36 } from '@/lib/nsiteSubdomain'; - -const SEED_STORAGE_KEY = 'ditto:seed'; +import { getStorageKey } from '@/lib/storageKey'; /** * Get or create a device-local random seed persisted in localStorage. * This is a general-purpose secret used to derive private identifiers * (e.g. sandbox frame subdomains) that must not be predictable by third parties. */ -function getSeed(): string { - const stored = localStorage.getItem(SEED_STORAGE_KEY); +function getSeed(appId: string): string { + const key = getStorageKey(appId, 'seed'); + const stored = localStorage.getItem(key); if (stored) return stored; const seed = crypto.randomUUID(); - localStorage.setItem(SEED_STORAGE_KEY, seed); + localStorage.setItem(key, seed); return seed; } @@ -35,9 +35,11 @@ function getSeed(): string { * * The result is a 50-character base36 string (256 bits of entropy) that * fits within the 63-character subdomain label limit. + * + * @param appId The app's configured `appId` — used to namespace the device seed in localStorage. */ -export function deriveIframeSubdomain(prefix: string, identifier: string): string { - const seed = getSeed(); +export function deriveIframeSubdomain(appId: string, prefix: string, identifier: string): string { + const seed = getSeed(appId); const enc = new TextEncoder(); const mac = hmac(sha256, enc.encode(seed), enc.encode(`${prefix}|${identifier}`)); return hexToBase36(bytesToHex(mac)); diff --git a/src/lib/storageKey.ts b/src/lib/storageKey.ts new file mode 100644 index 00000000..e6ae481f --- /dev/null +++ b/src/lib/storageKey.ts @@ -0,0 +1,16 @@ +/** + * Build a namespaced localStorage key using the app's configured `appId`. + * + * This keeps per-fork storage isolated and prevents two forks running on the + * same origin (e.g. during local development) from clobbering each other's + * preferences. + * + * @example + * // In a React component / hook: + * const { config } = useAppContext(); + * const key = getStorageKey(config.appId, 'showGlobalFeed'); + * // → "ditto:showGlobalFeed" (on the default build) + */ +export function getStorageKey(appId: string, suffix: string): string { + return `${appId}:${suffix}`; +} diff --git a/src/pages/AIChatPage.tsx b/src/pages/AIChatPage.tsx index d05a87f6..f161a918 100644 --- a/src/pages/AIChatPage.tsx +++ b/src/pages/AIChatPage.tsx @@ -171,9 +171,11 @@ function useToolExecutor() { // ─── System Prompt ─── -const SYSTEM_PROMPT: ChatMessage = { - role: 'system', - content: `You are Dork, extraordinaire. You are an AI assistant integrated into Ditto, a Nostr social client. You can help users with questions, conversations, and tasks. +/** Build the system prompt with the configured app name woven in. */ +function buildSystemPrompt(appName: string): ChatMessage { + return { + role: 'system', + content: `You are Dork, extraordinaire. You are an AI assistant integrated into ${appName}, a Nostr social client. You can help users with questions, conversations, and tasks. You have a set_theme tool that applies a full custom theme. It supports: @@ -189,7 +191,8 @@ You have a set_theme tool that applies a full custom theme. It supports: When the user asks to change the theme, be creative — combine colors, fonts, and backgrounds to create a cohesive aesthetic. Always set colors. Add a font when it enhances the mood. Add a background image only when you have a suitable URL or the user requests one. Be concise and friendly. When you use a tool, briefly describe the theme you created.`, -}; + }; +} // ─── Page Component ─── @@ -262,7 +265,7 @@ export function AIChatPage() { // Build the chat messages array for the API (includes system prompt + conversation history) const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => { - const apiMessages: ChatMessage[] = [SYSTEM_PROMPT]; + const apiMessages: ChatMessage[] = [buildSystemPrompt(config.appName)]; for (const msg of displayMsgs) { if (msg.role === 'tool_result') continue; // Tool results are internal @@ -270,7 +273,7 @@ export function AIChatPage() { } return apiMessages; - }, []); + }, [config.appName]); // Handle sending a message const handleSend = useCallback(async () => { diff --git a/src/pages/CSAEPolicyPage.tsx b/src/pages/CSAEPolicyPage.tsx index 889472ba..bbb6a536 100644 --- a/src/pages/CSAEPolicyPage.tsx +++ b/src/pages/CSAEPolicyPage.tsx @@ -45,8 +45,8 @@ export function CSAEPolicyPage() {

  • - Our infrastructure: We operate the Ditto relay and{' '} - Ditto Blossom server, which serve as the default relay and file host for + Our infrastructure: We operate the {config.appName} relay and{' '} + {config.appName} Blossom server, which serve as the default relay and file host for {' '}{config.appName}. We have full moderation control over content stored on these services.
  • @@ -61,8 +61,8 @@ export function CSAEPolicyPage() {

- We take full responsibility for the experience within our app. On our own infrastructure (Ditto relay - and Ditto Blossom server), we can directly remove content and ban offending accounts. For content + We take full responsibility for the experience within our app. On our own infrastructure ({config.appName} relay + and {config.appName} Blossom server), we can directly remove content and ban offending accounts. For content originating from third-party services, we actively block it from being displayed within {' '}{config.appName}.

@@ -117,11 +117,11 @@ export function CSAEPolicyPage() { suspected CSAE content for immediate review.
  • - Ditto relay moderation: On our own Ditto relay, we actively moderate content and + {config.appName} relay moderation: On our own {config.appName} relay, we actively moderate content and will immediately remove any CSAE material and permanently ban associated accounts.
  • - Ditto Blossom server moderation: On our own Ditto Blossom file server, we will + {config.appName} Blossom server moderation: On our own {config.appName} Blossom file server, we will immediately delete any CSAE media and ban the uploading account.
  • @@ -148,7 +148,7 @@ export function CSAEPolicyPage() { the app through content filters and blocklists.
  • - Removal from Ditto infrastructure: CSAE content on the Ditto relay and Ditto + Removal from {config.appName} infrastructure: CSAE content on the {config.appName} relay and {config.appName} Blossom server will be immediately deleted, and the associated accounts permanently banned.
  • @@ -223,7 +223,7 @@ export function CSAEPolicyPage() {

    • - Provide any information available to us -- including data from the Ditto relay and Ditto Blossom + Provide any information available to us -- including data from the {config.appName} relay and {config.appName} Blossom server -- that may assist in investigations, in accordance with applicable law.
    • @@ -248,7 +248,7 @@ export function CSAEPolicyPage() {
      • Full control over our own infrastructure: We can and do remove content from the - Ditto relay and Ditto Blossom server. CSAE material found on our infrastructure is deleted + {config.appName} relay and {config.appName} Blossom server. CSAE material found on our infrastructure is deleted immediately and accounts are permanently banned.
      • diff --git a/src/pages/FollowPage.tsx b/src/pages/FollowPage.tsx index 5f53c7db..0d250c1b 100644 --- a/src/pages/FollowPage.tsx +++ b/src/pages/FollowPage.tsx @@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { NoteCard } from '@/components/NoteCard'; import { getAvatarShape, isEmoji, emojiAvatarBorderStyle } from '@/lib/avatarShape'; import { cn } from '@/lib/utils'; +import { useAppContext } from '@/hooks/useAppContext'; import { useAuthor } from '@/hooks/useAuthor'; import { useAuthors } from '@/hooks/useAuthors'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -175,6 +176,7 @@ function ProfileFeed({ pubkey }: { pubkey: string }) { function FollowView({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { data: followData } = useFollowList(); const { isPending, follow } = useFollowActions(); const { toast } = useToast(); @@ -282,7 +284,7 @@ function FollowView({ pubkey }: { pubkey: string }) { className="w-full rounded-full py-3 text-base font-semibold" size="lg" > - Follow {displayName} on Ditto + Follow {displayName} on {config.appName} ) : isOwnProfile ? (
        @@ -357,6 +359,7 @@ function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[] const { data: event, isLoading: eventLoading } = useAddrEvent(addr, relays); const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { data: followList } = useFollowList(); const { mutateAsync: publishEvent } = useNostrPublish(); const { toast } = useToast(); @@ -535,7 +538,7 @@ function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[] size="lg" > - Follow {pubkeys.length} people on Ditto + Follow {pubkeys.length} people on {config.appName} ) : isFollowingAll ? (