Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e144832b0 | |||
| 32bf4bdab4 | |||
| a5adbf2fed | |||
| 8120162960 | |||
| 94a26d3da1 |
+1
-4
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">· {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>
|
||||
);
|
||||
}
|
||||
@@ -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">· {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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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', [
|
||||
|
||||
Reference in New Issue
Block a user