Add three-mode stats calculation: NIP-85 Only, Manual Only, and Both

Replace the binary nip85OnlyMode boolean with a statsMode enum that supports three options:
- "nip85-only": Show only pre-computed NIP-85 stats (fastest, may be empty)
- "manual-only": Always calculate stats from relay queries (slower, guaranteed)
- "both": Use NIP-85 when available with manual fallback (recommended default)

Updated components:
- AppContext: Changed nip85OnlyMode to statsMode with StatsMode type
- AppProvider: Updated Zod schema to validate three-mode enum
- useTrending.ts: Implemented logic for all three modes in useEventStats and useBatchEventStats
- AdvancedSettings: Replaced switch with radio group for better UX

Default remains "nip85-only" for optimal performance.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-19 04:50:07 -06:00
parent 5fd4ff4661
commit a3d24927d4
5 changed files with 105 additions and 26 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ const defaultConfig: AppConfig = {
feedIncludePacks: false,
},
nip85StatsPubkey: "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea",
nip85OnlyMode: true,
statsMode: "nip85-only",
};
export function App() {
+61 -18
View File
@@ -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() {
</div>
</div>
<div className="flex items-center justify-between py-2.5 px-3 border-t border-border">
<div className="min-w-0">
<span className="text-sm">NIP-85 Only Mode</span>
<p className="text-xs text-muted-foreground mt-0.5">
Disable manual stat calculation. Stats will only show if NIP-85 pubkey provides them.
<div className="py-3 px-3 border-t border-border space-y-3">
<div>
<Label className="text-sm font-medium">Stats Calculation Mode</Label>
<p className="text-xs text-muted-foreground mt-1 mb-3">
Choose how engagement stats are calculated.
</p>
</div>
<Switch
id="nip85-only-mode"
checked={config.nip85OnlyMode}
onCheckedChange={(checked) => {
updateConfig(() => ({ nip85OnlyMode: checked }));
<RadioGroup
value={config.statsMode}
onValueChange={(value: StatsMode) => {
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"
>
<div className="flex items-start space-x-3">
<RadioGroupItem value="nip85-only" id="nip85-only" disabled={!statsPubkey} />
<div className="grid gap-0.5 leading-none">
<Label
htmlFor="nip85-only"
className={`text-sm font-medium cursor-pointer ${!statsPubkey ? 'opacity-50' : ''}`}
>
NIP-85 Only
</Label>
<p className="text-xs text-muted-foreground">
Show only pre-computed stats. Faster, but may be empty if NIP-85 source is unavailable.
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="manual-only" id="manual-only" />
<div className="grid gap-0.5 leading-none">
<Label htmlFor="manual-only" className="text-sm font-medium cursor-pointer">
Manual Only
</Label>
<p className="text-xs text-muted-foreground">
Always calculate stats from relay queries. Slower, but guaranteed to work.
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="both" id="both" disabled={!statsPubkey} />
<div className="grid gap-0.5 leading-none">
<Label
htmlFor="both"
className={`text-sm font-medium cursor-pointer ${!statsPubkey ? 'opacity-50' : ''}`}
>
Both (Recommended)
</Label>
<p className="text-xs text-muted-foreground">
Use NIP-85 when available, fall back to manual calculation. Best balance of speed and reliability.
</p>
</div>
</div>
</RadioGroup>
</div>
</div>
</CollapsibleContent>
+1 -1
View File
@@ -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<AppConfig>;
export function AppProvider(props: AppProviderProps) {
+5 -2
View File
@@ -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 {
+37 -4
View File
@@ -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<string, EventStats>();
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<string, { commentCount: number; repostCount: number; reactionCount: number; zapCount: number }>();
@@ -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<string, EventStats>();
for (const id of uniqueIds) {
emptyMap.set(id, EMPTY_STATS);