diff --git a/src/App.tsx b/src/App.tsx
index ed958194..092ecfa5 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -152,6 +152,10 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
+ sidebarWidgets: [
+ { id: 'trends' },
+ { id: 'blobbi' },
+ ],
};
/**
diff --git a/src/components/MainLayout.tsx b/src/components/MainLayout.tsx
index ba97987c..6afda81b 100644
--- a/src/components/MainLayout.tsx
+++ b/src/components/MainLayout.tsx
@@ -1,4 +1,4 @@
-import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
+import { Suspense, useState, useMemo, useCallback, useRef, lazy } from 'react';
import { Outlet } from 'react-router-dom';
import { LeftSidebar } from '@/components/LeftSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
@@ -12,6 +12,8 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
+const WidgetSidebar = lazy(() => import('@/components/WidgetSidebar').then((m) => ({ default: m.WidgetSidebar })));
+
/** Skeleton shown in the content area while a lazy page chunk is loading. */
function PageSkeleton() {
return (
@@ -104,8 +106,8 @@ function MainLayoutInner() {
)}
- {/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */}
- {rightSidebar ??
}
+ {/* Right sidebar — render page-provided sidebar, or the widget sidebar */}
+ {rightSidebar ?? }>}
diff --git a/src/components/WidgetCard.tsx b/src/components/WidgetCard.tsx
new file mode 100644
index 00000000..36b8b212
--- /dev/null
+++ b/src/components/WidgetCard.tsx
@@ -0,0 +1,126 @@
+import { useState, useCallback, type ReactNode } from 'react';
+import { ChevronDown, ChevronUp, GripVertical, X } from 'lucide-react';
+
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { WidgetContext } from '@/contexts/WidgetContext';
+import { cn } from '@/lib/utils';
+import type { WidgetDefinition } from '@/lib/sidebarWidgets';
+import type { WidgetConfig } from '@/contexts/AppContext';
+
+interface WidgetCardProps {
+ definition: WidgetDefinition;
+ config: WidgetConfig;
+ onToggleCollapse: () => void;
+ onRemove: () => void;
+ onHeightChange: (height: number) => void;
+ isDragging?: boolean;
+ dragHandleProps?: Record;
+ children: ReactNode;
+}
+
+/** Wrapper for each widget in the sidebar — header, collapse, height control. */
+export function WidgetCard({
+ definition,
+ config,
+ onToggleCollapse,
+ onRemove,
+ onHeightChange,
+ isDragging,
+ dragHandleProps,
+ children,
+}: WidgetCardProps) {
+ const collapsed = config.collapsed ?? false;
+ const height = config.height ?? definition.defaultHeight;
+ const Icon = definition.icon;
+
+ // Height resize state
+ const [resizing, setResizing] = useState(false);
+
+ const handleResizeStart = useCallback((e: React.PointerEvent) => {
+ e.preventDefault();
+ setResizing(true);
+ const startY = e.clientY;
+ const startHeight = height;
+
+ const onMove = (ev: PointerEvent) => {
+ const delta = ev.clientY - startY;
+ const newHeight = Math.max(
+ definition.minHeight,
+ Math.min(definition.maxHeight, startHeight + delta),
+ );
+ onHeightChange(newHeight);
+ };
+
+ const onUp = () => {
+ setResizing(false);
+ document.removeEventListener('pointermove', onMove);
+ document.removeEventListener('pointerup', onUp);
+ };
+
+ document.addEventListener('pointermove', onMove);
+ document.addEventListener('pointerup', onUp);
+ }, [height, definition.minHeight, definition.maxHeight, onHeightChange]);
+
+ return (
+
+ {/* Header */}
+
+ {/* Drag handle */}
+
+
+ {/* Icon + label */}
+
+ {definition.label}
+
+ {/* Collapse toggle */}
+
+
+ {/* Remove */}
+
+
+
+ {/* Content */}
+ {!collapsed && (
+
+
+
+ {children}
+
+
+
+ {/* Resize handle */}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/WidgetPickerDialog.tsx b/src/components/WidgetPickerDialog.tsx
new file mode 100644
index 00000000..d95eca8e
--- /dev/null
+++ b/src/components/WidgetPickerDialog.tsx
@@ -0,0 +1,100 @@
+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 = {};
+ for (const w of WIDGET_DEFINITIONS) {
+ (groups[w.category] ??= []).push(w);
+ }
+ return groups;
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/src/components/WidgetSidebar.tsx b/src/components/WidgetSidebar.tsx
new file mode 100644
index 00000000..c85013c6
--- /dev/null
+++ b/src/components/WidgetSidebar.tsx
@@ -0,0 +1,235 @@
+import { useCallback, useMemo, useState, lazy, Suspense } from 'react';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+ useSortable,
+ arrayMove,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { Plus } from 'lucide-react';
+
+import { WidgetCard } from '@/components/WidgetCard';
+import { LinkFooter } from '@/components/LinkFooter';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useAppContext } from '@/hooks/useAppContext';
+import { getWidgetDefinition } from '@/lib/sidebarWidgets';
+import type { WidgetConfig } from '@/contexts/AppContext';
+import type { WidgetDefinition } from '@/lib/sidebarWidgets';
+
+// ── Lazy-loaded widget components ────────────────────────────────────────────
+
+const TrendingWidget = lazy(() => import('@/components/widgets/TrendingWidget').then((m) => ({ default: m.TrendingWidget })));
+const BlobbiWidget = lazy(() => import('@/components/widgets/BlobbiWidget').then((m) => ({ default: m.BlobbiWidget })));
+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 WikipediaWidget = lazy(() => import('@/components/widgets/WikipediaWidget').then((m) => ({ default: m.WikipediaWidget })));
+const BlueskyWidget = lazy(() => import('@/components/widgets/BlueskyWidget').then((m) => ({ default: m.BlueskyWidget })));
+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 ;
+ case 'blobbi':
+ return ;
+ case 'status':
+ return ;
+ case 'ai-chat':
+ return ;
+ case 'wikipedia':
+ return ;
+ case 'bluesky':
+ return ;
+ case 'feed:photos':
+ return ;
+ case 'feed:music':
+ return ;
+ case 'feed:articles':
+ return ;
+ case 'feed:events':
+ return ;
+ case 'feed:books':
+ return ;
+ default:
+ return Unknown widget.
;
+ }
+}
+
+/** Fallback while a widget component is loading. */
+function WidgetSkeleton() {
+ return (
+
+
+
+
+
+ );
+}
+
+// ── Sortable widget wrapper ──────────────────────────────────────────────────
+
+interface SortableWidgetProps {
+ config: WidgetConfig;
+ definition: WidgetDefinition;
+ onToggleCollapse: (id: string) => void;
+ onRemove: (id: string) => void;
+ onHeightChange: (id: string, height: number) => void;
+}
+
+function SortableWidget({ config, definition, onToggleCollapse, onRemove, onHeightChange }: SortableWidgetProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: config.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ zIndex: isDragging ? 10 : undefined,
+ };
+
+ return (
+
+ onToggleCollapse(config.id)}
+ onRemove={() => onRemove(config.id)}
+ onHeightChange={(h) => onHeightChange(config.id, h)}
+ isDragging={isDragging}
+ dragHandleProps={listeners}
+ >
+ }>
+
+
+
+
+ );
+}
+
+// ── Main sidebar ─────────────────────────────────────────────────────────────
+
+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(
+ () => widgets.filter((w) => getWidgetDefinition(w.id)),
+ [widgets],
+ );
+
+ const updateWidgets = useCallback((updater: (current: WidgetConfig[]) => WidgetConfig[]) => {
+ updateConfig((c) => ({
+ ...c,
+ sidebarWidgets: updater(c.sidebarWidgets ?? []),
+ }));
+ }, [updateConfig]);
+
+ const toggleCollapse = useCallback((id: string) => {
+ updateWidgets((ws) => ws.map((w) => w.id === id ? { ...w, collapsed: !w.collapsed } : w));
+ }, [updateWidgets]);
+
+ const removeWidget = useCallback((id: string) => {
+ updateWidgets((ws) => ws.filter((w) => w.id !== id));
+ }, [updateWidgets]);
+
+ const changeHeight = useCallback((id: string, height: number) => {
+ 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 } }),
+ useSensor(KeyboardSensor),
+ );
+
+ const sortableIds = useMemo(() => validWidgets.map((w) => w.id), [validWidgets]);
+
+ const handleDragEnd = useCallback((event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const oldIndex = sortableIds.indexOf(active.id as string);
+ const newIndex = sortableIds.indexOf(over.id as string);
+ if (oldIndex === -1 || newIndex === -1) return;
+ updateWidgets((ws) => arrayMove(ws, oldIndex, newIndex));
+ }, [sortableIds, updateWidgets]);
+
+ return (
+
+ );
+}
diff --git a/src/components/widgets/AIChatWidget.tsx b/src/components/widgets/AIChatWidget.tsx
new file mode 100644
index 00000000..282bb377
--- /dev/null
+++ b/src/components/widgets/AIChatWidget.tsx
@@ -0,0 +1,149 @@
+import { useState, useRef, useCallback, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { Send, Loader2, Bot, Maximize2 } from 'lucide-react';
+
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { cn } from '@/lib/utils';
+
+/** Compact AI chat widget for the sidebar. */
+export function AIChatWidget() {
+ const { user } = useCurrentUser();
+ const { sendStreamingMessage, isLoading, isAuthenticated } = useShakespeare();
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState('');
+ const [streamingContent, setStreamingContent] = useState('');
+ const scrollRef = useRef(null);
+ const inputRef = useRef(null);
+
+ const scrollToBottom = useCallback(() => {
+ const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
+ if (viewport) {
+ viewport.scrollTop = viewport.scrollHeight;
+ }
+ }, []);
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages, streamingContent, scrollToBottom]);
+
+ const handleSend = useCallback(async () => {
+ const text = input.trim();
+ if (!text || isLoading) return;
+
+ const userMessage: ChatMessage = { role: 'user', content: text };
+ const newMessages = [...messages, userMessage];
+ setMessages(newMessages);
+ setInput('');
+ setStreamingContent('');
+
+ try {
+ let accumulated = '';
+ await sendStreamingMessage(
+ newMessages,
+ 'shakespeare',
+ (chunk) => {
+ accumulated += chunk;
+ setStreamingContent(accumulated);
+ },
+ );
+ setMessages((prev) => [...prev, { role: 'assistant', content: accumulated }]);
+ setStreamingContent('');
+ } catch {
+ setMessages((prev) => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }]);
+ setStreamingContent('');
+ }
+ }, [input, isLoading, messages, sendStreamingMessage]);
+
+ if (!user || !isAuthenticated) {
+ return (
+
+
+
Log in to chat with AI
+
+ );
+ }
+
+ return (
+
+ {/* Messages area */}
+
+
+ {messages.length === 0 && !streamingContent && (
+
+ )}
+ {messages.map((msg, i) => (
+
+ ))}
+ {streamingContent && (
+
+ )}
+ {isLoading && !streamingContent && (
+
+ )}
+
+
+
+ {/* Input area */}
+
+
+
+
+
+
+ Full chat
+
+
+
+
+ );
+}
+
+function MessageBubble({ message }: { message: ChatMessage }) {
+ const isUser = message.role === 'user';
+ const content = typeof message.content === 'string' ? message.content : message.content.map((c) => c.text ?? '').join('');
+
+ return (
+
+ );
+}
diff --git a/src/components/widgets/BlobbiWidget.tsx b/src/components/widgets/BlobbiWidget.tsx
new file mode 100644
index 00000000..72ecdd4f
--- /dev/null
+++ b/src/components/widgets/BlobbiWidget.tsx
@@ -0,0 +1,131 @@
+import { useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import { Egg } from 'lucide-react';
+
+import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
+import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
+import { useBlobbisCollection } from '@/blobbi/core/hooks/useBlobbisCollection';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { Skeleton } from '@/components/ui/skeleton';
+import { cn } from '@/lib/utils';
+
+/** Map stat keys to Tailwind color classes. */
+const STAT_COLORS: Record = {
+ hunger: 'bg-orange-500',
+ happiness: 'bg-yellow-500',
+ health: 'bg-green-500',
+ hygiene: 'bg-blue-500',
+ energy: 'bg-violet-500',
+};
+
+const STAT_LABELS: Record = {
+ hunger: 'Hunger',
+ happiness: 'Happy',
+ health: 'Health',
+ hygiene: 'Hygiene',
+ energy: 'Energy',
+};
+
+/** Mini Blobbi widget showing the pet visual + stat bars. */
+export function BlobbiWidget() {
+ const { user } = useCurrentUser();
+ const { companions, isLoading } = useBlobbisCollection();
+
+ // Use the first (active) companion
+ const companion = useMemo(() => {
+ if (!companions || companions.length === 0) return null;
+ // Prefer active companions, then most recently interacted
+ return companions.find((c) => c.state === 'active') ?? companions[0];
+ }, [companions]);
+
+ if (!user) {
+ return (
+
+
+
+
+ Log in to hatch your Blobbi
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!companion) {
+ return (
+
+
+
+
+ Hatch your Blobbi
+ Get your virtual pet companion
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+function BlobbiWidgetContent({ companion }: { companion: ReturnType['companions'][number] }) {
+ const projected = useProjectedBlobbiState(companion);
+
+ return (
+
+ {/* Pet visual */}
+
+
+
+
+ {/* Name */}
+
{companion.name}
+
+ {/* Stat bars */}
+ {projected && projected.visibleStats.length > 0 && (
+
+ {projected.visibleStats.map(({ stat, value, status }) => (
+
+
+ {STAT_LABELS[stat] ?? stat}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/widgets/BlueskyWidget.tsx b/src/components/widgets/BlueskyWidget.tsx
new file mode 100644
index 00000000..4870872a
--- /dev/null
+++ b/src/components/widgets/BlueskyWidget.tsx
@@ -0,0 +1,96 @@
+import { useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import { MessageCircle, Repeat2, Heart } from 'lucide-react';
+
+import { useBlueskyTrending, type BlueskyPost } from '@/hooks/useBlueskyTrending';
+import { Skeleton } from '@/components/ui/skeleton';
+import { formatNumber } from '@/lib/formatNumber';
+
+/** Bluesky trending posts widget for the sidebar. */
+export function BlueskyWidget() {
+ const { data, isLoading } = useBlueskyTrending();
+
+ const posts = useMemo(() => {
+ if (!data?.pages) return [];
+ return data.pages.flatMap((p) => p.posts).slice(0, 5);
+ }, [data]);
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (posts.length === 0) {
+ return No trending posts right now.
;
+ }
+
+ return (
+
+ {posts.map((post) => (
+
+ ))}
+
+ View more on Bluesky
+
+
+ );
+}
+
+function BlueskyPostCard({ post }: { post: BlueskyPost }) {
+ const text = post.record.text;
+ const snippet = text.length > 120 ? text.slice(0, 120) + '...' : text;
+ const webUrl = `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`;
+ const internalUrl = `/i/${encodeURIComponent(webUrl)}`;
+
+ const timeAgo = useMemo(() => {
+ const diff = Date.now() - new Date(post.indexedAt).getTime();
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 60) return `${mins}m`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ return `${days}d`;
+ }, [post.indexedAt]);
+
+ return (
+
+ {/* Author line */}
+
+ {post.author.avatar ? (
+

+ ) : (
+
+ {(post.author.displayName ?? post.author.handle).charAt(0).toUpperCase()}
+
+ )}
+
{post.author.displayName ?? post.author.handle}
+
· {timeAgo}
+
+
+ {/* Content */}
+ {snippet}
+
+ {/* Engagement */}
+
+ {formatNumber(post.replyCount)}
+ {formatNumber(post.repostCount)}
+ {formatNumber(post.likeCount)}
+
+
+ );
+}
diff --git a/src/components/widgets/FeedWidget.tsx b/src/components/widgets/FeedWidget.tsx
new file mode 100644
index 00000000..f29a3a8d
--- /dev/null
+++ b/src/components/widgets/FeedWidget.tsx
@@ -0,0 +1,117 @@
+/**
+ * Generic compact feed widget that queries Nostr events by kind and renders
+ * them as a compact list. Used for Photos, Music, Articles, Events, Books widgets.
+ */
+
+import { useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import { nip19 } from 'nostr-tools';
+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 { genUserName } from '@/lib/genUserName';
+import { getAvatarShape } from '@/lib/avatarShape';
+import { timeAgo } from '@/lib/timeAgo';
+
+interface FeedWidgetProps {
+ /** Event kind(s) to fetch. */
+ kinds: number[];
+ /** Link to the full feed page. */
+ feedPath: string;
+ /** Label for "View all" link. */
+ feedLabel: string;
+ /** Number of items to show. */
+ limit?: number;
+ /** Empty state message. */
+ emptyMessage?: string;
+}
+
+/** Compact feed widget showing recent events for given kind(s). */
+export function FeedWidget({ kinds, feedPath, feedLabel, limit = 5, emptyMessage = 'No content yet.' }: FeedWidgetProps) {
+ const { nostr } = useNostr();
+
+ const kindsKey = kinds.join(',');
+ const { data: events, isLoading } = useQuery({
+ queryKey: ['widget-feed', kindsKey, limit],
+ queryFn: async () => {
+ return nostr.query([{ kinds, limit: limit * 2 }]);
+ },
+ staleTime: 5 * 60_000,
+ });
+
+ const filtered = useMemo(() => (events ?? []).slice(0, limit), [events, limit]);
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (filtered.length === 0) {
+ return {emptyMessage}
;
+ }
+
+ return (
+
+ {filtered.map((event) => (
+
+ ))}
+
+ {feedLabel}
+
+
+ );
+}
+
+/** Minimal event card for sidebar widgets. */
+function CompactEventCard({ event }: { event: NostrEvent }) {
+ const author = useAuthor(event.pubkey);
+ const metadata = author.data?.metadata;
+ const avatarShape = getAvatarShape(metadata);
+ const displayName = metadata?.name || genUserName(event.pubkey);
+ const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
+
+ // Try to get a title from tags (articles, events, etc.)
+ const title = event.tags.find(([t]) => t === 'title')?.[1];
+
+ // Build a snippet from content
+ const snippet = useMemo(() => {
+ if (title) return title;
+ const clean = event.content.replace(/https?:\/\/\S+/g, '').trim();
+ if (clean.length > 100) return clean.slice(0, 100) + '...';
+ return clean || '(media)';
+ }, [event.content, title]);
+
+ return (
+
+
+
+
+
+ {displayName[0]?.toUpperCase()}
+
+
+
{displayName}
+
· {timeAgo(event.created_at)}
+
+ {snippet}
+
+ );
+}
diff --git a/src/components/widgets/StatusWidget.tsx b/src/components/widgets/StatusWidget.tsx
new file mode 100644
index 00000000..7854bc1d
--- /dev/null
+++ b/src/components/widgets/StatusWidget.tsx
@@ -0,0 +1,106 @@
+import { useState } from 'react';
+import { Loader2 } from 'lucide-react';
+
+import { Input } from '@/components/ui/input';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useUserStatus } from '@/hooks/useUserStatus';
+import { usePublishStatus } from '@/hooks/usePublishStatus';
+import { useToast } from '@/hooks/useToast';
+
+/** Compact status widget — shows current NIP-38 status, click to edit inline. */
+export function StatusWidget() {
+ const { user } = useCurrentUser();
+ const userStatus = useUserStatus(user?.pubkey);
+ const publishStatus = usePublishStatus();
+ const { toast } = useToast();
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState('');
+
+ if (!user) {
+ return (
+ Log in to set a status.
+ );
+ }
+
+ if (editing) {
+ return (
+
+
setDraft(e.target.value.slice(0, 80))}
+ placeholder="What are you up to?"
+ className="h-8 text-base md:text-sm"
+ maxLength={80}
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const text = draft.trim();
+ publishStatus.mutateAsync({ status: text }).then(() => {
+ setEditing(false);
+ setDraft('');
+ toast({ title: text ? 'Status updated' : 'Status cleared' });
+ });
+ } else if (e.key === 'Escape') {
+ setEditing(false);
+ setDraft('');
+ }
+ }}
+ />
+
+
+ {userStatus.status && (
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/widgets/TrendingWidget.tsx b/src/components/widgets/TrendingWidget.tsx
new file mode 100644
index 00000000..bbae22d7
--- /dev/null
+++ b/src/components/widgets/TrendingWidget.tsx
@@ -0,0 +1,66 @@
+import { useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import { Skeleton } from '@/components/ui/skeleton';
+import { TrendSparkline } from '@/components/RightSidebar';
+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 (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (!trendingTags || trendingTags.length === 0) {
+ return No trends available.
;
+ }
+
+ return (
+
+ {trendingTags.slice(0, 5).map((item) => (
+
+
+
#{item.tag}
+ {item.accounts > 0 && (
+
+ {formatNumber(item.accounts)} people talking
+
+ )}
+
+ {sparklinesLoading ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ View all trends
+
+
+ );
+}
diff --git a/src/components/widgets/WikipediaWidget.tsx b/src/components/widgets/WikipediaWidget.tsx
new file mode 100644
index 00000000..f809f33c
--- /dev/null
+++ b/src/components/widgets/WikipediaWidget.tsx
@@ -0,0 +1,66 @@
+import { Star, ExternalLink } from 'lucide-react';
+
+import { useWikipediaFeatured } from '@/hooks/useWikipediaFeatured';
+import { Skeleton } from '@/components/ui/skeleton';
+
+/** Wikipedia widget showing today's featured article. */
+export function WikipediaWidget() {
+ const { data: feed, isLoading } = useWikipediaFeatured();
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ const tfa = feed?.tfa;
+ if (!tfa) {
+ return No featured article today.
;
+ }
+
+ const imageUrl = tfa.originalimage?.source ?? tfa.thumbnail?.source;
+ const excerpt = tfa.extract.length > 200 ? tfa.extract.slice(0, 200) + '...' : tfa.extract;
+
+ return (
+
+ {/* Image */}
+ {imageUrl && (
+
+

+
+ )}
+
+ {/* Content */}
+
+
+
+
+ {tfa.title}
+
+
+
+ {excerpt}
+
+
+
+ Wikipedia
+
+
+
+ );
+}
diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts
index 3f16c2c7..bc1e063a 100644
--- a/src/contexts/AppContext.ts
+++ b/src/contexts/AppContext.ts
@@ -245,6 +245,18 @@ export interface AppConfig {
curatorPubkey?: string;
/** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */
sandboxDomain: string;
+ /** Ordered list of right sidebar widget configs. Each entry is a widget type ID with optional display settings. */
+ sidebarWidgets: WidgetConfig[];
+}
+
+/** Configuration for a single widget in the right sidebar. */
+export interface WidgetConfig {
+ /** Widget type identifier (e.g. "trends", "blobbi", "wikipedia", "bluesky"). */
+ id: string;
+ /** Whether the widget is collapsed by the user. */
+ collapsed?: boolean;
+ /** User-configured height in pixels. Overrides the widget's default height. */
+ height?: number;
}
export interface AppContextType {
diff --git a/src/contexts/WidgetContext.ts b/src/contexts/WidgetContext.ts
new file mode 100644
index 00000000..fb72f3a6
--- /dev/null
+++ b/src/contexts/WidgetContext.ts
@@ -0,0 +1,14 @@
+import { createContext, useContext } from 'react';
+
+/**
+ * Context that signals a component is being rendered inside a sidebar widget.
+ * Pages check `useIsWidget()` to skip PageHeader and useLayoutOptions calls.
+ */
+const WidgetContext = createContext(false);
+
+/** Returns true when the calling component is rendered inside a sidebar widget. */
+export function useIsWidget(): boolean {
+ return useContext(WidgetContext);
+}
+
+export { WidgetContext };
diff --git a/src/hooks/useEncryptedSettings.ts b/src/hooks/useEncryptedSettings.ts
index c40f25fb..65513dc5 100644
--- a/src/hooks/useEncryptedSettings.ts
+++ b/src/hooks/useEncryptedSettings.ts
@@ -5,7 +5,7 @@ import type { NostrFilter } from '@nostrify/nostrify';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from './useCurrentUser';
-import type { Theme, FeedSettings, ContentWarningPolicy, SavedFeed } from '@/contexts/AppContext';
+import type { Theme, FeedSettings, ContentWarningPolicy, SavedFeed, WidgetConfig } from '@/contexts/AppContext';
import type { ThemeConfig } from '@/themes';
import type { ContentFilter } from './useContentFilters';
import type { LetterPreferences } from '@/lib/letterTypes';
@@ -78,6 +78,8 @@ export interface EncryptedSettings {
lastSync?: number;
/** Ordered list of sidebar item IDs (built-in + extra-kind) */
sidebarOrder?: string[];
+ /** Ordered list of right sidebar widget configs. */
+ sidebarWidgets?: WidgetConfig[];
/** Sidebar item ID to display on the homepage ("/") */
homePage?: string;
/** Whether the Global feed tab is shown */
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index bb6b269a..11898a41 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -247,6 +247,11 @@ export const AppConfigSchema = z.object({
imageQuality: z.enum(['compressed', 'original']),
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
sandboxDomain: z.string().optional(),
+ sidebarWidgets: z.array(z.object({
+ id: z.string(),
+ collapsed: z.boolean().optional(),
+ height: z.number().optional(),
+ })).optional(),
});
// ─── DittoConfigSchema (build-time ditto.json) ───────────────────────
@@ -316,6 +321,11 @@ export const EncryptedSettingsSchema = z.looseObject({
}).optional(),
lastSync: z.number().optional(),
sidebarOrder: z.array(z.string()).optional(),
+ sidebarWidgets: z.array(z.object({
+ id: z.string(),
+ collapsed: z.boolean().optional(),
+ height: z.number().optional(),
+ })).optional(),
homePage: z.string().optional(),
showGlobalFeed: z.boolean().optional(),
showCommunityFeed: z.boolean().optional(),
diff --git a/src/lib/sidebarWidgets.ts b/src/lib/sidebarWidgets.ts
new file mode 100644
index 00000000..6d91b74e
--- /dev/null
+++ b/src/lib/sidebarWidgets.ts
@@ -0,0 +1,171 @@
+import type { ComponentType } from 'react';
+import {
+ TrendingUp,
+ Egg,
+ SmilePlus,
+ Bot,
+ BookOpen,
+ Camera,
+ Music,
+ CalendarDays,
+ ScrollText,
+} from 'lucide-react';
+import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
+import { BlueskyIcon } from '@/components/icons/BlueskyIcon';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+type IconComponent = ComponentType<{ className?: string }>;
+
+/** Metadata for a widget type that can be added to the right sidebar. */
+export interface WidgetDefinition {
+ /** Unique identifier matching WidgetConfig.id */
+ id: string;
+ /** Display label shown in the widget header and picker. */
+ label: string;
+ /** Short description for the widget picker. */
+ description: string;
+ /** Icon component for the widget header and picker. */
+ icon: IconComponent;
+ /** Default height in pixels. */
+ defaultHeight: number;
+ /** Minimum height in pixels. */
+ minHeight: number;
+ /** Maximum height in pixels. */
+ maxHeight: number;
+ /** Category for grouping in the picker. */
+ category: 'personal' | 'content' | 'discovery';
+}
+
+// ── Registry ──────────────────────────────────────────────────────────────────
+
+/** 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',
+ },
+ {
+ id: 'wikipedia',
+ label: 'Wikipedia',
+ description: "Today's featured article from Wikipedia",
+ icon: WikipediaIcon,
+ defaultHeight: 350,
+ minHeight: 200,
+ maxHeight: 600,
+ category: 'discovery',
+ },
+ {
+ id: 'bluesky',
+ label: 'Bluesky',
+ description: 'Trending posts from Bluesky',
+ icon: BlueskyIcon,
+ defaultHeight: 400,
+ minHeight: 250,
+ maxHeight: 700,
+ category: 'discovery',
+ },
+
+ // Personal
+ {
+ id: 'blobbi',
+ label: 'Blobbi',
+ description: 'Your virtual pet companion',
+ icon: Egg,
+ defaultHeight: 280,
+ minHeight: 200,
+ maxHeight: 500,
+ category: 'personal',
+ },
+ {
+ id: 'status',
+ label: 'Status',
+ description: 'Your current status, editable inline',
+ icon: SmilePlus,
+ defaultHeight: 80,
+ minHeight: 60,
+ maxHeight: 120,
+ category: 'personal',
+ },
+ {
+ id: 'ai-chat',
+ label: 'AI Chat',
+ description: 'Chat with Shakespeare AI',
+ icon: Bot,
+ defaultHeight: 400,
+ minHeight: 250,
+ maxHeight: 700,
+ category: 'personal',
+ },
+
+ // Content feeds
+ {
+ id: 'feed:photos',
+ label: 'Photos',
+ description: 'Recent photos from your feed',
+ icon: Camera,
+ defaultHeight: 400,
+ minHeight: 250,
+ maxHeight: 700,
+ category: 'content',
+ },
+ {
+ id: 'feed:music',
+ label: 'Music',
+ description: 'Music tracks from your feed',
+ icon: Music,
+ defaultHeight: 350,
+ minHeight: 250,
+ maxHeight: 700,
+ category: 'content',
+ },
+ {
+ id: 'feed:articles',
+ label: 'Articles',
+ description: 'Long-form articles from your feed',
+ icon: ScrollText,
+ defaultHeight: 350,
+ minHeight: 250,
+ maxHeight: 700,
+ category: 'content',
+ },
+ {
+ id: 'feed:events',
+ label: 'Events',
+ description: 'Upcoming calendar events',
+ icon: CalendarDays,
+ defaultHeight: 300,
+ minHeight: 200,
+ maxHeight: 600,
+ category: 'content',
+ },
+ {
+ id: 'feed:books',
+ label: 'Books',
+ description: 'Book reviews and recommendations',
+ icon: BookOpen,
+ defaultHeight: 350,
+ minHeight: 250,
+ maxHeight: 700,
+ category: 'content',
+ },
+];
+
+/** Lookup a widget definition by ID. */
+export function getWidgetDefinition(id: string): WidgetDefinition | undefined {
+ return WIDGET_DEFINITIONS.find((w) => w.id === id);
+}
+
+/** Category labels for display in the picker. */
+export const WIDGET_CATEGORIES: Record = {
+ personal: 'Personal',
+ content: 'Content',
+ discovery: 'Discovery',
+};
diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx
index eeaaeb98..c69daf49 100644
--- a/src/test/TestApp.tsx
+++ b/src/test/TestApp.tsx
@@ -112,6 +112,7 @@ export function TestApp({ children }: TestAppProps) {
savedFeeds: [],
imageQuality: 'compressed',
sandboxDomain: 'iframe.diy',
+ sidebarWidgets: [],
};
return (