diff --git a/src/App.tsx b/src/App.tsx
index a8b840ca..0cd08fbb 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -54,7 +54,7 @@ const defaultConfig: AppConfig = {
feedIncludePacks: false,
},
nip85StatsPubkey: "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea",
- nip85OnlyMode: true,
+ statsMode: "nip85-only",
};
export function App() {
diff --git a/src/components/AdvancedSettings.tsx b/src/components/AdvancedSettings.tsx
index 4b49e970..bb9a5c03 100644
--- a/src/components/AdvancedSettings.tsx
+++ b/src/components/AdvancedSettings.tsx
@@ -4,12 +4,13 @@ import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { Switch } from '@/components/ui/switch';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { WalletSettings } from '@/components/WalletSettings';
import { RelayListManager } from '@/components/RelayListManager';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
+import type { StatsMode } from '@/contexts/AppContext';
export function AdvancedSettings() {
const { user } = useCurrentUser();
@@ -153,28 +154,70 @@ export function AdvancedSettings() {
-
-
-
NIP-85 Only Mode
-
- Disable manual stat calculation. Stats will only show if NIP-85 pubkey provides them.
+
+
+
+
+ Choose how engagement stats are calculated.
-
{
- updateConfig(() => ({ nip85OnlyMode: checked }));
+ {
+ updateConfig(() => ({ statsMode: value }));
+ const descriptions = {
+ 'nip85-only': 'Only NIP-85 pre-computed stats will be shown.',
+ 'manual-only': 'Stats will be calculated manually from relay queries.',
+ 'both': 'NIP-85 stats with manual fallback when unavailable.',
+ };
toast({
- title: checked ? 'NIP-85 only mode enabled' : 'NIP-85 only mode disabled',
- description: checked
- ? 'Manual stat calculation is disabled.'
- : 'Manual stat calculation will be used as fallback.',
+ title: 'Stats mode updated',
+ description: descriptions[value],
});
}}
- disabled={!statsPubkey}
- className="scale-90"
- />
+ disabled={!statsPubkey && config.statsMode !== 'manual-only'}
+ className="gap-3"
+ >
+
+
+
+
+
+ Show only pre-computed stats. Faster, but may be empty if NIP-85 source is unavailable.
+
+
+
+
+
+
+
+
+ Always calculate stats from relay queries. Slower, but guaranteed to work.
+
+
+
+
+
+
+
+
+ Use NIP-85 when available, fall back to manual calculation. Best balance of speed and reliability.
+
+
+
+
diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx
index 9ed3a2e3..42a6998a 100644
--- a/src/components/AppProvider.tsx
+++ b/src/components/AppProvider.tsx
@@ -48,7 +48,7 @@ const AppConfigSchema = z.object({
(val) => val.length === 0 || (val.length === 64 && /^[0-9a-f]{64}$/i.test(val)),
{ message: 'Must be empty or a valid 64-character hex pubkey' }
),
- nip85OnlyMode: z.boolean(),
+ statsMode: z.enum(['nip85-only', 'manual-only', 'both']),
}) satisfies z.ZodType
;
export function AppProvider(props: AppProviderProps) {
diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts
index 21e4c694..8e7cd35a 100644
--- a/src/contexts/AppContext.ts
+++ b/src/contexts/AppContext.ts
@@ -39,6 +39,9 @@ export interface FeedSettings {
feedIncludePacks: boolean;
}
+/** Stats calculation mode: NIP-85 only, manual only, or both */
+export type StatsMode = "nip85-only" | "manual-only" | "both";
+
export interface AppConfig {
/** Current theme */
theme: Theme;
@@ -50,8 +53,8 @@ export interface AppConfig {
feedSettings: FeedSettings;
/** NIP-85 stats pubkey source (hex format) */
nip85StatsPubkey: string;
- /** Whether to disable manual stat calculation fallback (NIP-85 only mode) */
- nip85OnlyMode: boolean;
+ /** Stats calculation mode: NIP-85 only, manual only, or both */
+ statsMode: StatsMode;
}
export interface AppContextType {
diff --git a/src/hooks/useTrending.ts b/src/hooks/useTrending.ts
index 26b65b8b..e71993b6 100644
--- a/src/hooks/useTrending.ts
+++ b/src/hooks/useTrending.ts
@@ -259,13 +259,26 @@ export function useEventStats(eventId: string | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
- const nip85OnlyMode = config.nip85OnlyMode;
+ const statsMode = config.statsMode;
return useQuery({
queryKey: ['event-stats', eventId ?? ''],
queryFn: async ({ signal }) => {
if (!eventId) return EMPTY_STATS;
+ // Manual-only mode: skip NIP-85 entirely
+ if (statsMode === 'manual-only') {
+ const combined = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
+ const events = await nostr.query(
+ [
+ { kinds: [1, 6, 7, 9735], '#e': [eventId], limit: 50 },
+ { kinds: [1], '#q': [eventId], limit: 20 },
+ ],
+ { signal: combined },
+ );
+ return computeStats(eventId, events);
+ }
+
// Try NIP-85 first with aggressive timeout (500ms)
let nip85Stats: NostrEvent[] = [];
if (statsPubkey) {
@@ -287,7 +300,7 @@ export function useEventStats(eventId: string | undefined) {
const hasNip85 = nip85Stats.length > 0;
// If NIP-85 only mode is enabled and we don't have NIP-85 stats, return empty
- if (nip85OnlyMode && !hasNip85) {
+ if (statsMode === 'nip85-only' && !hasNip85) {
return EMPTY_STATS;
}
@@ -426,7 +439,7 @@ export function useBatchEventStats(eventIds: string[], enabled = true) {
const queryClient = useQueryClient();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
- const nip85OnlyMode = config.nip85OnlyMode;
+ const statsMode = config.statsMode;
const uniqueIds = [...new Set(eventIds)].sort();
@@ -435,6 +448,26 @@ export function useBatchEventStats(eventIds: string[], enabled = true) {
queryFn: async ({ signal }) => {
if (uniqueIds.length === 0) return new Map();
+ // Manual-only mode: skip NIP-85 entirely
+ if (statsMode === 'manual-only') {
+ const combined = AbortSignal.any([signal, AbortSignal.timeout(6000)]);
+ const events = await nostr.query(
+ [
+ { kinds: [1, 6, 7, 9735], '#e': uniqueIds, limit: uniqueIds.length * 10 },
+ { kinds: [1], '#q': uniqueIds, limit: uniqueIds.length * 3 },
+ ],
+ { signal: combined },
+ );
+
+ const statsMap = new Map();
+ for (const id of uniqueIds) {
+ const stats = computeStats(id, events);
+ statsMap.set(id, stats);
+ queryClient.setQueryData(['event-stats', id], stats);
+ }
+ return statsMap;
+ }
+
// Try NIP-85 first with aggressive timeout (500ms)
const nip85StatsMap = new Map();
@@ -474,7 +507,7 @@ export function useBatchEventStats(eventIds: string[], enabled = true) {
const hasAnyNip85Stats = nip85StatsMap.size > 0;
// If NIP-85 only mode is enabled and we have no stats, return empty map
- if (nip85OnlyMode && !hasAnyNip85Stats) {
+ if (statsMode === 'nip85-only' && !hasAnyNip85Stats) {
const emptyMap = new Map();
for (const id of uniqueIds) {
emptyMap.set(id, EMPTY_STATS);