Compare commits

...

5 Commits

Author SHA1 Message Date
sam 2e144832b0 change for more relevant widgets 2026-05-12 18:35:52 +07:00
sam 32bf4bdab4 pull in more events by default 2026-05-12 17:27:07 +07:00
sam a5adbf2fed dedupe and stale data issues 2026-05-12 17:01:00 +07:00
sam 8120162960 handle edge cases 2026-05-12 16:52:36 +07:00
sam 94a26d3da1 replace sidebar widgets in communities with more relevant things 2026-05-12 14:56:16 +07:00
14 changed files with 940 additions and 332 deletions
+1 -4
View File
@@ -141,10 +141,7 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
],
sidebarWidgets: [],
messaging: {
enabled: true,
relayMode: 'hybrid',
+11
View File
@@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
import { CommunityRightSidebar } from '@/components/CommunityRightSidebar';
import { ComposeBox } from '@/components/ComposeBox';
import { CreateGoalDialog } from '@/components/CreateGoalDialog';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
@@ -302,6 +303,16 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
showFAB: activeTab === 'comments' || activeTab === 'fundraising',
onFabClick: handleFabClick,
fabIcon,
rightSidebar: communityATag ? (
<CommunityRightSidebar
scopedATag={communityATag}
community={community}
memberCount={membership?.members.length}
viewerRank={viewerMember?.rank}
reportsCount={moderation.allReports.length}
activeGoalsCount={activeGoals.length}
/>
) : undefined,
});
const moderationCtx = useMemo(
+139
View File
@@ -0,0 +1,139 @@
import { MessageCircle, Shield, Target, Users } from 'lucide-react';
import type { ParsedCommunity } from '@/lib/communityUtils';
import { LinkFooter } from '@/components/LinkFooter';
import { ActiveConversationsWidget } from '@/components/widgets/ActiveConversationsWidget';
import { MyCommunitiesWidget } from '@/components/widgets/MyCommunitiesWidget';
import { CommunityFundraisingWidget } from '@/components/widgets/CommunityFundraisingWidget';
import { cn } from '@/lib/utils';
interface CommunityRightSidebarProps {
scopedATag?: string;
community?: ParsedCommunity | null;
memberCount?: number;
viewerRank?: number;
reportsCount?: number;
activeGoalsCount?: number;
className?: string;
}
interface SectionProps {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}
function Section({ title, icon, children }: SectionProps) {
return (
<section className="bg-background/85 rounded-xl p-3 -mx-1">
<h2 className="text-sm font-bold mb-2 flex items-center gap-1.5 text-foreground/90">
<span className="text-muted-foreground">{icon}</span>
{title}
</h2>
{children}
</section>
);
}
function getRoleLabel(rank: number | undefined): string {
if (rank === undefined) return 'Observer';
if (rank === 0) return 'Leadership';
return `Member (rank ${rank})`;
}
function CommunitySnapshot({
community,
memberCount,
viewerRank,
reportsCount,
activeGoalsCount,
}: {
community: ParsedCommunity;
memberCount?: number;
viewerRank?: number;
reportsCount?: number;
activeGoalsCount?: number;
}) {
return (
<div className="space-y-2 px-1">
<p className="text-xs font-semibold truncate">{community.name}</p>
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-md bg-muted/40 px-2 py-1.5">
<p className="text-muted-foreground">Members</p>
<p className="font-semibold">{memberCount ?? 0}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5">
<p className="text-muted-foreground">Moderators</p>
<p className="font-semibold">{community.moderatorPubkeys.length}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5">
<p className="text-muted-foreground">Your role</p>
<p className="font-semibold">{getRoleLabel(viewerRank)}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5">
<p className="text-muted-foreground">Open reports</p>
<p className="font-semibold">{reportsCount ?? 0}</p>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
{activeGoalsCount ?? 0} active goal{activeGoalsCount === 1 ? '' : 's'} in fundraising.
</p>
</div>
);
}
/** Community-focused right sidebar for overview and detail pages. */
export function CommunityRightSidebar({
scopedATag,
community,
memberCount,
viewerRank,
reportsCount,
activeGoalsCount,
className,
}: CommunityRightSidebarProps) {
return (
<aside
className={cn(
'w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-2 gap-3',
className,
)}
>
{scopedATag && community ? (
<Section title="Community snapshot" icon={<Users className="size-3.5" />}>
<CommunitySnapshot
community={community}
memberCount={memberCount}
viewerRank={viewerRank}
reportsCount={reportsCount}
activeGoalsCount={activeGoalsCount}
/>
</Section>
) : (
<Section title="My communities" icon={<Users className="size-3.5" />}>
<MyCommunitiesWidget />
</Section>
)}
<Section title="Active conversations" icon={<MessageCircle className="size-3.5" />}>
<ActiveConversationsWidget scopedATag={scopedATag} />
</Section>
<Section title="Fundraising" icon={<Target className="size-3.5" />}>
<CommunityFundraisingWidget scopedATag={scopedATag} />
</Section>
{scopedATag && (
<Section title="Moderation" icon={<Shield className="size-3.5" />}>
<p className="text-sm text-muted-foreground p-1">
Reports and member bans are scoped to this community and enforced in feed rendering.
</p>
</Section>
)}
<div className="mt-auto pt-2">
<LinkFooter />
</div>
</aside>
);
}
-100
View File
@@ -1,100 +0,0 @@
import { useMemo } from 'react';
import { Check, Plus } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { WIDGET_DEFINITIONS, WIDGET_CATEGORIES } from '@/lib/sidebarWidgets';
import type { WidgetConfig } from '@/contexts/AppContext';
import { cn } from '@/lib/utils';
interface WidgetPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentWidgets: WidgetConfig[];
onAdd: (id: string) => void;
onRemove: (id: string) => void;
}
/** Dialog for adding/removing widgets from the sidebar. */
export function WidgetPickerDialog({ open, onOpenChange, currentWidgets, onAdd, onRemove }: WidgetPickerDialogProps) {
const activeIds = useMemo(() => new Set(currentWidgets.map((w) => w.id)), [currentWidgets]);
// Group widgets by category
const grouped = useMemo(() => {
const groups: Record<string, typeof WIDGET_DEFINITIONS> = {};
for (const w of WIDGET_DEFINITIONS) {
(groups[w.category] ??= []).push(w);
}
return groups;
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Widget</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="space-y-5 pr-2">
{Object.entries(grouped).map(([category, widgets]) => (
<div key={category}>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-1">
{WIDGET_CATEGORIES[category] ?? category}
</h3>
<div className="space-y-1">
{widgets.map((widget) => {
const isActive = activeIds.has(widget.id);
const Icon = widget.icon;
return (
<button
key={widget.id}
onClick={() => {
if (isActive) {
onRemove(widget.id);
} else {
onAdd(widget.id);
}
}}
className={cn(
'flex items-center gap-3 w-full px-3 py-2.5 rounded-xl transition-colors text-left',
isActive
? 'bg-primary/10 hover:bg-primary/15'
: 'hover:bg-secondary/60',
)}
>
<div className={cn(
'size-9 rounded-lg flex items-center justify-center shrink-0',
isActive ? 'bg-primary/20 text-primary' : 'bg-secondary text-muted-foreground',
)}>
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{widget.label}</div>
<div className="text-xs text-muted-foreground truncate">{widget.description}</div>
</div>
<div className={cn(
'size-6 rounded-full flex items-center justify-center shrink-0 transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'border border-border text-muted-foreground/50',
)}>
{isActive ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
</div>
</button>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+2 -41
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, lazy, Suspense, memo } from 'react';
import { useCallback, useMemo, lazy, Suspense, memo } from 'react';
import {
DndContext,
closestCenter,
@@ -15,7 +15,6 @@ import {
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Plus } from 'lucide-react';
import { WidgetCard } from '@/components/WidgetCard';
import { ErrorBoundary } from '@/components/ErrorBoundary';
@@ -28,8 +27,6 @@ import type { WidgetDefinition } from '@/lib/sidebarWidgets';
// ── Lazy-loaded widget components ────────────────────────────────────────────
const TrendingWidget = lazy(() => import('@/components/widgets/TrendingWidget').then((m) => ({ default: m.TrendingWidget })));
const HotPostsWidget = lazy(() => import('@/components/widgets/HotPostsWidget').then((m) => ({ default: m.HotPostsWidget })));
const StatusWidget = lazy(() => import('@/components/widgets/StatusWidget').then((m) => ({ default: m.StatusWidget })));
const AIChatWidget = lazy(() => import('@/components/widgets/AIChatWidget').then((m) => ({ default: m.AIChatWidget })));
const BlueskyWidget = lazy(() => import('@/components/widgets/BlueskyWidget').then((m) => ({ default: m.BlueskyWidget })));
@@ -37,16 +34,10 @@ const PhotoWidget = lazy(() => import('@/components/widgets/PhotoWidget').then((
const MusicWidget = lazy(() => import('@/components/widgets/MusicWidget').then((m) => ({ default: m.MusicWidget })));
const FeedWidget = lazy(() => import('@/components/widgets/FeedWidget').then((m) => ({ default: m.FeedWidget })));
const WidgetPickerDialog = lazy(() => import('@/components/WidgetPickerDialog').then((m) => ({ default: m.WidgetPickerDialog })));
// ── Widget content resolver ──────────────────────────────────────────────────
function WidgetContent({ id }: { id: string }) {
switch (id) {
case 'trends':
return <TrendingWidget />;
case 'hot-posts':
return <HotPostsWidget />;
case 'status':
return <StatusWidget />;
case 'ai-chat':
@@ -145,7 +136,6 @@ const EMPTY_WIDGETS: WidgetConfig[] = [];
export function WidgetSidebar() {
const { config, updateConfig } = useAppContext();
const widgets = config.sidebarWidgets ?? EMPTY_WIDGETS;
const [pickerOpen, setPickerOpen] = useState(false);
// Filter out widgets with unknown definitions
const validWidgets = useMemo(
@@ -168,13 +158,6 @@ export function WidgetSidebar() {
updateWidgets((ws) => ws.map((w) => w.id === id ? { ...w, height } : w));
}, [updateWidgets]);
const addWidget = useCallback((id: string) => {
updateWidgets((ws) => {
if (ws.some((w) => w.id === id)) return ws;
return [...ws, { id }];
});
}, [updateWidgets]);
// Drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -212,35 +195,13 @@ export function WidgetSidebar() {
/>
);
})}
{/* Add widget button */}
<button
onClick={() => setPickerOpen(true)}
className="flex items-center justify-center gap-1.5 w-full py-2.5 rounded-xl bg-background/85 text-muted-foreground hover:text-foreground hover:bg-background transition-colors text-xs"
>
<Plus className="size-3.5" />
Add widget
</button>
</div>
</SortableContext>
</DndContext>
<div className="mt-3">
<div className="mt-auto pt-3">
<LinkFooter />
</div>
{/* Widget picker dialog */}
<Suspense fallback={null}>
{pickerOpen && (
<WidgetPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
currentWidgets={widgets}
onAdd={addWidget}
onRemove={removeWidget}
/>
)}
</Suspense>
</aside>
);
}
@@ -0,0 +1,223 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { MessageSquare } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useMyCommunities, type MyCommunityEntry } from '@/hooks/useMyCommunities';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
interface ActiveConversationsWidgetProps {
/** Number of conversations to show. */
limit?: number;
/** When provided, only show comments scoped to this community a-tag. */
scopedATag?: string;
}
/** A grouped conversation thread (most-recent comment in a thread). */
interface ConversationThread {
/** Latest comment in the thread. */
latest: NostrEvent;
/** Root scope a-tag (the community). */
communityATag: string;
/** Number of comments observed for this thread. */
count: number;
}
/** Returns the root event id for a thread when present. */
function getThreadRootId(event: NostrEvent): string | undefined {
const upperRoot = event.tags.find(([n]) => n === 'E')?.[1];
if (upperRoot) return upperRoot;
const lowerRoot = event.tags.find(([n, , , marker]) => n === 'e' && marker === 'root')?.[1];
if (lowerRoot) return lowerRoot;
return event.tags.find(([n]) => n === 'e')?.[1];
}
/**
* Sidebar widget showing recent NIP-22 comment threads scoped to the user's
* communities (or a specific community when `scopedATag` is provided).
*
* Threads are deduplicated by their root `E` tag — only the latest comment
* per thread is shown so the list reads like a "what's active" snapshot.
*/
export function ActiveConversationsWidget({ limit = 5, scopedATag }: ActiveConversationsWidgetProps) {
const { nostr } = useNostr();
const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities();
const aTags = useMemo(() => {
if (scopedATag) return [scopedATag];
return (myCommunities ?? []).map((c) => c.community.aTag).filter(Boolean);
}, [myCommunities, scopedATag]);
const aTagsKey = aTags.join(',');
const { data: comments, isLoading: commentsLoading, isError: commentsError } = useQuery({
queryKey: ['widget-active-conversations', aTagsKey],
queryFn: async ({ signal }) => {
if (aTags.length === 0) return [];
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
return nostr.query(
[{ kinds: [1111], '#A': aTags, limit: 100 }],
{ signal: querySignal },
);
},
enabled: scopedATag ? true : !communitiesLoading && aTags.length > 0,
staleTime: 60_000,
});
const threads = useMemo<ConversationThread[]>(() => {
if (!comments || comments.length === 0) return [];
const byThread = new Map<string, ConversationThread>();
for (const event of comments) {
const communityATag = event.tags.find(([n]) => n === 'A')?.[1];
if (!communityATag) continue;
// Thread key: root tag (prefer NIP-22 `E`, fallback to lowercase `e` variants).
const key = getThreadRootId(event) ?? event.id;
const existing = byThread.get(key);
if (!existing) {
byThread.set(key, { latest: event, communityATag, count: 1 });
} else {
existing.count += 1;
if (event.created_at > existing.latest.created_at) {
existing.latest = event;
}
}
}
return Array.from(byThread.values())
.sort((a, b) => b.latest.created_at - a.latest.created_at)
.slice(0, limit);
}, [comments, limit]);
const isLoading = scopedATag ? commentsLoading : communitiesLoading || commentsLoading;
if (!scopedATag && aTags.length === 0 && !isLoading) {
return (
<p className="text-sm text-muted-foreground p-1">
Join a community to see active conversations.
</p>
);
}
if (isLoading) {
return (
<div className="space-y-3 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-full" />
</div>
))}
</div>
);
}
if (commentsError) {
return <p className="text-sm text-destructive p-1">Failed to load active conversations.</p>;
}
if (threads.length === 0) {
return (
<p className="text-sm text-muted-foreground p-1">No recent conversations.</p>
);
}
return (
<div className="space-y-0.5">
{threads.map((thread) => (
<ConversationRow
key={thread.latest.id}
thread={thread}
myCommunities={myCommunities}
showCommunityName={!scopedATag}
/>
))}
</div>
);
}
interface ConversationRowProps {
thread: ConversationThread;
myCommunities: MyCommunityEntry[] | undefined;
showCommunityName: boolean;
}
function ConversationRow({ thread, myCommunities, showCommunityName }: ConversationRowProps) {
const { latest, communityATag, count } = thread;
const author = useAuthor(latest.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(latest.pubkey);
const community = useMemo(
() => myCommunities?.find((c) => c.community.aTag === communityATag)?.community,
[myCommunities, communityATag],
);
const eventId = useMemo(
() => nip19.neventEncode({ id: latest.id, author: latest.pubkey }),
[latest],
);
const snippet = useMemo(() => {
const clean = latest.content.replace(/https?:\/\/\S+/g, '').replace(/nostr:[a-z0-9]+/g, '').trim();
if (clean.length > 80) return clean.slice(0, 80) + '...';
return clean || '(no preview)';
}, [latest.content]);
const communityNaddr = useMemo(() => {
const [, pubkey, dTag] = communityATag.split(':');
if (!pubkey || !dTag) return null;
try {
return nip19.naddrEncode({ kind: COMMUNITY_DEFINITION_KIND, pubkey, identifier: dTag });
} catch {
return null;
}
}, [communityATag]);
return (
<div className="px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors">
<Link to={`/${eventId}`} className="block">
<div className="flex items-center gap-1.5 mb-0.5 min-w-0">
<Avatar className="size-4 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-xs font-semibold truncate">{displayName}</span>
<span className="text-xs text-muted-foreground shrink-0">&middot; {timeAgo(latest.created_at)}</span>
</div>
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
</Link>
{(showCommunityName && community) || count > 1 ? (
<div className="flex items-center gap-2 mt-1 text-[11px] text-muted-foreground">
{showCommunityName && community && communityNaddr && (
<Link
to={`/${communityNaddr}`}
className="hover:text-foreground hover:underline truncate"
>
{community.name}
</Link>
)}
{count > 1 && (
<span className="flex items-center gap-1 shrink-0">
<MessageSquare className="size-2.5" />
{count}
</span>
)}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,139 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { Target } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { useMyCommunities } from '@/hooks/useMyCommunities';
import { parseGoalEvent, isGoalExpired, formatSats, ZAP_GOAL_KIND } from '@/lib/goalUtils';
interface CommunityFundraisingWidgetProps {
/** Number of active goals to render. */
limit?: number;
/** Optional single-community scope (`34550:<pubkey>:<d>`). */
scopedATag?: string;
}
interface GoalItem {
event: NostrEvent;
title: string;
amountSats: number;
communityATag?: string;
closedAt?: number;
}
/** Community fundraising widget powered by NIP-75 goals (kind 9041). */
export function CommunityFundraisingWidget({ limit = 5, scopedATag }: CommunityFundraisingWidgetProps) {
const { nostr } = useNostr();
const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities();
const aTags = useMemo(() => {
if (scopedATag) return [scopedATag];
return (myCommunities ?? []).map((c) => c.community.aTag);
}, [myCommunities, scopedATag]);
const communityNameByATag = useMemo(() => {
const byATag = new Map<string, string>();
for (const entry of myCommunities ?? []) {
byATag.set(entry.community.aTag, entry.community.name);
}
return byATag;
}, [myCommunities]);
const { data: events, isLoading: goalsLoading, isError } = useQuery({
queryKey: ['widget-community-fundraising', aTags.join(',')],
queryFn: async ({ signal }) => {
if (aTags.length === 0) return [];
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
return nostr.query(
[{ kinds: [ZAP_GOAL_KIND], '#a': aTags, limit: 100 }],
{ signal: querySignal },
);
},
enabled: scopedATag ? true : !communitiesLoading && aTags.length > 0,
staleTime: 60_000,
});
const goals = useMemo<GoalItem[]>(() => {
if (!events) return [];
const parsedGoals: GoalItem[] = [];
for (const event of events) {
const parsed = parseGoalEvent(event);
if (!parsed || isGoalExpired(parsed)) continue;
parsedGoals.push({
event,
title: parsed.title,
amountSats: parsed.amountSats,
communityATag: parsed.communityATag,
closedAt: parsed.closedAt,
});
}
return parsedGoals
.sort((a, b) => {
const aDeadline = a.closedAt ?? Number.MAX_SAFE_INTEGER;
const bDeadline = b.closedAt ?? Number.MAX_SAFE_INTEGER;
return aDeadline - bDeadline;
})
.slice(0, limit);
}, [events, limit]);
const isLoading = scopedATag ? goalsLoading : communitiesLoading || goalsLoading;
if (!scopedATag && aTags.length === 0 && !isLoading) {
return (
<p className="text-sm text-muted-foreground p-1">
Join communities to follow fundraising goals.
</p>
);
}
if (isLoading) {
return (
<div className="space-y-2 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-2.5 w-1/2" />
</div>
))}
</div>
);
}
if (isError) {
return <p className="text-sm text-destructive p-1">Failed to load community goals.</p>;
}
if (goals.length === 0) {
return <p className="text-sm text-muted-foreground p-1">No active fundraising goals.</p>;
}
return (
<div className="space-y-0.5">
{goals.map((goal) => {
const encoded = nip19.neventEncode({ id: goal.event.id, author: goal.event.pubkey });
return (
<Link
key={goal.event.id}
to={`/${encoded}`}
className="block px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
>
<p className="text-xs font-semibold truncate">{goal.title}</p>
<p className="text-[11px] text-muted-foreground flex items-center gap-1">
<Target className="size-3 shrink-0" />
{formatSats(goal.amountSats)} sats
</p>
{!scopedATag && goal.communityATag && communityNameByATag.get(goal.communityATag) && (
<p className="text-[11px] text-muted-foreground truncate">
{communityNameByATag.get(goal.communityATag)}
</p>
)}
</Link>
);
})}
</div>
);
}
-97
View File
@@ -1,97 +0,0 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { isEventMuted } from '@/lib/muteHelpers';
import { useAuthor } from '@/hooks/useAuthor';
import { useOpenPost } from '@/hooks/useOpenPost';
import { useSortedPosts } from '@/hooks/useTrending';
import { useMuteList } from '@/hooks/useMuteList';
/** Hot posts widget for the right sidebar. */
export function HotPostsWidget() {
const { data: rawPosts, isLoading } = useSortedPosts('hot', 5);
const { muteItems } = useMuteList();
const posts = useMemo(() => {
if (!rawPosts || muteItems.length === 0) return rawPosts;
return rawPosts.filter((e) => !isEventMuted(e, muteItems));
}, [rawPosts, muteItems]);
if (isLoading) {
return (
<div className="space-y-3 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-3/4" />
</div>
))}
</div>
);
}
if (!posts || posts.length === 0) {
return <p className="text-sm text-muted-foreground p-1">No hot posts right now.</p>;
}
return (
<div className="space-y-0.5">
{posts.slice(0, 5).map((event) => (
<HotPostCard key={event.id} event={event} />
))}
<div className="pt-1 px-2">
<Link to="/trends" className="text-xs text-primary hover:underline">View all on Trends</Link>
</div>
</div>
);
}
/** Compact hot post card for the sidebar widget. */
function HotPostCard({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(event.pubkey);
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
const { onClick: openPost, onAuxClick } = useOpenPost(`/${encodedId}`);
const snippet = useMemo(() => {
const clean = event.content.replace(/https?:\/\/\S+/g, '').trim();
if (clean.length > 100) return clean.slice(0, 100) + '\u2026';
return clean || '(media)';
}, [event.content]);
return (
<button
onClick={openPost}
onAuxClick={onAuxClick}
className="block w-full text-left hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
>
<div className="flex items-center gap-1.5 mb-0.5">
<Avatar className="size-4">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-xs font-semibold truncate">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
<span className="text-xs text-muted-foreground shrink-0">&middot; {timeAgo(event.created_at)}</span>
</div>
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
</button>
);
}
@@ -0,0 +1,83 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Crown } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Skeleton } from '@/components/ui/skeleton';
import { useMyCommunities } from '@/hooks/useMyCommunities';
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
interface MyCommunitiesWidgetProps {
limit?: number;
}
/** Sidebar widget listing communities the current user founded or joined. */
export function MyCommunitiesWidget({ limit = 6 }: MyCommunitiesWidgetProps) {
const { data: communities, isLoading } = useMyCommunities();
const items = useMemo(
() => (communities ?? []).slice(0, limit),
[communities, limit],
);
if (isLoading) {
return (
<div className="space-y-2 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-2.5 w-full" />
</div>
))}
</div>
);
}
if (!communities || communities.length === 0) {
return (
<p className="text-sm text-muted-foreground p-1">
Join communities to build your Agora network.
</p>
);
}
return (
<div className="space-y-0.5">
{items.map((entry) => {
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: entry.event.pubkey,
identifier: entry.community.dTag,
});
return (
<Link
key={entry.community.aTag}
to={`/${naddr}`}
className="block px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
>
<div className="flex items-center gap-1.5 min-w-0">
<p className="text-xs font-semibold truncate flex-1">{entry.community.name}</p>
{entry.isFounded && (
<span className="flex items-center gap-1 text-[10px] text-amber-500 shrink-0">
<Crown className="size-2.5" />
Founder
</span>
)}
</div>
{entry.community.description && (
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">
{entry.community.description}
</p>
)}
</Link>
);
})}
<div className="pt-1 px-2">
<Link to="/communities" className="text-xs text-primary hover:underline">
View all communities
</Link>
</div>
</div>
);
}
@@ -0,0 +1,130 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Users } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { Skeleton } from '@/components/ui/skeleton';
import { useMyCommunities } from '@/hooks/useMyCommunities';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
type ParsedCommunity,
} from '@/lib/communityUtils';
interface SuggestedCommunitiesWidgetProps {
/** Number of suggestions to show. */
limit?: number;
/** Optional community a-tag to exclude (e.g. the one currently being viewed). */
excludeATag?: string;
}
/** Sidebar widget that surfaces communities the user hasn't joined yet. */
export function SuggestedCommunitiesWidget({ limit = 5, excludeATag }: SuggestedCommunitiesWidgetProps) {
const { nostr } = useNostr();
const { data: myCommunities } = useMyCommunities();
const { data: events, isLoading, isError } = useQuery({
queryKey: ['widget-suggested-communities'],
queryFn: async ({ signal }) => {
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
return nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], limit: 100 }],
{ signal: querySignal },
);
},
staleTime: 5 * 60_000,
});
const suggestions = useMemo(() => {
if (!events) return [];
const myATags = new Set((myCommunities ?? []).map((c) => c.community.aTag));
if (excludeATag) myATags.add(excludeATag);
const latestByATag = new Map<string, { community: ParsedCommunity; createdAt: number }>();
for (const event of events) {
const c = parseCommunityEvent(event);
if (!c) continue;
if (myATags.has(c.aTag)) continue;
const existing = latestByATag.get(c.aTag);
if (!existing || event.created_at > existing.createdAt) {
latestByATag.set(c.aTag, { community: c, createdAt: event.created_at });
}
}
return Array.from(latestByATag.values())
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit)
.map(({ community }) => community);
}, [events, myCommunities, excludeATag, limit]);
if (isLoading) {
return (
<div className="space-y-2 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="size-9 rounded-lg shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-2.5 w-full" />
</div>
</div>
))}
</div>
);
}
if (isError) {
return (
<p className="text-sm text-destructive p-1">
Failed to load suggested communities.
</p>
);
}
if (suggestions.length === 0) {
return <p className="text-sm text-muted-foreground p-1">No suggestions right now.</p>;
}
return (
<div className="space-y-0.5">
{suggestions.map((c) => {
const [, pubkey] = c.aTag.split(':');
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey,
identifier: c.dTag,
});
return (
<Link
key={c.aTag}
to={`/${naddr}`}
className="flex items-start gap-2 px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
>
<div className="size-9 rounded-lg overflow-hidden bg-primary/10 shrink-0 flex items-center justify-center">
{c.image ? (
<img src={c.image} alt={c.name} className="w-full h-full object-cover" />
) : (
<Users className="size-4 text-primary/60" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold truncate">{c.name}</p>
{c.description && (
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">
{c.description}
</p>
)}
</div>
</Link>
);
})}
<div className="pt-1 px-2">
<Link to="/communities" className="text-xs text-primary hover:underline">
Browse all communities
</Link>
</div>
</div>
);
}
-66
View File
@@ -1,66 +0,0 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { TrendSparkline } from '@/components/TrendSparkline';
import { useTrendingTags, useTagSparklines } from '@/hooks/useTrending';
import { formatNumber } from '@/lib/formatNumber';
/** Compact trending tags widget for the right sidebar. */
export function TrendingWidget() {
const { data: trendingTagsResult, isLoading: tagsLoading } = useTrendingTags(true);
const trendingTags = trendingTagsResult?.tags;
const labelCreatedAt = trendingTagsResult?.labelCreatedAt ?? 0;
const visibleTags = useMemo(() => (trendingTags ?? []).slice(0, 5).map((t) => t.tag), [trendingTags]);
const { data: sparklineData, isLoading: sparklinesLoading } = useTagSparklines(visibleTags, labelCreatedAt, visibleTags.length > 0);
if (tagsLoading) {
return (
<div className="space-y-4 p-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex justify-between items-center">
<div className="space-y-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-8 w-12" />
</div>
))}
</div>
);
}
if (!trendingTags || trendingTags.length === 0) {
return <p className="text-sm text-muted-foreground p-1">No trends available.</p>;
}
return (
<div className="space-y-1">
{trendingTags.slice(0, 5).map((item) => (
<Link
key={item.tag}
to={`/t/${item.tag}`}
className="flex items-center justify-between group hover:bg-secondary/40 px-2 py-1.5 rounded-lg transition-colors"
>
<div>
<div className="font-bold text-sm">#{item.tag}</div>
{item.accounts > 0 && (
<div className="text-xs text-muted-foreground">
<span className="text-primary font-semibold">{formatNumber(item.accounts)}</span> people talking
</div>
)}
</div>
{sparklinesLoading ? (
<Skeleton className="h-[35px] w-[50px] rounded" />
) : (
<TrendSparkline data={sparklineData?.get(item.tag) ?? []} />
)}
</Link>
))}
<div className="pt-1 px-2">
<Link to="/trends" className="text-xs text-primary hover:underline">View all trends</Link>
</div>
</div>
);
}
@@ -0,0 +1,210 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { CalendarDays, MapPin } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { Skeleton } from '@/components/ui/skeleton';
interface UpcomingEventsWidgetProps {
/** Number of events to show. */
limit?: number;
/** When provided, only show events tagged to this community a-tag. */
scopedATag?: string;
}
interface ParsedCalendarEvent {
/** Raw event. */
event: NostrEvent;
/** Title (falls back to "Untitled event"). */
title: string;
/** Start time in seconds. */
startSeconds: number;
/** Whether this is a date-only (kind 31922) vs time-based (kind 31923) event. */
dateOnly: boolean;
/** Optional location string. */
location?: string;
}
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
});
const DATETIME_FORMAT = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
/** Parse a calendar event's start tag into a unix timestamp (seconds). */
function parseStart(event: NostrEvent): { seconds: number; dateOnly: boolean } | null {
const start = event.tags.find(([n]) => n === 'start')?.[1];
if (!start) return null;
if (event.kind === 31922) {
// Date-based: YYYY-MM-DD. Treat as local-midnight start of day.
const dateMatch = /^\d{4}-\d{2}-\d{2}$/.test(start);
if (!dateMatch) return null;
const seconds = Math.floor(new Date(`${start}T00:00:00`).getTime() / 1000);
if (!Number.isFinite(seconds)) return null;
return { seconds, dateOnly: true };
}
if (event.kind === 31923) {
const seconds = parseInt(start, 10);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return { seconds, dateOnly: false };
}
return null;
}
/** Sidebar widget listing the next upcoming calendar events. */
export function UpcomingEventsWidget({ limit = 5, scopedATag }: UpcomingEventsWidgetProps) {
const { nostr } = useNostr();
const { data: events, isLoading, isError } = useQuery({
queryKey: ['widget-upcoming-events', scopedATag ?? 'global'],
queryFn: async ({ signal }) => {
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
return nostr.query(
[
{
kinds: [31922, 31923],
limit: 100,
...(scopedATag ? { '#a': [scopedATag] } : {}),
},
],
{ signal: querySignal },
);
},
staleTime: 5 * 60_000,
});
const upcoming = useMemo<ParsedCalendarEvent[]>(() => {
if (!events) return [];
const now = Math.floor(Date.now() / 1000);
const latestByAddress = new Map<string, NostrEvent>();
for (const event of events) {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (!dTag) continue;
const key = `${event.kind}:${event.pubkey}:${dTag}`;
const existing = latestByAddress.get(key);
if (!existing || event.created_at > existing.created_at) {
latestByAddress.set(key, event);
}
}
const parsedEvents: ParsedCalendarEvent[] = [];
for (const event of latestByAddress.values()) {
const start = parseStart(event);
if (!start) continue;
if (start.seconds < now) continue;
const title =
event.tags.find(([n]) => n === 'title')?.[1]
|| event.tags.find(([n]) => n === 'name')?.[1]
|| 'Untitled event';
const location = event.tags.find(([n]) => n === 'location')?.[1];
parsedEvents.push({
event,
title,
startSeconds: start.seconds,
dateOnly: start.dateOnly,
location,
});
}
return parsedEvents
.sort((a, b) => a.startSeconds - b.startSeconds)
.slice(0, limit);
}, [events, limit]);
if (isLoading) {
return (
<div className="space-y-2 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-2">
<Skeleton className="size-10 rounded-lg shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-2.5 w-1/2" />
</div>
</div>
))}
</div>
);
}
if (isError) {
return <p className="text-sm text-destructive p-1">Failed to load upcoming events.</p>;
}
if (upcoming.length === 0) {
return <p className="text-sm text-muted-foreground p-1">No upcoming events.</p>;
}
return (
<div className="space-y-0.5">
{upcoming.map(({ event, title, startSeconds, dateOnly, location }) => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
});
const startDate = new Date(startSeconds * 1000);
const formatted = dateOnly ? DATE_FORMAT.format(startDate) : DATETIME_FORMAT.format(startDate);
return (
<Link
key={event.id}
to={`/${naddr}`}
className="flex items-start gap-2 px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
>
<DateBadge date={startDate} />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold truncate">{title}</p>
<p className="text-[11px] text-muted-foreground flex items-center gap-1">
<CalendarDays className="size-3 shrink-0" />
<span className="truncate">{formatted}</span>
</p>
{location && (
<p className="text-[11px] text-muted-foreground flex items-center gap-1 mt-0.5">
<MapPin className="size-3 shrink-0" />
<span className="truncate">{location}</span>
</p>
)}
</div>
</Link>
);
})}
<div className="pt-1 px-2">
<Link to="/events" className="text-xs text-primary hover:underline">
View all events
</Link>
</div>
</div>
);
}
const MONTH_SHORT = new Intl.DateTimeFormat(undefined, { month: 'short' });
/** Compact two-line date badge (Month + Day). */
function DateBadge({ date }: { date: Date }) {
return (
<div className="size-10 rounded-lg bg-primary/10 border border-primary/15 flex flex-col items-center justify-center shrink-0 leading-none">
<span className="text-[9px] uppercase tracking-wide text-primary/70 font-semibold">
{MONTH_SHORT.format(date)}
</span>
<span className="text-sm font-bold text-primary">
{date.getDate()}
</span>
</div>
);
}
-24
View File
@@ -1,7 +1,5 @@
import type { ComponentType } from 'react';
import {
TrendingUp,
Flame,
SmilePlus,
Bot,
Camera,
@@ -44,28 +42,6 @@ export interface WidgetDefinition {
/** All available widget definitions. */
export const WIDGET_DEFINITIONS: WidgetDefinition[] = [
// Discovery
{
id: 'trends',
label: 'Trending',
description: 'Top trending hashtags with sparkline charts',
icon: TrendingUp,
defaultHeight: 320,
minHeight: 200,
maxHeight: 600,
category: 'discovery',
href: '/trends',
},
{
id: 'hot-posts',
label: 'Hot Posts',
description: 'Top posts from the Hot feed',
icon: Flame,
defaultHeight: 350,
minHeight: 200,
maxHeight: 600,
category: 'discovery',
href: '/trends',
},
{
id: 'bluesky',
label: 'Bluesky',
+2
View File
@@ -6,6 +6,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useInView } from 'react-intersection-observer';
import { CommunityCard } from '@/components/CommunityCard';
import { CommunityRightSidebar } from '@/components/CommunityRightSidebar';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { LoginArea } from '@/components/auth/LoginArea';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
@@ -81,6 +82,7 @@ export function CommunitiesPage() {
useLayoutOptions({
hasSubHeader: !!user,
rightSidebar: <CommunityRightSidebar />,
});
const [activeTab, setActiveTab] = useFeedTab<CommunitiesTab>('communities', [