mkstack upgrade

This commit is contained in:
2025-11-10 02:23:29 +01:00
parent 86a93d056c
commit 2c6ffcffdc
28 changed files with 3569 additions and 189 deletions

View File

@@ -6,12 +6,15 @@ import { createHead, UnheadProvider } from '@unhead/react/client';
import { InferSeoMetaPlugin } from '@unhead/addons';
import { Suspense } from 'react';
import NostrProvider from '@/components/NostrProvider';
import { NostrSync } from '@/components/NostrSync';
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { NostrLoginProvider } from '@nostrify/react/login';
import { AppProvider } from '@/components/AppProvider';
import { NWCProvider } from '@/contexts/NWCContext';
import { DMProvider, type DMConfig } from '@/components/DMProvider';
import { AppConfig } from '@/contexts/AppContext';
import { PROTOCOL_MODE } from '@/lib/dmConstants';
import AppRouter from './AppRouter';
const head = createHead({
@@ -32,30 +35,44 @@ const queryClient = new QueryClient({
const defaultConfig: AppConfig = {
theme: "light",
relayUrl: "wss://relay.nostr.band",
relayMetadata: {
relays: [
{ url: 'wss://relay.ditto.pub', read: true, write: true },
{ url: 'wss://relay.nostr.band', read: true, write: true },
{ url: 'wss://relay.damus.io', read: true, write: true },
],
updatedAt: 0,
},
};
const presetRelays = [
{ url: 'wss://ditto.pub/relay', name: 'Ditto' },
{ url: 'wss://relay.nostr.band', name: 'Nostr.Band' },
{ url: 'wss://relay.damus.io', name: 'Damus' },
{ url: 'wss://relay.primal.net', name: 'Primal' },
];
const dmConfig: DMConfig = {
// Enable or disable DMs entirely
enabled: true, // Set to false to completely disable messaging functionality
// Choose one protocol mode:
// PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only
// PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only
// PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17)
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
};
export function App() {
return (
<UnheadProvider head={head}>
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig} presetRelays={presetRelays}>
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<NostrSync />
<NWCProvider>
<TooltipProvider>
<Toaster />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
<DMProvider config={dmConfig}>
<TooltipProvider>
<Toaster />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
</DMProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>

View File

@@ -1,7 +1,7 @@
import { ReactNode, useEffect } from 'react';
import { z } from 'zod';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext';
import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext';
interface AppProviderProps {
children: ReactNode;
@@ -9,47 +9,54 @@ interface AppProviderProps {
storageKey: string;
/** Default app configuration */
defaultConfig: AppConfig;
/** Optional list of preset relays to display in the RelaySelector */
presetRelays?: { name: string; url: string }[];
}
// Zod schema for RelayMetadata validation
const RelayMetadataSchema = z.object({
relays: z.array(z.object({
url: z.string().url(),
read: z.boolean(),
write: z.boolean(),
})),
updatedAt: z.number(),
}) satisfies z.ZodType<RelayMetadata>;
// Zod schema for AppConfig validation
const AppConfigSchema: z.ZodType<AppConfig, z.ZodTypeDef, unknown> = z.object({
const AppConfigSchema = z.object({
theme: z.enum(['dark', 'light', 'system']),
relayUrl: z.string().url(),
blogOwnerPubkey: z.string().length(64).optional(), // deprecated, optional for backward compatibility
});
relayMetadata: RelayMetadataSchema,
}) satisfies z.ZodType<AppConfig>;
export function AppProvider(props: AppProviderProps) {
const {
children,
storageKey,
defaultConfig,
presetRelays,
} = props;
// App configuration state with localStorage persistence
const [config, setConfig] = useLocalStorage<AppConfig>(
const [rawConfig, setConfig] = useLocalStorage<Partial<AppConfig>>(
storageKey,
defaultConfig,
{},
{
serialize: JSON.stringify,
deserialize: (value: string) => {
const parsed = JSON.parse(value);
return AppConfigSchema.parse(parsed);
return AppConfigSchema.partial().parse(parsed);
}
}
);
// Generic config updater with callback pattern
const updateConfig = (updater: (currentConfig: AppConfig) => AppConfig) => {
const updateConfig = (updater: (currentConfig: Partial<AppConfig>) => Partial<AppConfig>) => {
setConfig(updater);
};
const config = { ...defaultConfig, ...rawConfig };
const appContextValue: AppContextType = {
config,
updateConfig,
presetRelays,
};
// Apply theme effects to document
@@ -89,11 +96,11 @@ function useApplyTheme(theme: Theme) {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
root.classList.add(systemTheme);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { NostrEvent, NPool, NRelay1 } from '@nostrify/nostrify';
import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify';
import { NostrContext } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
@@ -10,7 +10,7 @@ interface NostrProviderProps {
const NostrProvider: React.FC<NostrProviderProps> = (props) => {
const { children } = props;
const { config, presetRelays } = useAppContext();
const { config } = useAppContext();
const queryClient = useQueryClient();
@@ -18,13 +18,13 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
const pool = useRef<NPool | undefined>(undefined);
// Use refs so the pool always has the latest data
const relayUrl = useRef<string>(config.relayUrl);
const relayMetadata = useRef(config.relayMetadata);
// Update refs when config changes
// Invalidate Nostr queries when relay metadata changes
useEffect(() => {
relayUrl.current = config.relayUrl;
queryClient.resetQueries();
}, [config.relayUrl, queryClient]);
relayMetadata.current = config.relayMetadata;
queryClient.invalidateQueries({ queryKey: ['nostr'] });
}, [config.relayMetadata, queryClient]);
// Initialize NPool only once
if (!pool.current) {
@@ -32,21 +32,27 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
open(url: string) {
return new NRelay1(url);
},
reqRouter(filters) {
return new Map([[relayUrl.current, filters]]);
reqRouter(filters: NostrFilter[]) {
const routes = new Map<string, NostrFilter[]>();
// Route to all read relays
const readRelays = relayMetadata.current.relays
.filter(r => r.read)
.map(r => r.url);
for (const url of readRelays) {
routes.set(url, filters);
}
return routes;
},
eventRouter(_event: NostrEvent) {
// Publish to the selected relay
const allRelays = new Set<string>([relayUrl.current]);
// Get write relays from metadata
const writeRelays = relayMetadata.current.relays
.filter(r => r.write)
.map(r => r.url);
// Also publish to the preset relays, capped to 5
for (const { url } of (presetRelays ?? [])) {
allRelays.add(url);
if (allRelays.size >= 5) {
break;
}
}
const allRelays = new Set<string>(writeRelays);
return [...allRelays];
},

View File

@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
/**
* NostrSync - Syncs user's Nostr data
*
* This component runs globally to sync various Nostr data when the user logs in.
* Currently syncs:
* - NIP-65 relay list (kind 10002)
*/
export function NostrSync() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config, updateConfig } = useAppContext();
useEffect(() => {
if (!user) return;
const syncRelaysFromNostr = async () => {
try {
const events = await nostr.query(
[{ kinds: [10002], authors: [user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(5000) }
);
if (events.length > 0) {
const event = events[0];
// Only update if the event is newer than our stored data
if (event.created_at > config.relayMetadata.updatedAt) {
const fetchedRelays = event.tags
.filter(([name]) => name === 'r')
.map(([_, url, marker]) => ({
url,
read: !marker || marker === 'read',
write: !marker || marker === 'write',
}));
if (fetchedRelays.length > 0) {
console.log('Syncing relay list from Nostr:', fetchedRelays);
updateConfig((current) => ({
...current,
relayMetadata: {
relays: fetchedRelays,
updatedAt: event.created_at,
},
}));
}
}
}
} catch (error) {
console.error('Failed to sync relays from Nostr:', error);
}
};
syncRelaysFromNostr();
}, [user, config.relayMetadata.updatedAt, nostr, updateConfig]);
return null;
}

View File

@@ -0,0 +1,286 @@
import { useState, useEffect } from 'react';
import { Plus, X, Wifi, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
interface Relay {
url: string;
read: boolean;
write: boolean;
}
export function RelayListManager() {
const { config, updateConfig } = useAppContext();
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const { toast } = useToast();
const [relays, setRelays] = useState<Relay[]>(config.relayMetadata.relays);
const [newRelayUrl, setNewRelayUrl] = useState('');
// Sync local state with config when it changes (e.g., from NostrProvider sync)
useEffect(() => {
setRelays(config.relayMetadata.relays);
}, [config.relayMetadata.relays]);
const normalizeRelayUrl = (url: string): string => {
url = url.trim();
try {
return new URL(url).toString();
} catch {
try {
return new URL(`wss://${url}`).toString();
} catch {
return url;
}
}
};
const isValidRelayUrl = (url: string): boolean => {
const trimmed = url.trim();
if (!trimmed) return false;
const normalized = normalizeRelayUrl(trimmed);
try {
new URL(normalized);
return true;
} catch {
return false;
}
};
const handleAddRelay = () => {
if (!isValidRelayUrl(newRelayUrl)) {
toast({
title: 'Invalid relay URL',
description: 'Please enter a valid relay URL (e.g., wss://relay.example.com)',
variant: 'destructive',
});
return;
}
const normalized = normalizeRelayUrl(newRelayUrl);
if (relays.some(r => r.url === normalized)) {
toast({
title: 'Relay already exists',
description: 'This relay is already in your list.',
variant: 'destructive',
});
return;
}
const newRelays = [...relays, { url: normalized, read: true, write: true }];
setRelays(newRelays);
setNewRelayUrl('');
saveRelays(newRelays);
};
const handleRemoveRelay = (url: string) => {
const newRelays = relays.filter(r => r.url !== url);
setRelays(newRelays);
saveRelays(newRelays);
};
const handleToggleRead = (url: string) => {
const newRelays = relays.map(r =>
r.url === url ? { ...r, read: !r.read } : r
);
setRelays(newRelays);
saveRelays(newRelays);
};
const handleToggleWrite = (url: string) => {
const newRelays = relays.map(r =>
r.url === url ? { ...r, write: !r.write } : r
);
setRelays(newRelays);
saveRelays(newRelays);
};
const saveRelays = (newRelays: Relay[]) => {
const now = Math.floor(Date.now() / 1000);
// Update local config
updateConfig((current) => ({
...current,
relayMetadata: {
relays: newRelays,
updatedAt: now,
},
}));
// Publish to Nostr if user is logged in
if (user) {
publishNIP65RelayList(newRelays);
}
};
const publishNIP65RelayList = (relayList: Relay[]) => {
const tags = relayList.map(relay => {
if (relay.read && relay.write) {
return ['r', relay.url];
} else if (relay.read) {
return ['r', relay.url, 'read'];
} else if (relay.write) {
return ['r', relay.url, 'write'];
}
// If neither read nor write, don't include (shouldn't happen)
return null;
}).filter((tag): tag is string[] => tag !== null);
publishEvent(
{
kind: 10002,
content: '',
tags,
},
{
onSuccess: () => {
toast({
title: 'Relay list published',
description: 'Your relay list has been published to Nostr.',
});
},
onError: (error) => {
console.error('Failed to publish relay list:', error);
toast({
title: 'Failed to publish relay list',
description: 'There was an error publishing your relay list to Nostr.',
variant: 'destructive',
});
},
}
);
};
const renderRelayUrl = (url: string): string => {
try {
const parsed = new URL(url);
if (parsed.protocol === 'wss:') {
if (parsed.pathname === '/') {
return parsed.host;
} else {
return parsed.host + parsed.pathname;
}
} else {
return parsed.href;
}
} catch {
return url;
}
}
return (
<div className="space-y-4">
{/* Relay List */}
<div className="space-y-2">
{relays.map((relay) => (
<div
key={relay.url}
className="flex items-center gap-3 p-3 rounded-md border bg-muted/20"
>
<Wifi className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-mono text-sm flex-1 truncate" title={relay.url}>
{renderRelayUrl(relay.url)}
</span>
{/* Settings Popover */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-5 text-muted-foreground hover:text-foreground shrink-0"
>
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48" align="end">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor={`read-${relay.url}`} className="text-sm cursor-pointer">
Read
</Label>
<Switch
id={`read-${relay.url}`}
checked={relay.read}
onCheckedChange={() => handleToggleRead(relay.url)}
className="data-[state=checked]:bg-green-500 scale-75"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor={`write-${relay.url}`} className="text-sm cursor-pointer">
Write
</Label>
<Switch
id={`write-${relay.url}`}
checked={relay.write}
onCheckedChange={() => handleToggleWrite(relay.url)}
className="data-[state=checked]:bg-blue-500 scale-75"
/>
</div>
</div>
</PopoverContent>
</Popover>
{/* Remove Button */}
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveRelay(relay.url)}
className="size-5 text-muted-foreground hover:text-destructive hover:bg-transparent shrink-0"
disabled={relays.length <= 1}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
{/* Add Relay Form */}
<div className="flex gap-2">
<div className="flex-1">
<Label htmlFor="new-relay-url" className="sr-only">
Relay URL
</Label>
<Input
id="new-relay-url"
placeholder="Enter relay URL (e.g., wss://relay.example.com)"
value={newRelayUrl}
onChange={(e) => setNewRelayUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddRelay();
}
}}
/>
</div>
<Button
onClick={handleAddRelay}
disabled={!newRelayUrl.trim()}
variant="outline"
size="sm"
className="h-10 shrink-0"
>
<Plus className="h-4 w-4 mr-2" />
Add Relay
</Button>
</div>
{!user && (
<p className="text-xs text-muted-foreground">
Log in to sync your relay list with Nostr
</p>
)}
</div>
);
}

View File

@@ -29,6 +29,7 @@ import { useWallet } from '@/hooks/useWallet';
import { useToast } from '@/hooks/useToast';
import { useIsMobile } from '@/hooks/useIsMobile';
import type { NWCConnection, NWCInfo } from '@/hooks/useNWC';
import type { WebLNProvider } from "@webbtc/webln-types";
interface WalletModalProps {
children?: React.ReactNode;
@@ -68,8 +69,7 @@ AddWalletContent.displayName = 'AddWalletContent';
// Extracted WalletContent to prevent re-renders
const WalletContent = forwardRef<HTMLDivElement, {
hasWebLN: boolean;
isDetecting: boolean;
webln: WebLNProvider | null;
hasNWC: boolean;
connections: NWCConnection[];
connectionInfo: Record<string, NWCInfo>;
@@ -78,8 +78,7 @@ const WalletContent = forwardRef<HTMLDivElement, {
handleRemoveConnection: (cs: string) => void;
setAddDialogOpen: (open: boolean) => void;
}>(({
hasWebLN,
isDetecting,
webln,
hasNWC,
connections,
connectionInfo,
@@ -103,9 +102,9 @@ const WalletContent = forwardRef<HTMLDivElement, {
</div>
</div>
<div className="flex items-center gap-2">
{hasWebLN && <CheckCircle className="h-4 w-4 text-green-600" />}
<Badge variant={hasWebLN ? "default" : "secondary"} className="text-xs">
{isDetecting ? "..." : hasWebLN ? "Ready" : "Not Found"}
{webln && <CheckCircle className="h-4 w-4 text-green-600" />}
<Badge variant={webln ? "default" : "secondary"} className="text-xs">
{webln ? "Ready" : "Not Found"}
</Badge>
</div>
</div>
@@ -191,7 +190,7 @@ const WalletContent = forwardRef<HTMLDivElement, {
)}
</div>
{/* Help */}
{!hasWebLN && connections.length === 0 && (
{!webln && connections.length === 0 && (
<>
<Separator />
<div className="text-center py-4 space-y-2">
@@ -222,7 +221,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
setActiveConnection
} = useNWC();
const { hasWebLN, isDetecting } = useWallet();
const { webln } = useWallet();
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
const { toast } = useToast();
@@ -263,8 +262,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
};
const walletContentProps = {
hasWebLN,
isDetecting,
webln,
hasNWC,
connections,
connectionInfo,

View File

@@ -5,13 +5,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { Zap } from 'lucide-react';
import type { Event } from 'nostr-tools';
import { Button } from './ui/button';
interface ZapButtonProps {
target: Event;
className?: string;
showCount?: boolean;
buttonVariant?: 'default' | 'outline' | 'ghost' | 'link' | 'destructive';
zapData?: { count: number; totalSats: number; isLoading?: boolean };
}
@@ -19,7 +17,6 @@ export function ZapButton({
target,
className = "text-xs ml-1",
showCount = true,
buttonVariant = "outline",
zapData: externalZapData
}: ZapButtonProps) {
const { user } = useCurrentUser();
@@ -44,18 +41,18 @@ export function ZapButton({
return (
<ZapDialog target={target}>
<Button variant={buttonVariant} className={`flex items-center gap-1 ${className}`}>
<Zap className="h-4 w-4" />
<span className="text-xs">
{showLoading ? (
'...'
) : showCount && totalSats > 0 ? (
`${totalSats.toLocaleString()}`
) : (
'Zap'
)}
</span>
</Button>
<div className={`flex items-center gap-1 ${className}`}>
<Zap className="h-4 w-4" />
<span className="text-xs">
{showLoading ? (
'...'
) : showCount && totalSats > 0 ? (
`${totalSats.toLocaleString()}`
) : (
'Zap'
)}
</span>
</div>
</ZapDialog>
);
}

View File

@@ -33,6 +33,7 @@ import { useWallet } from '@/hooks/useWallet';
import { useIsMobile } from '@/hooks/useIsMobile';
import type { Event } from 'nostr-tools';
import QRCode from 'qrcode';
import type { WebLNProvider } from "@webbtc/webln-types";
interface ZapDialogProps {
target: Event;
@@ -55,7 +56,7 @@ interface ZapContentProps {
isZapping: boolean;
qrCodeUrl: string;
copied: boolean;
hasWebLN: boolean;
webln: WebLNProvider | null;
handleZap: () => void;
handleCopy: () => void;
openInWallet: () => void;
@@ -73,7 +74,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
isZapping,
qrCodeUrl,
copied,
hasWebLN,
webln,
handleZap,
handleCopy,
openInWallet,
@@ -138,7 +139,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
{/* Payment buttons */}
<div className="space-y-3 mt-4">
{hasWebLN && (
{webln && (
<Button
onClick={() => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
@@ -234,13 +235,12 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
));
ZapContent.displayName = 'ZapContent';
export function ZapDialog({ target, children, className }: ZapDialogProps) {
const [open, setOpen] = useState(false);
const { user } = useCurrentUser();
const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast();
const { webln, activeNWC, hasWebLN, detectWebLN } = useWallet();
const { webln, activeNWC } = useWallet();
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
const [amount, setAmount] = useState<number | string>(100);
const [comment, setComment] = useState<string>('');
@@ -251,17 +251,10 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
useEffect(() => {
if (target) {
setComment('Zapped with zelo.news!');
setComment('Zapped with MKStack!');
}
}, [target]);
// Detect WebLN when dialog opens
useEffect(() => {
if (open && !hasWebLN) {
detectWebLN();
}
}, [open, hasWebLN, detectWebLN]);
// Generate QR code
useEffect(() => {
let isCancelled = false;
@@ -345,7 +338,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
isZapping,
qrCodeUrl,
copied,
hasWebLN,
webln,
handleZap,
handleCopy,
openInWallet,

View File

@@ -10,12 +10,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
import { RelaySelector } from '@/components/RelaySelector';
import { WalletModal } from '@/components/WalletModal';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { genUserName } from '@/lib/genUserName';
import { useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
interface AccountSwitcherProps {
onAddAccountClick: () => void;
@@ -23,7 +20,6 @@ interface AccountSwitcherProps {
export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
const { currentUser, otherUsers, setLogin, removeLogin } = useLoggedInAccounts();
const navigate = useNavigate();
if (!currentUser) return null;
@@ -46,23 +42,6 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56 p-2 animate-scale-in'>
{/* Profile quick link */}
<DropdownMenuItem
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
onSelect={() => navigate(`/${nip19.npubEncode(currentUser.pubkey)}`)}
>
<Avatar className='w-8 h-8'>
<AvatarImage src={currentUser.metadata.picture} alt={getDisplayName(currentUser)} />
<AvatarFallback>{getDisplayName(currentUser).charAt(0)}</AvatarFallback>
</Avatar>
<div className='flex-1 truncate'>
<p className='text-sm font-medium truncate'>{getDisplayName(currentUser)}</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className='font-medium text-sm px-2 py-1.5'>Switch Relay</div>
<RelaySelector className="w-full" />
<DropdownMenuSeparator />
<div className='font-medium text-sm px-2 py-1.5'>Switch Account</div>
{otherUsers.map((user) => (
<DropdownMenuItem

View File

@@ -14,7 +14,6 @@ import { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu
import { MessageSquare, ChevronDown, ChevronRight, MoreHorizontal } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { genUserName } from '@/lib/genUserName';
import { ZapButton } from '@/components/ZapButton';
interface CommentProps {
root: NostrEvent | URL;
@@ -84,13 +83,6 @@ export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }: Comme
<MessageSquare className="h-3 w-3 mr-1" />
Reply
</Button>
{/* Zap Button */}
<ZapButton
target={comment as NostrEvent}
buttonVariant="ghost"
className="h-8 px-2 text-xs"
/>
{hasReplies && (
<Collapsible open={showReplies} onOpenChange={setShowReplies}>

View File

@@ -0,0 +1,409 @@
import { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useConversationMessages } from '@/hooks/useConversationMessages';
import { useDMContext } from '@/hooks/useDMContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { MESSAGE_PROTOCOL, PROTOCOL_MODE, type MessageProtocol } from '@/lib/dmConstants';
import { formatConversationTime, formatFullDateTime } from '@/lib/dmUtils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowLeft, Send, Loader2, AlertTriangle, Key, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { NoteContent } from '@/components/NoteContent';
import type { NostrEvent } from '@nostrify/nostrify';
interface DMChatAreaProps {
pubkey: string | null;
onBack?: () => void;
className?: string;
}
const MessageBubble = memo(({
message,
isFromCurrentUser
}: {
message: {
id: string;
pubkey: string;
kind: number;
tags: string[][];
decryptedContent?: string;
decryptedEvent?: NostrEvent;
error?: string;
created_at: number;
isSending?: boolean;
};
isFromCurrentUser: boolean;
}) => {
// For NIP-17, use inner message kind (14/15); for NIP-04, use message kind (4)
const actualKind = message.decryptedEvent?.kind || message.kind;
const isNIP4Message = message.kind === 4;
const isFileAttachment = actualKind === 15; // Kind 15 = files/attachments
// Create a NostrEvent object for NoteContent (only used for kind 15)
// For NIP-17 file attachments, use the decryptedEvent which has the actual tags
const messageEvent: NostrEvent = message.decryptedEvent || {
id: message.id,
pubkey: message.pubkey,
created_at: message.created_at,
kind: message.kind,
tags: message.tags,
content: message.decryptedContent || '',
sig: '', // Not needed for display
};
return (
<div className={cn("flex mb-4", isFromCurrentUser ? "justify-end" : "justify-start")}>
<div className={cn(
"max-w-[70%] rounded-lg px-4 py-2",
isFromCurrentUser
? "bg-primary text-primary-foreground"
: "bg-muted"
)}>
{message.error ? (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<p className="text-sm italic opacity-70 cursor-help">🔒 Failed to decrypt</p>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{message.error}</p>
</TooltipContent>
</Tooltip>
) : isFileAttachment ? (
// Kind 15: Use NoteContent to render files/media with imeta tags
<div className="text-sm">
<NoteContent event={messageEvent} className="whitespace-pre-wrap break-words" />
</div>
) : (
// Kind 4 (NIP-04) and Kind 14 (NIP-17 text): Display plain text
<p className="text-sm whitespace-pre-wrap break-words">
{message.decryptedContent}
</p>
)}
<div className="flex items-center gap-2 mt-1">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className={cn(
"text-xs opacity-70 cursor-default",
isFromCurrentUser ? "text-primary-foreground" : "text-muted-foreground"
)}>
{formatConversationTime(message.created_at)}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{formatFullDateTime(message.created_at)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className={cn(
"flex-shrink-0 opacity-50",
isFromCurrentUser ? "text-primary-foreground" : "text-muted-foreground"
)}>
{message.kind === 4 ? (
<Key className="h-3 w-3" />
) : (
<ShieldCheck className="h-3 w-3" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{message.kind === 4 && "NIP-04 Kind 4 (Legacy DM)"}
{message.kind === 14 && "NIP-17 Kind 14 (Private Message)"}
{message.kind === 15 && "NIP-17 Kind 15 (Media)"}
{message.kind !== 4 && message.kind !== 14 && message.kind !== 15 && `Kind ${message.kind}`}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isNIP4Message && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0">
<AlertTriangle className="h-3 w-3 text-yellow-600 dark:text-yellow-500" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Uses outdated NIP-04 encryption</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{message.isSending && (
<Loader2 className="h-3 w-3 animate-spin opacity-70" />
)}
</div>
</div>
</div>
);
});
MessageBubble.displayName = 'MessageBubble';
const ChatHeader = ({ pubkey, onBack }: { pubkey: string; onBack?: () => void }) => {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(pubkey);
const avatarUrl = metadata?.picture;
const initials = displayName.slice(0, 2).toUpperCase();
return (
<div className="p-4 border-b flex items-center gap-3">
{onBack && (
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="md:hidden"
>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<Avatar className="h-10 w-10">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h2 className="font-semibold truncate">{displayName}</h2>
{metadata?.nip05 && (
<p className="text-xs text-muted-foreground truncate">{metadata.nip05}</p>
)}
</div>
</div>
);
};
const EmptyState = ({ isLoading }: { isLoading: boolean }) => {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="text-center text-muted-foreground max-w-sm">
{isLoading ? (
<>
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-sm">Loading conversations...</p>
<p className="text-xs mt-2">
Fetching encrypted messages from relays
</p>
</>
) : (
<>
<p className="text-sm">Select a conversation to start messaging</p>
<p className="text-xs mt-2">
Your messages are encrypted and stored locally
</p>
</>
)}
</div>
</div>
);
};
export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => {
const { user } = useCurrentUser();
const { sendMessage, protocolMode, isLoading } = useDMContext();
const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(pubkey || '');
const [messageText, setMessageText] = useState('');
const [isSending, setIsSending] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Determine default protocol based on mode
const getDefaultProtocol = () => {
if (protocolMode === PROTOCOL_MODE.NIP04_ONLY) return MESSAGE_PROTOCOL.NIP04;
if (protocolMode === PROTOCOL_MODE.NIP17_ONLY) return MESSAGE_PROTOCOL.NIP17;
if (protocolMode === PROTOCOL_MODE.NIP04_OR_NIP17) return MESSAGE_PROTOCOL.NIP17;
// Fallback to NIP-17 for any unexpected mode
return MESSAGE_PROTOCOL.NIP17;
};
const [selectedProtocol, setSelectedProtocol] = useState<MessageProtocol>(getDefaultProtocol());
const scrollAreaRef = useRef<HTMLDivElement>(null);
// Determine if selection is allowed
const allowSelection = protocolMode === PROTOCOL_MODE.NIP04_OR_NIP17;
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (scrollAreaRef.current) {
const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
}, [messages.length]);
const handleSend = useCallback(async () => {
if (!messageText.trim() || !pubkey || !user) return;
setIsSending(true);
try {
await sendMessage({
recipientPubkey: pubkey,
content: messageText.trim(),
protocol: selectedProtocol,
});
setMessageText('');
} catch (error) {
console.error('Failed to send message:', error);
} finally {
setIsSending(false);
}
}, [messageText, pubkey, user, sendMessage, selectedProtocol]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
const handleLoadMore = useCallback(async () => {
if (!scrollAreaRef.current || isLoadingMore) return;
const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (!scrollContainer) return;
// Store current scroll position and height
const previousScrollHeight = scrollContainer.scrollHeight;
const previousScrollTop = scrollContainer.scrollTop;
setIsLoadingMore(true);
// Load more messages
loadEarlierMessages();
// Wait for DOM to update, then restore relative scroll position
setTimeout(() => {
if (scrollContainer) {
const newScrollHeight = scrollContainer.scrollHeight;
const heightDifference = newScrollHeight - previousScrollHeight;
scrollContainer.scrollTop = previousScrollTop + heightDifference;
}
setIsLoadingMore(false);
}, 0);
}, [loadEarlierMessages, isLoadingMore]);
if (!pubkey) {
return (
<Card className={cn("h-full", className)}>
<EmptyState isLoading={isLoading} />
</Card>
);
}
if (!user) {
return (
<Card className={cn("h-full flex items-center justify-center", className)}>
<div className="text-center text-muted-foreground">
<p className="text-sm">Please log in to view messages</p>
</div>
</Card>
);
}
return (
<Card className={cn("h-full flex flex-col", className)}>
<ChatHeader pubkey={pubkey} onBack={onBack} />
<ScrollArea ref={scrollAreaRef} className="flex-1 p-4">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<p className="text-sm">No messages yet</p>
<p className="text-xs mt-1">Send a message to start the conversation</p>
</div>
</div>
) : (
<div>
{hasMoreMessages && (
<div className="flex justify-center mb-4">
<Button
variant="outline"
size="sm"
onClick={handleLoadMore}
disabled={isLoadingMore}
className="text-xs"
>
{isLoadingMore ? (
<>
<Loader2 className="h-3 w-3 animate-spin mr-2" />
Loading...
</>
) : (
'Load Earlier Messages'
)}
</Button>
</div>
)}
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isFromCurrentUser={message.pubkey === user.pubkey}
/>
))}
</div>
)}
</ScrollArea>
<div className="p-4 border-t">
<div className="flex gap-2">
<Textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
className="min-h-[80px] resize-none"
disabled={isSending}
/>
<div className="flex flex-col gap-2">
<Button
onClick={handleSend}
disabled={!messageText.trim() || isSending}
size="icon"
className="h-[44px] w-[90px]"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Send className="h-5 w-5" />
)}
</Button>
<Select
value={selectedProtocol}
onValueChange={(value) => setSelectedProtocol(value as MessageProtocol)}
disabled={!allowSelection}
>
<SelectTrigger className="h-[32px] w-[90px] text-xs px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MESSAGE_PROTOCOL.NIP17} className="text-xs">
NIP-17
</SelectItem>
<SelectItem value={MESSAGE_PROTOCOL.NIP04} className="text-xs">
NIP-04
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</Card>
);
};

View File

@@ -0,0 +1,266 @@
import { useMemo, useState, memo } from 'react';
import { AlertTriangle, Info, Loader2 } from 'lucide-react';
import { useDMContext } from '@/hooks/useDMContext';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { formatConversationTime, formatFullDateTime } from '@/lib/dmUtils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { LOADING_PHASES } from '@/lib/dmConstants';
interface DMConversationListProps {
selectedPubkey: string | null;
onSelectConversation: (pubkey: string) => void;
className?: string;
onStatusClick?: () => void;
}
interface ConversationItemProps {
pubkey: string;
isSelected: boolean;
onClick: () => void;
lastMessage: { decryptedContent?: string; error?: string } | null;
lastActivity: number;
hasNIP4Messages: boolean;
}
const ConversationItemComponent = ({
pubkey,
isSelected,
onClick,
lastMessage,
lastActivity,
hasNIP4Messages
}: ConversationItemProps) => {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(pubkey);
const avatarUrl = metadata?.picture;
const initials = displayName.slice(0, 2).toUpperCase();
const lastMessagePreview = lastMessage?.error
? '🔒 Encrypted message'
: lastMessage?.decryptedContent || 'No messages yet';
// Show skeleton only for name/avatar while loading (we already have message data)
const isLoadingProfile = author.isLoading && !metadata;
return (
<button
onClick={onClick}
className={cn(
"w-full text-left p-3 rounded-lg transition-colors hover:bg-accent block overflow-hidden",
isSelected && "bg-accent"
)}
>
<div className="flex items-start gap-3 max-w-full">
{isLoadingProfile ? (
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
) : (
<Avatar className="h-10 w-10 flex-shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{isLoadingProfile ? (
<Skeleton className="h-[1.25rem] w-24" />
) : (
<span className="font-medium text-sm truncate">{displayName}</span>
)}
{hasNIP4Messages && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0">
<AlertTriangle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-500" />
</div>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-xs max-w-[200px]">Some messages use outdated NIP-04 encryption</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground whitespace-nowrap flex-shrink-0 cursor-default">
{formatConversationTime(lastActivity)}
</span>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-xs">{formatFullDateTime(lastActivity)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-sm text-muted-foreground truncate">
{lastMessagePreview}
</p>
</div>
</div>
</button>
);
};
const ConversationItem = memo(ConversationItemComponent);
ConversationItem.displayName = 'ConversationItem';
const ConversationListSkeleton = () => {
return (
<div className="space-y-2 p-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-start gap-3 p-3">
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="h-3 w-full" />
</div>
</div>
))}
</div>
);
};
export const DMConversationList = ({
selectedPubkey,
onSelectConversation,
className,
onStatusClick
}: DMConversationListProps) => {
const { conversations, isLoading, loadingPhase } = useDMContext();
const [activeTab, setActiveTab] = useState<'known' | 'requests'>('known');
// Filter conversations by type
const { knownConversations, requestConversations } = useMemo(() => {
return {
knownConversations: conversations.filter(c => c.isKnown),
requestConversations: conversations.filter(c => c.isRequest),
};
}, [conversations]);
// Get the current list based on active tab
const currentConversations = activeTab === 'known' ? knownConversations : requestConversations;
// Show skeleton during initial load (cache + relays) if we have no conversations yet
const isInitialLoad = (loadingPhase === LOADING_PHASES.CACHE || loadingPhase === LOADING_PHASES.RELAYS) && conversations.length === 0;
return (
<Card className={cn("h-full flex flex-col overflow-hidden", className)}>
{/* Header - always visible */}
<div className="p-4 border-b flex-shrink-0 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-lg">Messages</h2>
{(loadingPhase === LOADING_PHASES.CACHE ||
loadingPhase === LOADING_PHASES.RELAYS ||
loadingPhase === LOADING_PHASES.SUBSCRIPTIONS) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{loadingPhase === LOADING_PHASES.CACHE && 'Loading from cache...'}
{loadingPhase === LOADING_PHASES.RELAYS && 'Querying relays for new messages...'}
{loadingPhase === LOADING_PHASES.SUBSCRIPTIONS && 'Setting up subscriptions...'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{onStatusClick && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onStatusClick}
aria-label="View messaging status"
>
<Info className="h-4 w-4" />
</Button>
)}
</div>
{/* Tab buttons - always visible */}
<div className="px-2 pt-2 flex-shrink-0">
<div className="grid grid-cols-2 gap-1 bg-muted p-1 rounded-lg">
<button
onClick={() => setActiveTab('known')}
className={cn(
"text-xs py-2 px-3 rounded-md transition-colors",
activeTab === 'known'
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
)}
>
Active {knownConversations.length > 0 && `(${knownConversations.length})`}
</button>
<button
onClick={() => setActiveTab('requests')}
className={cn(
"text-xs py-2 px-3 rounded-md transition-colors",
activeTab === 'requests'
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
)}
>
Requests {requestConversations.length > 0 && `(${requestConversations.length})`}
</button>
</div>
</div>
{/* Content area - show skeleton during initial load, otherwise show conversations */}
<div className="flex-1 min-h-0 mt-2 overflow-hidden">
{(isLoading || isInitialLoad) ? (
<ConversationListSkeleton />
) : conversations.length === 0 ? (
<div className="flex items-center justify-center h-full text-center text-muted-foreground px-4">
<div>
<p className="text-sm">No conversations yet</p>
<p className="text-xs mt-1">Start a new conversation to get started</p>
</div>
</div>
) : currentConversations.length === 0 ? (
<div className="flex items-center justify-center h-32 text-center text-muted-foreground px-4">
<p className="text-sm">No {activeTab} conversations</p>
</div>
) : (
<ScrollArea className="h-full block">
<div className="block w-full px-2 py-2 space-y-1">
{currentConversations.map((conversation) => (
<ConversationItem
key={conversation.pubkey}
pubkey={conversation.pubkey}
isSelected={selectedPubkey === conversation.pubkey}
onClick={() => onSelectConversation(conversation.pubkey)}
lastMessage={conversation.lastMessage}
lastActivity={conversation.lastActivity}
hasNIP4Messages={conversation.hasNIP4Messages}
/>
))}
</div>
</ScrollArea>
)}
</div>
</Card>
);
};

View File

@@ -0,0 +1,84 @@
import { useState, useCallback } from 'react';
import { DMConversationList } from '@/components/dm/DMConversationList';
import { DMChatArea } from '@/components/dm/DMChatArea';
import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
import { useDMContext } from '@/hooks/useDMContext';
import { useIsMobile } from '@/hooks/useIsMobile';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface DMMessagingInterfaceProps {
className?: string;
}
export const DMMessagingInterface = ({ className }: DMMessagingInterfaceProps) => {
const [selectedPubkey, setSelectedPubkey] = useState<string | null>(null);
const [statusModalOpen, setStatusModalOpen] = useState(false);
const isMobile = useIsMobile();
const { clearCacheAndRefetch } = useDMContext();
// On mobile, show only one panel at a time
const showConversationList = !isMobile || !selectedPubkey;
const showChatArea = !isMobile || selectedPubkey;
const handleSelectConversation = useCallback((pubkey: string) => {
setSelectedPubkey(pubkey);
}, []);
const handleBack = useCallback(() => {
setSelectedPubkey(null);
}, []);
return (
<>
{/* Status Modal */}
<Dialog open={statusModalOpen} onOpenChange={setStatusModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Messaging Status</DialogTitle>
<DialogDescription>
View loading status, cache info, and connection details
</DialogDescription>
</DialogHeader>
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
</DialogContent>
</Dialog>
<div className={cn("flex gap-4 overflow-hidden", className)}>
{/* Conversation List - Left Sidebar */}
<div className={cn(
"md:w-80 md:flex-shrink-0",
isMobile && !showConversationList && "hidden",
isMobile && showConversationList && "w-full"
)}>
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={handleSelectConversation}
className="h-full"
onStatusClick={() => setStatusModalOpen(true)}
/>
</div>
{/* Chat Area - Right Panel */}
<div className={cn(
"flex-1 md:min-w-0",
isMobile && !showChatArea && "hidden",
isMobile && showChatArea && "w-full"
)}>
<DMChatArea
pubkey={selectedPubkey}
onBack={isMobile ? handleBack : undefined}
className="h-full"
/>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,214 @@
import { useState } from 'react';
import { RefreshCw, Database, Wifi, CheckCircle2, Loader2 } from 'lucide-react';
import { useDMContext } from '@/hooks/useDMContext';
import { LOADING_PHASES } from '@/lib/dmConstants';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useToast } from '@/hooks/useToast';
interface DMStatusInfoProps {
clearCacheAndRefetch?: () => Promise<void>;
}
export const DMStatusInfo = ({ clearCacheAndRefetch }: DMStatusInfoProps) => {
const [isClearing, setIsClearing] = useState(false);
const { toast } = useToast();
const {
loadingPhase,
subscriptions,
scanProgress,
isDoingInitialLoad,
lastSync,
conversations,
} = useDMContext();
const handleClearCache = async () => {
if (!clearCacheAndRefetch) return;
setIsClearing(true);
try {
await clearCacheAndRefetch();
toast({
title: 'Cache cleared',
description: 'Refetching messages from relays...',
});
setIsClearing(false);
} catch (error) {
console.error('Error clearing cache:', error);
toast({
title: 'Error',
description: 'Failed to clear cache. Please try again.',
variant: 'destructive',
});
setIsClearing(false);
}
};
const getLoadingPhaseInfo = () => {
switch (loadingPhase) {
case LOADING_PHASES.IDLE:
return { label: 'Idle', description: 'Not yet initialized', icon: Loader2, color: 'text-muted-foreground' };
case LOADING_PHASES.CACHE:
return { label: 'Loading from cache', description: 'Reading cached messages...', icon: Database, color: 'text-blue-500' };
case LOADING_PHASES.RELAYS:
return { label: 'Loading from relays', description: 'Fetching messages from Nostr relays...', icon: Wifi, color: 'text-yellow-500' };
case LOADING_PHASES.SUBSCRIPTIONS:
return { label: 'Connecting subscriptions', description: 'Setting up real-time message sync...', icon: RefreshCw, color: 'text-orange-500' };
case LOADING_PHASES.READY:
return { label: 'Ready', description: 'All systems operational', icon: CheckCircle2, color: 'text-green-500' };
default:
return { label: 'Unknown', description: 'Status unknown', icon: Loader2, color: 'text-muted-foreground' };
}
};
const phaseInfo = getLoadingPhaseInfo();
const PhaseIcon = phaseInfo.icon;
const formatTimestamp = (timestamp: number | null) => {
if (!timestamp) return 'Never';
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
return date.toLocaleDateString();
};
return (
<div className="space-y-4">
{/* Loading Phase */}
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<PhaseIcon className={`h-5 w-5 ${phaseInfo.color} ${loadingPhase !== LOADING_PHASES.READY ? 'animate-pulse' : ''}`} />
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{phaseInfo.label}</p>
{isDoingInitialLoad && (
<Badge variant="secondary" className="text-xs">
Initial Load
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{phaseInfo.description}</p>
</div>
</div>
</CardContent>
</Card>
{/* Scan Progress */}
{(scanProgress.nip4 !== null || scanProgress.nip17 !== null) && (
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<p className="text-sm font-medium">Scanning Messages</p>
{scanProgress.nip4 !== null && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">NIP-4 (Legacy)</span>
<span className="text-muted-foreground">{scanProgress.nip4.current} events</span>
</div>
<p className="text-xs text-muted-foreground">{scanProgress.nip4.status}</p>
</div>
)}
{scanProgress.nip17 !== null && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">NIP-17 (Private)</span>
<span className="text-muted-foreground">{scanProgress.nip17.current} events</span>
</div>
<p className="text-xs text-muted-foreground">{scanProgress.nip17.status}</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Subscriptions */}
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<p className="text-sm font-medium">Real-time Subscriptions</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">NIP-4 (Legacy DMs)</span>
<Badge variant={subscriptions.isNIP4Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP4Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">NIP-17 (Private DMs)</span>
<Badge variant={subscriptions.isNIP17Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP17Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Cache Info */}
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<p className="text-sm font-medium">Cache Information</p>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Conversations</span>
<span className="font-medium">{conversations.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Last NIP-4 sync</span>
<span className="font-medium">{formatTimestamp(lastSync.nip4)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Last NIP-17 sync</span>
<span className="font-medium">{formatTimestamp(lastSync.nip17)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
{clearCacheAndRefetch && (
<>
<Separator />
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">Cache Management</p>
<p className="text-xs text-muted-foreground">
Clear all cached messages and refetch from relays. This will force a fresh sync.
</p>
</div>
<Button
onClick={handleClearCache}
disabled={isClearing}
variant="outline"
className="w-full"
>
{isClearing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Clearing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Clear Cache & Refetch
</>
)}
</Button>
</div>
</>
)}
</div>
);
};

View File

@@ -2,22 +2,25 @@ import { createContext } from "react";
export type Theme = "dark" | "light" | "system";
export interface RelayMetadata {
/** List of relays with read/write permissions */
relays: { url: string; read: boolean; write: boolean }[];
/** Unix timestamp of when the relay list was last updated */
updatedAt: number;
}
export interface AppConfig {
/** Current theme */
theme: Theme;
/** Selected relay URL */
relayUrl: string;
/** @deprecated No longer used - all users can create posts */
blogOwnerPubkey?: string;
/** NIP-65 relay list metadata */
relayMetadata: RelayMetadata;
}
export interface AppContextType {
/** Current application configuration */
config: AppConfig;
/** Update configuration using a callback that receives current config and returns new config */
updateConfig: (updater: (currentConfig: AppConfig) => AppConfig) => void;
/** Optional list of preset relays to display in the RelaySelector */
presetRelays?: { name: string; url: string }[];
updateConfig: (updater: (currentConfig: Partial<AppConfig>) => Partial<AppConfig>) => void;
}
export const AppContext = createContext<AppContextType | undefined>(undefined);

138
src/contexts/DMContext.ts Normal file
View File

@@ -0,0 +1,138 @@
import { createContext } from 'react';
import { type LoadingPhase, type ProtocolMode } from '@/lib/dmConstants';
import { type NostrEvent } from '@nostrify/nostrify';
import type { MessageProtocol } from '@/lib/dmConstants';
// ============================================================================
// DM Types and Constants
// ============================================================================
interface ParticipantData {
messages: DecryptedMessage[];
lastActivity: number;
lastMessage: DecryptedMessage | null;
hasNIP4: boolean;
hasNIP17: boolean;
}
type MessagesState = Map<string, ParticipantData>;
interface LastSyncData {
nip4: number | null;
nip17: number | null;
}
interface SubscriptionStatus {
isNIP4Connected: boolean;
isNIP17Connected: boolean;
}
interface ScanProgress {
current: number;
status: string;
}
interface ScanProgressState {
nip4: ScanProgress | null;
nip17: ScanProgress | null;
}
interface ConversationSummary {
id: string;
pubkey: string;
lastMessage: DecryptedMessage | null;
lastActivity: number;
hasNIP4Messages: boolean;
hasNIP17Messages: boolean;
isKnown: boolean;
isRequest: boolean;
lastMessageFromUser: boolean;
}
interface DecryptedMessage extends NostrEvent {
decryptedContent?: string;
error?: string;
isSending?: boolean;
clientFirstSeen?: number;
decryptedEvent?: NostrEvent; // For NIP-17: the inner kind 14/15 event
originalGiftWrapId?: string; // Store gift wrap ID for NIP-17 deduplication
}
/**
* File attachment for direct messages (NIP-92 compatible).
*
* All fields are required. Use with `useUploadFile` hook to upload files
* and generate the proper tags format.
*
* @example
* ```tsx
* import { useUploadFile } from '@/hooks/useUploadFile';
* import type { FileAttachment } from '@/contexts/DMContext';
*
* const { mutateAsync: uploadFile } = useUploadFile();
*
* const tags = await uploadFile(file);
* const attachment: FileAttachment = {
* url: tags[0][1],
* mimeType: file.type,
* size: file.size,
* name: file.name,
* tags: tags
* };
*
* await sendMessage({
* recipientPubkey: 'hex-pubkey',
* content: 'Check out this file!',
* attachments: [attachment]
* });
* ```
*
* @property url - Blossom server URL where file is hosted
* @property mimeType - MIME type of the file (e.g., 'image/png')
* @property size - File size in bytes
* @property name - Original filename
* @property tags - NIP-94 file metadata tags (includes hashes)
*/
export interface FileAttachment {
url: string;
mimeType: string;
size: number;
name: string;
tags: string[][];
}
/**
* Direct Messaging context interface providing access to all DM functionality.
*
* @property messages - Raw message state (Map of pubkey -> participant data)
* @property isLoading - True during initial load phases
* @property loadingPhase - Current loading phase (CACHE, RELAYS, SUBSCRIPTIONS, READY, IDLE)
* @property isDoingInitialLoad - True only during cache/relay loading (not subscriptions)
* @property lastSync - Unix timestamps of last successful sync for each protocol
* @property subscriptions - Connection status for real-time message subscriptions
* @property conversations - Array of conversation summaries sorted by last activity
* @property sendMessage - Send an encrypted direct message (NIP-04 or NIP-17)
* @property protocolMode - Current protocol mode (NIP04_ONLY, NIP17_ONLY, or BOTH)
* @property scanProgress - Progress info for large message history scans
* @property clearCacheAndRefetch - Clear IndexedDB cache and reload all messages from relays
*/
export interface DMContextType {
messages: MessagesState;
isLoading: boolean;
loadingPhase: LoadingPhase;
isDoingInitialLoad: boolean;
lastSync: LastSyncData;
subscriptions: SubscriptionStatus;
conversations: ConversationSummary[];
sendMessage: (params: {
recipientPubkey: string;
content: string;
protocol?: MessageProtocol;
attachments?: FileAttachment[];
}) => Promise<void>;
protocolMode: ProtocolMode;
scanProgress: ScanProgressState;
clearCacheAndRefetch: () => Promise<void>;
}
export const DMContext = createContext<DMContextType | null>(null);

View File

@@ -28,6 +28,7 @@ export function useAuthor(pubkey: string | undefined) {
return { event };
}
},
staleTime: 5 * 60 * 1000, // Keep cached data fresh for 5 minutes
retry: 3,
});
}

View File

@@ -6,7 +6,7 @@ export function useComments(root: NostrEvent | URL, limit?: number) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['comments', root instanceof URL ? root.toString() : root.id, limit],
queryKey: ['nostr', 'comments', root instanceof URL ? root.toString() : root.id, limit],
queryFn: async (c) => {
const filter: NostrFilter = { kinds: [1111] };

View File

@@ -0,0 +1,87 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDMContext } from "@/hooks/useDMContext";
const MESSAGES_PER_PAGE = 25;
/**
* Hook to access paginated messages for a specific conversation.
*
* Returns the most recent messages (default 25) with the ability to load earlier messages.
* Automatically resets to default page size when switching conversations.
*
* @example
* ```tsx
* import { useConversationMessages } from '@/contexts/DMContext';
*
* function MessageThread({ recipientPubkey }: { recipientPubkey: string }) {
* const {
* messages,
* hasMoreMessages,
* loadEarlierMessages,
* totalCount
* } = useConversationMessages(recipientPubkey);
*
* return (
* <div>
* {hasMoreMessages && (
* <button onClick={loadEarlierMessages}>
* Load Earlier ({totalCount - messages.length} more)
* </button>
* )}
* {messages.map(msg => (
* <div key={msg.id}>{msg.decryptedContent}</div>
* ))}
* </div>
* );
* }
* ```
*
* @param conversationId - The pubkey of the conversation participant
* @returns Paginated message data with loading function
*/
export function useConversationMessages(conversationId: string) {
const { messages: allMessages } = useDMContext();
const [visibleCount, setVisibleCount] = useState(MESSAGES_PER_PAGE);
const result = useMemo(() => {
const conversationData = allMessages.get(conversationId);
if (!conversationData) {
return {
messages: [],
hasMoreMessages: false,
totalCount: 0,
lastMessage: null,
lastActivity: 0,
};
}
const totalMessages = conversationData.messages.length;
const hasMore = totalMessages > visibleCount;
// Return the most recent N messages (slice from the end)
const visibleMessages = conversationData.messages.slice(-visibleCount);
return {
messages: visibleMessages,
hasMoreMessages: hasMore,
totalCount: totalMessages,
lastMessage: conversationData.lastMessage,
lastActivity: conversationData.lastActivity,
};
}, [allMessages, conversationId, visibleCount]);
const loadEarlierMessages = useCallback(() => {
setVisibleCount(prev => prev + MESSAGES_PER_PAGE);
}, []);
// Reset visible count when conversation changes
useEffect(() => {
setVisibleCount(MESSAGES_PER_PAGE);
}, [conversationId]);
return {
...result,
loadEarlierMessages,
};
}

45
src/hooks/useDMContext.ts Normal file
View File

@@ -0,0 +1,45 @@
import { useContext } from "react";
import { DMContext, DMContextType } from "@/contexts/DMContext";
/**
* Hook to access the direct messaging system.
*
* Provides access to conversations, message sending, loading states, and cache management.
* Must be used within a DMProvider.
*
* @example
* ```tsx
* import { useDMContext } from '@/hooks/useDMContext';
* import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
*
* function MyComponent() {
* const { conversations, sendMessage, isLoading } = useDMContext();
*
* // Send a message
* await sendMessage({
* recipientPubkey: 'hex-pubkey',
* content: 'Hello!',
* protocol: MESSAGE_PROTOCOL.NIP17
* });
*
* // Display conversations
* return (
* <div>
* {isLoading ? 'Loading...' : conversations.map(c => (
* <div key={c.pubkey}>{c.lastMessage?.decryptedContent}</div>
* ))}
* </div>
* );
* }
* ```
*
* @returns DMContextType - The direct messaging context
* @throws Error if used outside DMProvider
*/
export function useDMContext(): DMContextType {
const context = useContext(DMContext);
if (!context) {
throw new Error('useDMContext must be used within DMProvider');
}
return context;
}

View File

@@ -15,7 +15,7 @@ export function useLoggedInAccounts() {
const { logins, setLogin, removeLogin } = useNostrLogin();
const { data: authors = [] } = useQuery({
queryKey: ['logins', logins.map((l) => l.id).join(';')],
queryKey: ['nostr', 'logins', logins.map((l) => l.id).join(';')],
queryFn: async ({ signal }) => {
const events = await nostr.query(
[{ kinds: [0], authors: logins.map((l) => l.pubkey) }],

View File

@@ -1,68 +1,22 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { useNWC } from '@/hooks/useNWCContext';
import type { WebLNProvider } from 'webln';
import { requestProvider } from 'webln';
import type { WebLNProvider } from '@webbtc/webln-types';
export interface WalletStatus {
hasWebLN: boolean;
hasNWC: boolean;
webln: WebLNProvider | null;
activeNWC: ReturnType<typeof useNWC>['getActiveConnection'] extends () => infer T ? T : null;
isDetecting: boolean;
preferredMethod: 'nwc' | 'webln' | 'manual';
}
export function useWallet() {
const [webln, setWebln] = useState<WebLNProvider | null>(null);
const [isDetecting, setIsDetecting] = useState(false);
const [hasAttemptedDetection, setHasAttemptedDetection] = useState(false);
const { connections, getActiveConnection } = useNWC();
// Get the active connection directly - no memoization to avoid stale state
const activeNWC = getActiveConnection();
// Detect WebLN
const detectWebLN = useCallback(async () => {
if (webln || isDetecting) return webln;
setIsDetecting(true);
try {
const provider = await requestProvider();
setWebln(provider);
setHasAttemptedDetection(true);
return provider;
} catch (error) {
// Only log the error if it's not the common "no provider" error
if (error instanceof Error && !error.message.includes('no WebLN provider')) {
console.warn('WebLN detection error:', error);
}
setWebln(null);
setHasAttemptedDetection(true);
return null;
} finally {
setIsDetecting(false);
}
}, [webln, isDetecting]);
// Only auto-detect once on mount
useEffect(() => {
if (!hasAttemptedDetection) {
detectWebLN();
}
}, [detectWebLN, hasAttemptedDetection]);
// Test WebLN connection
const testWebLN = useCallback(async (): Promise<boolean> => {
if (!webln) return false;
try {
await webln.enable();
return true;
} catch (error) {
console.error('WebLN test failed:', error);
return false;
}
}, [webln]);
// Access WebLN directly from browser global scope
const webln = (globalThis as { webln?: WebLNProvider }).webln || null;
// Calculate status values reactively
const hasNWC = useMemo(() => {
@@ -77,18 +31,11 @@ export function useWallet() {
: 'manual';
const status: WalletStatus = {
hasWebLN: !!webln,
hasNWC,
webln,
activeNWC,
isDetecting,
preferredMethod,
};
return {
...status,
hasAttemptedDetection,
detectWebLN,
testWebLN,
};
return status;
}

View File

@@ -7,7 +7,7 @@ import { useNWC } from '@/hooks/useNWCContext';
import type { NWCConnection } from '@/hooks/useNWC';
import { nip57 } from 'nostr-tools';
import type { Event } from 'nostr-tools';
import type { WebLNProvider } from 'webln';
import type { WebLNProvider } from '@webbtc/webln-types';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -205,7 +205,7 @@ export function useZaps(
profile: actualTarget.pubkey,
event: event,
amount: zapAmount,
relays: [config.relayUrl],
relays: config.relayMetadata.relays.map(r => r.url),
comment
});
@@ -263,10 +263,22 @@ export function useZaps(
});
}
}
if (webln) { // Try WebLN next
try {
await webln.sendPayment(newInvoice);
// For native WebLN, we may need to enable it first
let webLnProvider = webln;
if (webln.enable && typeof webln.enable === 'function') {
const enabledProvider = await webln.enable();
// Some implementations return the provider, others return void
// Cast to WebLNProvider to handle both cases
const provider = enabledProvider as WebLNProvider | undefined;
if (provider) {
webLnProvider = provider;
}
}
await webLnProvider.sendPayment(newInvoice);
// Clear states immediately on success
setIsZapping(false);
@@ -283,7 +295,7 @@ export function useZaps(
// Close dialog last to ensure clean state
onZapSuccess?.();
} catch (weblnError) {
console.error('webln payment failed, falling back:', weblnError);
console.error('WebLN payment failed, falling back:', weblnError);
// Show specific WebLN error to user for debugging
const errorMessage = weblnError instanceof Error ? weblnError.message : 'Unknown WebLN error';

86
src/lib/dmConstants.ts Normal file
View File

@@ -0,0 +1,86 @@
import type { NostrEvent } from '@nostrify/nostrify';
// ============================================================================
// Message Protocol Types
// ============================================================================
export const MESSAGE_PROTOCOL = {
NIP04: 'nip04',
NIP17: 'nip17',
UNKNOWN: 'unknown',
} as const;
export type MessageProtocol = typeof MESSAGE_PROTOCOL[keyof typeof MESSAGE_PROTOCOL];
// ============================================================================
// Protocol Mode (for user selection)
// ============================================================================
export const PROTOCOL_MODE = {
NIP04_ONLY: 'nip04_only',
NIP17_ONLY: 'nip17_only',
NIP04_OR_NIP17: 'nip04_or_nip17',
} as const;
export type ProtocolMode = typeof PROTOCOL_MODE[keyof typeof PROTOCOL_MODE];
// ============================================================================
// Loading Phases
// ============================================================================
export const LOADING_PHASES = {
IDLE: 'idle',
CACHE: 'cache',
RELAYS: 'relays',
SUBSCRIPTIONS: 'subscriptions',
READY: 'ready',
} as const;
export type LoadingPhase = typeof LOADING_PHASES[keyof typeof LOADING_PHASES];
// ============================================================================
// Protocol Configuration
// ============================================================================
export const PROTOCOL_CONFIG = {
[MESSAGE_PROTOCOL.NIP04]: {
label: 'NIP-04',
description: 'Legacy DMs',
kind: 4,
},
[MESSAGE_PROTOCOL.NIP17]: {
label: 'NIP-17',
description: 'Private DMs',
kind: 1059,
},
[MESSAGE_PROTOCOL.UNKNOWN]: {
label: 'Unknown',
description: 'Unknown protocol',
kind: 0,
},
} as const;
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Get the message protocol from an event kind
*/
export function getMessageProtocol(event: NostrEvent): MessageProtocol {
switch (event.kind) {
case 4:
return MESSAGE_PROTOCOL.NIP04;
case 1059:
return MESSAGE_PROTOCOL.NIP17;
default:
return MESSAGE_PROTOCOL.UNKNOWN;
}
}
/**
* Check if a protocol is valid for sending messages
*/
export function isValidSendProtocol(protocol: MessageProtocol): boolean {
return protocol === MESSAGE_PROTOCOL.NIP04 || protocol === MESSAGE_PROTOCOL.NIP17;
}

117
src/lib/dmMessageStore.ts Normal file
View File

@@ -0,0 +1,117 @@
import { openDB, type IDBPDatabase } from 'idb';
import type { NostrEvent } from '@nostrify/nostrify';
// ============================================================================
// IndexedDB Schema
// ============================================================================
// Use domain-based naming to avoid conflicts between apps on same domain
const getDBName = () => {
// Use hostname for unique DB per app (e.g., 'nostr-dm-store-localhost', 'nostr-dm-store-myapp.com')
const hostname = typeof window !== 'undefined' ? window.location.hostname : 'default';
return `nostr-dm-store-${hostname}`;
};
const DB_NAME = getDBName();
const DB_VERSION = 1;
const STORE_NAME = 'messages';
interface StoredParticipant {
messages: NostrEvent[];
lastActivity: number;
hasNIP4: boolean;
hasNIP17: boolean;
}
export interface MessageStore {
participants: Record<string, StoredParticipant>;
lastSync: {
nip4: number | null;
nip17: number | null;
};
}
// ============================================================================
// Database Operations
// ============================================================================
/**
* Open the IndexedDB database
*/
async function openDatabase(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
// Create the messages store if it doesn't exist
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
},
});
}
/**
* Write messages to IndexedDB for a specific user
* Messages are stored in their original encrypted form (kind 4 or kind 13)
*/
export async function writeMessagesToDB(
userPubkey: string,
messageStore: MessageStore
): Promise<void> {
try {
const db = await openDatabase();
// Store messages in their original encrypted form (no NIP-44 wrapper needed)
// Each message content is already encrypted by the sender
await db.put(STORE_NAME, messageStore, userPubkey);
} catch (error) {
console.error('[MessageStore] ❌ Error writing to IndexedDB:', error);
throw error;
}
}
/**
* Read messages from IndexedDB for a specific user
* Messages are stored in their original encrypted form (kind 4 or kind 13)
*/
export async function readMessagesFromDB(
userPubkey: string
): Promise<MessageStore | undefined> {
try {
const db = await openDatabase();
const data = await db.get(STORE_NAME, userPubkey);
if (!data) {
return undefined;
}
return data as MessageStore;
} catch (error) {
console.error('[MessageStore] Error reading from IndexedDB:', error);
throw error;
}
}
/**
* Delete messages from IndexedDB for a specific user
*/
export async function deleteMessagesFromDB(userPubkey: string): Promise<void> {
try {
const db = await openDatabase();
await db.delete(STORE_NAME, userPubkey);
} catch (error) {
console.error('[MessageStore] Error deleting from IndexedDB:', error);
throw error;
}
}
/**
* Clear all messages from IndexedDB
*/
export async function clearAllMessages(): Promise<void> {
try {
const db = await openDatabase();
await db.clear(STORE_NAME);
} catch (error) {
console.error('[MessageStore] Error clearing IndexedDB:', error);
throw error;
}
}

98
src/lib/dmUtils.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Validate that an event is a proper DM event
*/
export function validateDMEvent(event: NostrEvent): boolean {
// Must be kind 4 (NIP-04 DM)
if (event.kind !== 4) return false;
// Must have a 'p' tag
const hasRecipient = event.tags?.some(([name]) => name === 'p');
if (!hasRecipient) return false;
// Must have content (even if encrypted)
if (!event.content) return false;
return true;
}
/**
* Get the recipient pubkey from a DM event
*/
export function getRecipientPubkey(event: NostrEvent): string | undefined {
return event.tags?.find(([name]) => name === 'p')?.[1];
}
/**
* Get the conversation partner pubkey from a DM event
* (the other person in the conversation, not the current user)
*/
export function getConversationPartner(event: NostrEvent, userPubkey: string): string | undefined {
const isFromUser = event.pubkey === userPubkey;
if (isFromUser) {
// If we sent it, the partner is the recipient
return getRecipientPubkey(event);
} else {
// If they sent it, the partner is the author
return event.pubkey;
}
}
/**
* Format timestamp for display (matches Signal/WhatsApp/Telegram pattern)
* Today: Show time (e.g., "2:45 PM")
* Yesterday: "Yesterday"
* This week: Day name (e.g., "Mon")
* This year: Month and day (e.g., "Jan 15")
* Older: Full date (e.g., "Jan 15, 2024")
*/
export function formatConversationTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
// Start of today (midnight)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Start of yesterday
const yesterdayStart = new Date(todayStart);
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
// Start of this week (assuming week starts on Sunday, adjust if needed)
const weekStart = new Date(todayStart);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
if (date >= todayStart) {
// Today: Show time (e.g., "2:45 PM")
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
} else if (date >= yesterdayStart) {
// Yesterday
return 'Yesterday';
} else if (date >= weekStart) {
// This week: Show day name (e.g., "Monday")
return date.toLocaleDateString(undefined, { weekday: 'short' });
} else if (date.getFullYear() === now.getFullYear()) {
// This year: Show month and day (e.g., "Jan 15")
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} else {
// Older: Show full date (e.g., "Jan 15, 2024")
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
}
/**
* Format timestamp as full date and time for tooltips
* e.g., "Mon, Jan 15, 2024, 2:45 PM"
*/
export function formatFullDateTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}

View File

@@ -1,3 +1,15 @@
import Buffer from 'buffer';
/**
* Polyfill for Buffer in browser environment
*
* Many Node.js libraries like isomorphic-git and bitcoinjs-lib expect Buffer to be globally available.
* This polyfill makes the buffer package's Buffer available globally.
*/
if (!globalThis.Buffer) {
globalThis.Buffer = Buffer.Buffer;
}
/**
* Polyfill for AbortSignal.any()
*