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).
This commit is contained in:
Alex Gleason
2026-04-17 11:01:04 -05:00
parent 9837c23a96
commit 52e42fcd6e
27 changed files with 251 additions and 142 deletions
+22 -19
View File
@@ -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() {
<div className="border-b border-border">
<div className="flex items-center justify-between py-3.5 px-3">
<div className="min-w-0">
<Label className="text-sm font-medium">Ditto Feed</Label>
<p className="text-xs text-muted-foreground mt-0.5">Show trending and curated content from the Ditto relay</p>
<Label className="text-sm font-medium">{config.appName} Feed</Label>
<p className="text-xs text-muted-foreground mt-0.5">Show trending and curated content from the {config.appName} relay</p>
</div>
<Switch
checked={showDittoFeed}
@@ -569,6 +571,7 @@ function FeedTabsSection() {
function InterestsSection() {
const { toast } = useToast();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { hashtags, addInterest: addHashtag, removeInterest: removeHashtag, isLoading: isLoadingHashtags } = useInterests('t');
const { hashtags: geotags, addInterest: addGeotag, removeInterest: removeGeotag, isLoading: isLoadingGeotags } = useInterests('g');
const [newHashtag, setNewHashtag] = useState('');
@@ -630,7 +633,7 @@ function InterestsSection() {
<div className="flex gap-2">
<Input
placeholder="ditto"
placeholder={config.appId}
value={newHashtag}
onChange={(e) => setNewHashtag(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }}
+6 -3
View File
@@ -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<string> {
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 <PixelatedLogo className={className} size={size} />;
@@ -118,7 +120,7 @@ export function DittoLogo({ className, size = 40 }: DittoLogoProps) {
return (
<div
role="img"
aria-label="Ditto"
aria-label={config.appName}
style={{
width: size,
height: size,
@@ -141,6 +143,7 @@ export function DittoLogo({ className, size = 40 }: DittoLogoProps) {
function PixelatedLogo({ className, size = 40 }: DittoLogoProps) {
const ref = useRef<HTMLDivElement>(null);
const [ready, setReady] = useState(!!maskCache);
const { config } = useAppContext();
useEffect(() => {
const el = ref.current;
@@ -164,7 +167,7 @@ function PixelatedLogo({ className, size = 40 }: DittoLogoProps) {
<div
ref={ref}
role="img"
aria-label="Ditto"
aria-label={config.appName}
style={{
width: size,
height: size,
+8 -5
View File
@@ -12,9 +12,11 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Loader2, MapPin } from 'lucide-react';
import LoginDialog from '@/components/auth/LoginDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { useAppContext } from '@/hooks/useAppContext';
import { useFeed } from '@/hooks/useFeed';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { DITTO_RELAYS } from '@/lib/appRelays';
import { getStorageKey } from '@/lib/storageKey';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useInterests } from '@/hooks/useInterests';
@@ -55,6 +57,7 @@ interface FeedProps {
export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, feedId = 'home' }: FeedProps = {}) {
const { user } = useCurrentUser();
const { config } = useAppContext();
const { muteItems } = useMuteList();
const { savedFeeds } = useSavedFeeds();
const { hashtags } = useInterests();
@@ -63,23 +66,23 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// Tab settings from localStorage
const showGlobalFeed = (() => {
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
<SubHeaderBar>
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
{!isKindSpecificPage && showDittoFeed && (
<TabButton label="Ditto" active={activeTab === 'ditto'} onClick={() => handleSetActiveTab('ditto')} />
<TabButton label={config.appName} active={activeTab === 'ditto'} onClick={() => handleSetActiveTab('ditto')} />
)}
{!isKindSpecificPage && showCommunityFeed && (
<TabButton label={communityLabel} active={activeTab === 'communities'} onClick={() => handleSetActiveTab('communities')} />
+6 -3
View File
@@ -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 {
* <HelpFAQSection items={['what-are-relays', 'what-are-blossom']} hideHeadings />
*/
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;
+3 -1
View File
@@ -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 {
* <label>Relays <HelpTip faqId="what-are-relays" /></label>
*/
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 (
+3 -1
View File
@@ -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({
+13 -8
View File
@@ -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.
+1 -1
View File
@@ -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<Map<string, string>>(new Map());
+8 -5
View File
@@ -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<string | undefined> {
@@ -31,12 +31,15 @@ async function fetchChangelogExcerpt(version: string): Promise<string | undefine
/** Compares the running app version against localStorage and shows a toast when the version changes. */
export function VersionCheck() {
const { config } = useAppContext();
useEffect(() => {
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;
}
+4 -1
View File
@@ -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<WeatherUnitSystem>('ditto:weather-units', 'normal');
const [units, setUnits] = useLocalStorage<WeatherUnitSystem>(getStorageKey(config.appId, 'weather-units'), 'normal');
if (!stationRef) {
return (
+3 -1
View File
@@ -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);
@@ -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() {
<span className="text-muted-foreground flex-1">
Using your{' '}
<Link to="/settings" className="text-primary font-medium hover:underline">
Ditto theme
{config.appName} theme
</Link>
{' '}as stationery
</span>
+4 -2
View File
@@ -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 && (
<>
<DialogHeader>
<DialogTitle>Ditto themes</DialogTitle>
<DialogTitle>{config.appName} themes</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
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.
</p>
<p>
<Link
+8 -9
View File
@@ -1,14 +1,12 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
/** localStorage key for cached curator follow list. */
const CACHE_KEY = 'ditto:curatorFollowList';
import { getStorageKey } from '@/lib/storageKey';
/** Read cached curator follow list from localStorage. */
function getCached(): string[] | undefined {
function getCached(cacheKey: string): string[] | undefined {
try {
const raw = localStorage.getItem(CACHE_KEY);
const raw = localStorage.getItem(cacheKey);
if (!raw) return undefined;
const cached = JSON.parse(raw);
if (!Array.isArray(cached)) return undefined;
@@ -19,9 +17,9 @@ function getCached(): string[] | undefined {
}
/** Persist curator follow list to localStorage. */
function setCached(pubkeys: string[]): void {
function setCached(cacheKey: string, pubkeys: string[]): void {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(pubkeys));
localStorage.setItem(cacheKey, JSON.stringify(pubkeys));
} catch {
// Storage full or unavailable — non-critical
}
@@ -39,6 +37,7 @@ export function useCuratorFollowList() {
const { nostr } = useNostr();
const { config } = useAppContext();
const curatorPubkey = config.curatorPubkey;
const cacheKey = getStorageKey(config.appId, 'curatorFollowList');
return useQuery<string[]>({
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),
});
}
+5 -2
View File
@@ -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;
+4 -3
View File
@@ -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<T extends string = string>(
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<T>(() => {
const defaultTab = (user ? 'follows' : 'ditto') as T;
+10 -9
View File
@@ -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<FollowListData>({
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,
});
}
+8 -7
View File
@@ -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<SyncPhase>(() => {
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;
}
+16 -10
View File
@@ -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);
},
+64 -24
View File
@@ -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;
}
+9 -7
View File
@@ -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));
+16
View File
@@ -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}`;
}
+9 -6
View File
@@ -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 () => {
+9 -9
View File
@@ -45,8 +45,8 @@ export function CSAEPolicyPage() {
</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>
<strong>Our infrastructure:</strong> We operate the <strong>Ditto relay</strong> and{' '}
<strong>Ditto Blossom server</strong>, which serve as the default relay and file host for
<strong>Our infrastructure:</strong> We operate the <strong>{config.appName} relay</strong> and{' '}
<strong>{config.appName} Blossom server</strong>, which serve as the default relay and file host for
{' '}{config.appName}. We have full moderation control over content stored on these services.
</li>
<li>
@@ -61,8 +61,8 @@ export function CSAEPolicyPage() {
</li>
</ul>
<p>
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}.
</p>
@@ -117,11 +117,11 @@ export function CSAEPolicyPage() {
suspected CSAE content for immediate review.
</li>
<li>
<strong>Ditto relay moderation:</strong> On our own Ditto relay, we actively moderate content and
<strong>{config.appName} relay moderation:</strong> On our own {config.appName} relay, we actively moderate content and
will immediately remove any CSAE material and permanently ban associated accounts.
</li>
<li>
<strong>Ditto Blossom server moderation:</strong> On our own Ditto Blossom file server, we will
<strong>{config.appName} Blossom server moderation:</strong> On our own {config.appName} Blossom file server, we will
immediately delete any CSAE media and ban the uploading account.
</li>
<li>
@@ -148,7 +148,7 @@ export function CSAEPolicyPage() {
the app through content filters and blocklists.
</li>
<li>
<strong>Removal from Ditto infrastructure:</strong> CSAE content on the Ditto relay and Ditto
<strong>Removal from {config.appName} infrastructure:</strong> CSAE content on the {config.appName} relay and {config.appName}
Blossom server will be immediately deleted, and the associated accounts permanently banned.
</li>
<li>
@@ -223,7 +223,7 @@ export function CSAEPolicyPage() {
</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>
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.
</li>
<li>
@@ -248,7 +248,7 @@ export function CSAEPolicyPage() {
<ul className="list-disc list-inside space-y-1 ml-2">
<li>
<strong>Full control over our own infrastructure:</strong> 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.
</li>
<li>
+5 -2
View File
@@ -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}
</Button>
) : isOwnProfile ? (
<div className="text-center space-y-3">
@@ -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"
>
<UserPlus className="size-5" />
Follow {pubkeys.length} people on Ditto
Follow {pubkeys.length} people on {config.appName}
</Button>
) : isFollowingAll ? (
<Button disabled className="w-full rounded-full py-3 text-base font-semibold gap-2" size="lg">
+1 -1
View File
@@ -28,7 +28,7 @@ export function HelpPage() {
<div className="px-4 pt-4 pb-1">
<h2 className="text-lg font-bold">Frequently Asked Questions</h2>
<p className="text-sm text-muted-foreground mt-1">
Everything you need to know about Nostr, Ditto, and how it all works.
Everything you need to know about Nostr, {config.appName}, and how it all works.
</p>
</div>
+3 -2
View File
@@ -104,6 +104,7 @@ import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import type { AddrCoords } from '@/hooks/useEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { impactMedium } from '@/lib/haptics';
import { getStorageKey } from '@/lib/storageKey';
import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils';
@@ -1397,7 +1398,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
const profileThemeColors = (showCustomProfileThemes || isOwnProfile) ? profileTheme?.colors : undefined;
// First-time custom theme info modal
const [hasSeenThemeInfo, setHasSeenThemeInfo] = useLocalStorage('ditto:seen-profile-theme-info', false);
const [hasSeenThemeInfo, setHasSeenThemeInfo] = useLocalStorage(getStorageKey(config.appId, 'seen-profile-theme-info'), false);
const [themeInfoOpen, setThemeInfoOpen] = useState(false);
const { updateFeedSettings } = useFeedSettings();
const { updateSettings: encryptedUpdateSettings } = useEncryptedSettings();
@@ -1429,7 +1430,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
setLocalProfileBg(profileTheme.background);
}
}, [editProfileThemeOpen, profileTheme]);
const [dismissedThemeSnapshot, setDismissedThemeSnapshot] = useLocalStorage<string | null>('ditto:dismissed-share-theme-snapshot', null);
const [dismissedThemeSnapshot, setDismissedThemeSnapshot] = useLocalStorage<string | null>(getStorageKey(config.appId, 'dismissed-share-theme-snapshot'), null);
// Temporarily apply the visited user's theme globally while on their profile
const { theme: ownTheme, customTheme: ownCustomTheme, themes: configuredThemes, applyCustomTheme } = useTheme();