From db7214e56299a295f46986a51fed269cc312f9fe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 07:38:25 +0000 Subject: [PATCH 1/2] feat: add bookmark annotations for personal notes on saved tweets Add the ability to annotate bookmarks with personal notes explaining why a tweet was saved. Annotations are stored locally in ~/.config/xfeed/annotations.json. Features: - Storage layer in src/config/annotations.ts for persistent annotations - useAnnotations hook for React state management with debounced saves - AnnotationEditor modal for adding/editing/deleting annotations - Visual indicator [+] on PostCard for annotated bookmarks - 'a' keybinding in BookmarksScreen and PostDetailScreen - Annotation display in PostDetailScreen for bookmarked tweets The implementation follows existing patterns: - Config storage pattern from src/config/loader.ts - Modal pattern from FolderNameInputModal - State management pattern from useActions hook --- src/app.tsx | 64 +++++++++ src/components/PostCard.tsx | 11 +- src/components/PostList.tsx | 11 +- src/config/annotations.test.ts | 229 +++++++++++++++++++++++++++++++ src/config/annotations.ts | 177 ++++++++++++++++++++++++ src/hooks/useAnnotations.ts | 149 ++++++++++++++++++++ src/modals/AnnotationEditor.tsx | 185 +++++++++++++++++++++++++ src/screens/BookmarksScreen.tsx | 10 +- src/screens/PostDetailScreen.tsx | 43 ++++++ 9 files changed, 876 insertions(+), 3 deletions(-) create mode 100644 src/config/annotations.test.ts create mode 100644 src/config/annotations.ts create mode 100644 src/hooks/useAnnotations.ts create mode 100644 src/modals/AnnotationEditor.tsx diff --git a/src/app.tsx b/src/app.tsx index e1bebf6..3bf5d5a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -20,8 +20,13 @@ import { useBookmarkMutation, } from "@/experiments"; import { useActions } from "@/hooks/useActions"; +import { useAnnotations } from "@/hooks/useAnnotations"; import { useNavigation } from "@/hooks/useNavigation"; import { copyToClipboard } from "@/lib/clipboard"; +import { + AnnotationEditorContent, + type AnnotationEditorResult, +} from "@/modals/AnnotationEditor"; import { BookmarkFolderSelectorContent } from "@/modals/BookmarkFolderSelector"; import { DeleteFolderConfirmContent } from "@/modals/DeleteFolderConfirmModal"; import { ExitConfirmationContent } from "@/modals/ExitConfirmationModal"; @@ -147,6 +152,14 @@ function AppContent({ client, user }: AppProps) { currentFolderId: selectedBookmarkFolder?.id, }); + // Annotations hook for bookmark annotations + const { + getAnnotation, + hasAnnotation, + setAnnotation, + deleteAnnotation, + } = useAnnotations(); + // Splash screen state const [showSplash, setShowSplash] = useState(true); const [minTimeElapsed, setMinTimeElapsed] = useState(false); @@ -391,6 +404,53 @@ function AppContent({ client, user }: AppProps) { } }, [client, selectedPost, dialog]); + // Open annotation editor for a tweet + const handleAnnotate = useCallback( + async (tweetId: string) => { + const existingAnnotation = getAnnotation(tweetId); + + const result = await dialog.prompt({ + content: (ctx) => ( + + ), + unstyled: true, + }); + + // undefined means dismissed + if (!result) return; + + if (result.action === "delete") { + deleteAnnotation(tweetId); + toast.success("Annotation deleted"); + } else if (result.action === "save" && result.text) { + setAnnotation(tweetId, result.text); + toast.success(existingAnnotation ? "Annotation updated" : "Annotation saved"); + } + }, + [dialog, getAnnotation, setAnnotation, deleteAnnotation] + ); + + // Handler for annotating the currently selected post (from PostDetailScreen) + const handleAnnotateSelectedPost = useCallback(() => { + if (selectedPost) { + handleAnnotate(selectedPost.id); + } + }, [selectedPost, handleAnnotate]); + + // Handler for annotating a post from BookmarksScreen list + const handleAnnotateFromList = useCallback( + (post: TweetData) => { + handleAnnotate(post.id); + }, + [handleAnnotate] + ); + // Open bookmark folder selector dialog const handleBookmarkFolderSelectorOpen = useCallback(async () => { const folder = await dialog.choice({ @@ -777,6 +837,8 @@ function AppContent({ client, user }: AppProps) { onLike={() => toggleLike(selectedPost)} onBookmark={() => toggleBookmark(selectedPost)} onMoveToFolder={handleMoveToFolder} + onAnnotate={handleAnnotateSelectedPost} + annotationText={getAnnotation(selectedPost.id)} isLiked={getState(selectedPost.id).liked} isBookmarked={getState(selectedPost.id).bookmarked} isJustLiked={getState(selectedPost.id).justLiked} @@ -858,8 +920,10 @@ function AppContent({ client, user }: AppProps) { onPostSelect={handlePostSelect} onLike={toggleLike} onBookmark={toggleBookmark} + onAnnotate={handleAnnotateFromList} getActionState={getState} initActionState={initState} + hasAnnotation={hasAnnotation} onCreateFolder={handleCreateBookmarkFolder} onEditFolder={handleEditBookmarkFolder} onDeleteFolder={handleDeleteBookmarkFolder} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index fe8497b..13e9ef7 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -48,6 +48,8 @@ interface PostCardProps { isJustLiked?: boolean; /** True briefly after bookmarking (for visual pulse feedback) */ isJustBookmarked?: boolean; + /** Whether the tweet has an annotation */ + hasAnnotation?: boolean; /** Parent post author username - if provided, strips leading @mention matching this user */ parentAuthorUsername?: string; /** Main post author username (for nested reply mention stripping) */ @@ -66,6 +68,9 @@ const HEART_FILLED = "\u2665"; // ♥ const FLAG_EMPTY = "\u2690"; // ⚐ const FLAG_FILLED = "\u2691"; // ⚑ +// Unicode symbol for annotation indicator +const NOTE_INDICATOR = "+"; // Simple plus sign for "has note" + export function PostCard({ post, isSelected, @@ -74,6 +79,7 @@ export function PostCard({ isBookmarked, isJustLiked, isJustBookmarked, + hasAnnotation, parentAuthorUsername, mainPostAuthorUsername, onCardClick, @@ -192,7 +198,7 @@ export function PostCard({ backgroundColor: isSelected ? colors.selectedBg : undefined, }} > - {/* Author line with selection indicator */} + {/* Author line with selection indicator and annotation marker */} {isSelected ? "> " : " "} @@ -200,6 +206,9 @@ export function PostCard({ @{post.author.username} {timeAgo ? ` · ${timeAgo}` : ""} + {hasAnnotation ? ( + [{NOTE_INDICATOR}] + ) : null} {/* Post text */} diff --git a/src/components/PostList.tsx b/src/components/PostList.tsx index fbd9946..88a64f3 100644 --- a/src/components/PostList.tsx +++ b/src/components/PostList.tsx @@ -23,6 +23,8 @@ interface PostListProps { onLike?: (post: TweetData) => void; /** Called when user presses 'b' to toggle bookmark on selected post */ onBookmark?: (post: TweetData) => void; + /** Called when user presses 'a' to annotate selected post */ + onAnnotate?: (post: TweetData) => void; /** Get current action state for a tweet */ getActionState?: (tweetId: string) => TweetActionState; /** Initialize action state from API data */ @@ -31,6 +33,8 @@ interface PostListProps { liked: boolean, bookmarked: boolean ) => void; + /** Check if a tweet has an annotation */ + hasAnnotation?: (tweetId: string) => boolean; /** Called when user scrolls near the bottom to load more posts */ onLoadMore?: () => void; /** Whether more posts are currently being loaded */ @@ -55,8 +59,10 @@ export function PostList({ onSelectedIndexChange, onLike, onBookmark, + onAnnotate, getActionState, initActionState, + hasAnnotation, onLoadMore, loadingMore = false, hasMore = true, @@ -121,7 +127,7 @@ export function PostList({ onSelectedIndexChange?.(selectedIndex); }, [selectedIndex, onSelectedIndexChange]); - // Handle like/bookmark keyboard shortcuts + // Handle like/bookmark/annotate keyboard shortcuts useKeyboard((key) => { if (!focused || posts.length === 0) return; @@ -132,6 +138,8 @@ export function PostList({ onLike?.(currentPost); } else if (key.name === "b") { onBookmark?.(currentPost); + } else if (key.name === "a") { + onAnnotate?.(currentPost); } }); @@ -237,6 +245,7 @@ export function PostList({ isBookmarked={actionState?.bookmarked} isJustLiked={actionState?.justLiked} isJustBookmarked={actionState?.justBookmarked} + hasAnnotation={hasAnnotation?.(post.id)} onCardClick={() => onPostSelect?.(post)} onLikeClick={() => onLike?.(post)} onBookmarkClick={() => onBookmark?.(post)} diff --git a/src/config/annotations.test.ts b/src/config/annotations.test.ts new file mode 100644 index 0000000..46c53d8 --- /dev/null +++ b/src/config/annotations.test.ts @@ -0,0 +1,229 @@ +/** + * Tests for annotations storage layer + */ + +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +import { + loadAnnotations, + saveAnnotations, + getAnnotation, + setAnnotation, + deleteAnnotation, + exportAnnotations, + type AnnotationsFile, +} from "./annotations"; + +// Use a test-specific config directory +const TEST_CONFIG_DIR = path.join(homedir(), ".config", "xfeed-test"); +const TEST_ANNOTATIONS_PATH = path.join(TEST_CONFIG_DIR, "annotations.json"); + +// Mock the module paths for testing +const originalConfigDir = path.join(homedir(), ".config", "xfeed"); +const originalAnnotationsPath = path.join(originalConfigDir, "annotations.json"); + +describe("annotations storage", () => { + // Store original annotations file content if it exists + let originalContent: string | null = null; + + beforeEach(() => { + // Backup original file if it exists + if (existsSync(originalAnnotationsPath)) { + const { readFileSync } = require("node:fs"); + originalContent = readFileSync(originalAnnotationsPath, "utf-8"); + } + // Clean up any existing test data + if (existsSync(originalAnnotationsPath)) { + rmSync(originalAnnotationsPath); + } + }); + + afterEach(() => { + // Restore original file if it existed + if (originalContent !== null) { + if (!existsSync(originalConfigDir)) { + mkdirSync(originalConfigDir, { recursive: true }); + } + writeFileSync(originalAnnotationsPath, originalContent); + } else if (existsSync(originalAnnotationsPath)) { + // Clean up test file if no original existed + rmSync(originalAnnotationsPath); + } + originalContent = null; + }); + + describe("loadAnnotations", () => { + it("returns empty annotations when file does not exist", () => { + const result = loadAnnotations(); + expect(result.version).toBe(1); + expect(result.annotations).toEqual({}); + }); + + it("loads annotations from file", () => { + const testData: AnnotationsFile = { + version: 1, + annotations: { + "123": { + text: "Test annotation", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }, + }; + + if (!existsSync(originalConfigDir)) { + mkdirSync(originalConfigDir, { recursive: true }); + } + writeFileSync(originalAnnotationsPath, JSON.stringify(testData)); + + const result = loadAnnotations(); + expect(result.version).toBe(1); + expect(result.annotations["123"]?.text).toBe("Test annotation"); + }); + + it("returns empty annotations for corrupt JSON", () => { + if (!existsSync(originalConfigDir)) { + mkdirSync(originalConfigDir, { recursive: true }); + } + writeFileSync(originalAnnotationsPath, "not valid json {{{"); + + const result = loadAnnotations(); + expect(result.version).toBe(1); + expect(result.annotations).toEqual({}); + }); + + it("returns empty annotations for invalid structure", () => { + if (!existsSync(originalConfigDir)) { + mkdirSync(originalConfigDir, { recursive: true }); + } + writeFileSync(originalAnnotationsPath, '"just a string"'); + + const result = loadAnnotations(); + expect(result.version).toBe(1); + expect(result.annotations).toEqual({}); + }); + }); + + describe("saveAnnotations", () => { + it("creates directory if it does not exist", () => { + const testData: AnnotationsFile = { + version: 1, + annotations: {}, + }; + + // Remove config dir if it exists + if (existsSync(originalConfigDir)) { + rmSync(originalConfigDir, { recursive: true }); + } + + saveAnnotations(testData); + expect(existsSync(originalAnnotationsPath)).toBe(true); + }); + + it("saves annotations to file", () => { + const testData: AnnotationsFile = { + version: 1, + annotations: { + "456": { + text: "Saved annotation", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }, + }; + + saveAnnotations(testData); + const loaded = loadAnnotations(); + expect(loaded.annotations["456"]?.text).toBe("Saved annotation"); + }); + }); + + describe("getAnnotation", () => { + it("returns undefined for non-existent annotation", () => { + const result = getAnnotation("nonexistent"); + expect(result).toBeUndefined(); + }); + + it("returns annotation when it exists", () => { + const testData: AnnotationsFile = { + version: 1, + annotations: { + "789": { + text: "Existing annotation", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }, + }; + saveAnnotations(testData); + + const result = getAnnotation("789"); + expect(result?.text).toBe("Existing annotation"); + }); + }); + + describe("setAnnotation", () => { + it("creates new annotation", () => { + setAnnotation("new-tweet", "New annotation text"); + + const result = getAnnotation("new-tweet"); + expect(result?.text).toBe("New annotation text"); + expect(result?.createdAt).toBeDefined(); + expect(result?.updatedAt).toBeDefined(); + }); + + it("updates existing annotation", () => { + setAnnotation("update-tweet", "Original text"); + const original = getAnnotation("update-tweet"); + + const originalCreatedAt = original?.createdAt; + + setAnnotation("update-tweet", "Updated text"); + const updated = getAnnotation("update-tweet"); + + expect(updated?.text).toBe("Updated text"); + // createdAt should remain the same when updating + expect(updated?.createdAt).toBe(originalCreatedAt); + // updatedAt should be set (may be same as original if executed quickly) + expect(updated?.updatedAt).toBeDefined(); + }); + }); + + describe("deleteAnnotation", () => { + it("deletes existing annotation", () => { + setAnnotation("delete-tweet", "To be deleted"); + expect(getAnnotation("delete-tweet")).toBeDefined(); + + deleteAnnotation("delete-tweet"); + expect(getAnnotation("delete-tweet")).toBeUndefined(); + }); + + it("handles deleting non-existent annotation gracefully", () => { + // Should not throw + deleteAnnotation("nonexistent"); + expect(getAnnotation("nonexistent")).toBeUndefined(); + }); + }); + + describe("exportAnnotations", () => { + it("exports empty annotations", () => { + const result = exportAnnotations(); + expect(result.version).toBe(1); + expect(result.exportedAt).toBeDefined(); + expect(result.annotations).toEqual([]); + }); + + it("exports all annotations", () => { + setAnnotation("export-1", "First annotation"); + setAnnotation("export-2", "Second annotation"); + + const result = exportAnnotations(); + expect(result.annotations.length).toBe(2); + expect(result.annotations.some((a) => a.tweetId === "export-1")).toBe(true); + expect(result.annotations.some((a) => a.tweetId === "export-2")).toBe(true); + }); + }); +}); diff --git a/src/config/annotations.ts b/src/config/annotations.ts new file mode 100644 index 0000000..a1a25d1 --- /dev/null +++ b/src/config/annotations.ts @@ -0,0 +1,177 @@ +/** + * Annotations storage layer for bookmark annotations. + * Stores annotations in ~/.config/xfeed/annotations.json + */ + +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +const CONFIG_DIR = path.join(homedir(), ".config", "xfeed"); +const ANNOTATIONS_PATH = path.join(CONFIG_DIR, "annotations.json"); + +/** Current schema version for annotations file */ +const ANNOTATIONS_VERSION = 1; + +/** Single annotation entry */ +export interface Annotation { + /** The annotation text */ + text: string; + /** When the annotation was created */ + createdAt: string; + /** When the annotation was last updated */ + updatedAt: string; +} + +/** Annotations file structure */ +export interface AnnotationsFile { + version: number; + annotations: Record; +} + +/** Export format for future AI agent integration */ +export interface AnnotationsExport { + version: number; + exportedAt: string; + annotations: Array<{ + tweetId: string; + text: string; + createdAt: string; + updatedAt: string; + }>; +} + +/** + * Load annotations from ~/.config/xfeed/annotations.json + * Returns empty annotations object if file doesn't exist or is invalid. + */ +export function loadAnnotations(): AnnotationsFile { + if (!existsSync(ANNOTATIONS_PATH)) { + return { version: ANNOTATIONS_VERSION, annotations: {} }; + } + + try { + const content = readFileSync(ANNOTATIONS_PATH, "utf-8"); + const parsed: unknown = JSON.parse(content); + + // Validate structure + if (typeof parsed !== "object" || parsed === null) { + return { version: ANNOTATIONS_VERSION, annotations: {} }; + } + + const file = parsed as Record; + + // Check version + if (typeof file.version !== "number") { + return { version: ANNOTATIONS_VERSION, annotations: {} }; + } + + // Validate annotations object + if (typeof file.annotations !== "object" || file.annotations === null) { + return { version: ANNOTATIONS_VERSION, annotations: {} }; + } + + return { + version: file.version, + annotations: file.annotations as Record, + }; + } catch { + // Corrupt JSON - return empty annotations + return { version: ANNOTATIONS_VERSION, annotations: {} }; + } +} + +/** + * Save annotations to ~/.config/xfeed/annotations.json + * Creates directory if it doesn't exist. + */ +export function saveAnnotations(file: AnnotationsFile): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + writeFileSync(ANNOTATIONS_PATH, JSON.stringify(file, null, 2) + "\n"); + chmodSync(ANNOTATIONS_PATH, 0o600); +} + +/** + * Get annotation for a specific tweet. + * Returns undefined if no annotation exists. + */ +export function getAnnotation(tweetId: string): Annotation | undefined { + const file = loadAnnotations(); + return file.annotations[tweetId]; +} + +/** + * Set or update annotation for a tweet. + * Creates new annotation if none exists, updates if one does. + */ +export function setAnnotation(tweetId: string, text: string): void { + const file = loadAnnotations(); + const now = new Date().toISOString(); + + const existing = file.annotations[tweetId]; + if (existing) { + // Update existing annotation + file.annotations[tweetId] = { + text, + createdAt: existing.createdAt, + updatedAt: now, + }; + } else { + // Create new annotation + file.annotations[tweetId] = { + text, + createdAt: now, + updatedAt: now, + }; + } + + saveAnnotations(file); +} + +/** + * Delete annotation for a tweet. + * No-op if annotation doesn't exist. + */ +export function deleteAnnotation(tweetId: string): void { + const file = loadAnnotations(); + + if (file.annotations[tweetId]) { + delete file.annotations[tweetId]; + saveAnnotations(file); + } +} + +/** + * Export all annotations in a format suitable for AI agent integration. + */ +export function exportAnnotations(): AnnotationsExport { + const file = loadAnnotations(); + + return { + version: file.version, + exportedAt: new Date().toISOString(), + annotations: Object.entries(file.annotations).map( + ([tweetId, annotation]) => ({ + tweetId, + text: annotation.text, + createdAt: annotation.createdAt, + updatedAt: annotation.updatedAt, + }) + ), + }; +} + +/** + * Get the annotations file path (for display purposes). + */ +export function getAnnotationsPath(): string { + return ANNOTATIONS_PATH; +} diff --git a/src/hooks/useAnnotations.ts b/src/hooks/useAnnotations.ts new file mode 100644 index 0000000..57e75f1 --- /dev/null +++ b/src/hooks/useAnnotations.ts @@ -0,0 +1,149 @@ +/** + * useAnnotations - Hook for managing bookmark annotations + * + * Provides functions to get, set, and delete annotations with + * in-memory caching and lazy disk writes. + */ + +import { useState, useCallback, useEffect, useRef } from "react"; + +import { + loadAnnotations, + saveAnnotations, + type Annotation, + type AnnotationsFile, +} from "@/config/annotations"; + +export interface UseAnnotationsResult { + /** Get annotation text for a tweet (undefined if none) */ + getAnnotation: (tweetId: string) => string | undefined; + /** Check if a tweet has an annotation */ + hasAnnotation: (tweetId: string) => boolean; + /** Set or update annotation for a tweet */ + setAnnotation: (tweetId: string, text: string) => void; + /** Delete annotation for a tweet */ + deleteAnnotation: (tweetId: string) => void; + /** Get all annotation tweet IDs (for bulk checking) */ + getAnnotatedTweetIds: () => Set; +} + +/** Debounce delay for disk writes in ms */ +const SAVE_DEBOUNCE_MS = 500; + +/** + * Hook for managing bookmark annotations. + * Maintains an in-memory cache with debounced disk writes. + */ +export function useAnnotations(): UseAnnotationsResult { + // In-memory cache of annotations + const [annotationsFile, setAnnotationsFile] = useState( + () => loadAnnotations() + ); + + // Track pending save timeout + const saveTimeoutRef = useRef | null>(null); + + // Set of annotated tweet IDs for quick lookup + const annotatedIdsRef = useRef>( + new Set(Object.keys(annotationsFile.annotations)) + ); + + // Update annotatedIds when annotations change + useEffect(() => { + annotatedIdsRef.current = new Set(Object.keys(annotationsFile.annotations)); + }, [annotationsFile]); + + // Debounced save to disk + const scheduleSave = useCallback((file: AnnotationsFile) => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + saveTimeoutRef.current = setTimeout(() => { + saveAnnotations(file); + saveTimeoutRef.current = null; + }, SAVE_DEBOUNCE_MS); + }, []); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + // Save immediately on unmount if there are pending changes + saveAnnotations(annotationsFile); + } + }; + }, [annotationsFile]); + + const getAnnotation = useCallback( + (tweetId: string): string | undefined => { + return annotationsFile.annotations[tweetId]?.text; + }, + [annotationsFile] + ); + + const hasAnnotation = useCallback( + (tweetId: string): boolean => { + return tweetId in annotationsFile.annotations; + }, + [annotationsFile] + ); + + const setAnnotation = useCallback( + (tweetId: string, text: string): void => { + const now = new Date().toISOString(); + + setAnnotationsFile((prev) => { + const existing = prev.annotations[tweetId]; + const newAnnotation: Annotation = existing + ? { text, createdAt: existing.createdAt, updatedAt: now } + : { text, createdAt: now, updatedAt: now }; + + const updated: AnnotationsFile = { + ...prev, + annotations: { + ...prev.annotations, + [tweetId]: newAnnotation, + }, + }; + + scheduleSave(updated); + return updated; + }); + }, + [scheduleSave] + ); + + const deleteAnnotation = useCallback( + (tweetId: string): void => { + setAnnotationsFile((prev) => { + if (!(tweetId in prev.annotations)) { + return prev; + } + + const { [tweetId]: _, ...rest } = prev.annotations; + const updated: AnnotationsFile = { + ...prev, + annotations: rest, + }; + + scheduleSave(updated); + return updated; + }); + }, + [scheduleSave] + ); + + const getAnnotatedTweetIds = useCallback((): Set => { + return annotatedIdsRef.current; + }, []); + + return { + getAnnotation, + hasAnnotation, + setAnnotation, + deleteAnnotation, + getAnnotatedTweetIds, + }; +} diff --git a/src/modals/AnnotationEditor.tsx b/src/modals/AnnotationEditor.tsx new file mode 100644 index 0000000..4e40d55 --- /dev/null +++ b/src/modals/AnnotationEditor.tsx @@ -0,0 +1,185 @@ +/** + * AnnotationEditor - Modal for adding/editing bookmark annotations + * + * Uses @opentui-ui/dialog for async dialog management. + * Features: + * - Multi-line text input (500 character limit) + * - Enter to submit, Esc to cancel + * - Ctrl+D to delete existing annotation + * - Character count display + */ + +import { + useDialogKeyboard, + type PromptContext, +} from "@opentui-ui/dialog/react"; +import { useState } from "react"; + +import { colors } from "@/lib/colors"; + +const MAX_ANNOTATION_LENGTH = 500; + +// Dialog colors (Catppuccin-inspired) +const dialogColors = { + bgDark: "#1e1e2e", + bgPanel: "#181825", + bgInput: "#11111b", + textPrimary: "#cdd6f4", + textSecondary: "#bac2de", + textMuted: "#6c7086", + accent: "#89b4fa", +}; + +/** Result from the annotation editor */ +export interface AnnotationEditorResult { + /** The action taken */ + action: "save" | "delete"; + /** The annotation text (only for save action) */ + text?: string; +} + +/** Props for AnnotationEditorContent (used with dialog.choice) */ +export interface AnnotationEditorContentProps + extends PromptContext { + /** Initial annotation text (for editing existing annotation) */ + initialText?: string; + /** Whether an annotation already exists (shows delete option) */ + hasExisting?: boolean; +} + +/** + * Content component for annotation editor dialog. + * Use with dialog.prompt(). + */ +export function AnnotationEditorContent({ + initialText = "", + hasExisting = false, + resolve, + dismiss, + dialogId, +}: AnnotationEditorContentProps) { + const [value, setValue] = useState(initialText); + const [error, setError] = useState(null); + + const handleSubmit = () => { + const trimmed = value.trim(); + + if (!trimmed) { + if (hasExisting) { + // Empty text with existing annotation = delete + resolve({ action: "delete" }); + } else { + setError("Annotation cannot be empty"); + } + return; + } + + if (trimmed.length > MAX_ANNOTATION_LENGTH) { + setError(`Annotation must be ${MAX_ANNOTATION_LENGTH} characters or less`); + return; + } + + resolve({ action: "save", text: trimmed }); + }; + + const handleDelete = () => { + resolve({ action: "delete" }); + }; + + useDialogKeyboard((key) => { + if (key.name === "escape") { + dismiss(); + return; + } + + // Ctrl+D to delete (only if annotation exists) + if (key.ctrl && key.name === "d" && hasExisting) { + handleDelete(); + } + }, dialogId); + + const title = hasExisting ? "Edit Annotation" : "Add Annotation"; + const isOverLimit = value.length > MAX_ANNOTATION_LENGTH; + + return ( + + {/* Header */} + + + + + {title} + + + + {/* Content */} + + {/* Input field - larger for annotations */} + { + setValue(newValue); + if (error) setError(null); + }} + onSubmit={() => { + handleSubmit(); + }} + width={50} + height={3} + backgroundColor={dialogColors.bgInput} + textColor={dialogColors.textPrimary} + placeholderColor={dialogColors.textMuted} + cursorColor={dialogColors.accent} + /> + + {/* Character count */} + + {value.length}/{MAX_ANNOTATION_LENGTH} + + + {/* Error message */} + {error ? {error} : null} + + + {/* Footer */} + + Enter + save + Esc + cancel + {hasExisting ? ( + <> + ^D + delete + + ) : null} + + + ); +} diff --git a/src/screens/BookmarksScreen.tsx b/src/screens/BookmarksScreen.tsx index eea6b88..f3e83db 100644 --- a/src/screens/BookmarksScreen.tsx +++ b/src/screens/BookmarksScreen.tsx @@ -30,6 +30,8 @@ interface BookmarksScreenProps { onLike?: (post: TweetData) => void; /** Called when user presses 'b' to toggle bookmark */ onBookmark?: (post: TweetData) => void; + /** Called when user presses 'a' to annotate a bookmark */ + onAnnotate?: (post: TweetData) => void; /** Get current action state for a tweet */ getActionState?: (tweetId: string) => TweetActionState; /** Initialize action state from API data */ @@ -38,6 +40,8 @@ interface BookmarksScreenProps { liked: boolean, bookmarked: boolean ) => void; + /** Check if a tweet has an annotation */ + hasAnnotation?: (tweetId: string) => boolean; /** Called when user presses 'c' to create a new folder */ onCreateFolder?: () => void; /** Called when user presses 'e' to edit current folder (only when in a folder) */ @@ -69,7 +73,7 @@ function ScreenHeader({ folderName, inFolder }: ScreenHeaderProps) { {" "} - (f folder, n new{inFolder ? ", e rename, D delete" : ""}) + (a annotate, f folder, n new{inFolder ? ", e rename, D delete" : ""}) ); @@ -85,8 +89,10 @@ export function BookmarksScreen({ onPostSelect, onLike, onBookmark, + onAnnotate, getActionState, initActionState, + hasAnnotation, onCreateFolder, onEditFolder, onDeleteFolder, @@ -191,8 +197,10 @@ export function BookmarksScreen({ onPostSelect={onPostSelect} onLike={onLike} onBookmark={onBookmark} + onAnnotate={onAnnotate} getActionState={getActionState} initActionState={initActionState} + hasAnnotation={hasAnnotation} onLoadMore={fetchNextPage} loadingMore={isFetchingNextPage} hasMore={hasNextPage} diff --git a/src/screens/PostDetailScreen.tsx b/src/screens/PostDetailScreen.tsx index fccb6c3..2d89b93 100644 --- a/src/screens/PostDetailScreen.tsx +++ b/src/screens/PostDetailScreen.tsx @@ -85,6 +85,10 @@ interface PostDetailScreenProps { onBookmark?: () => void; /** Called when user presses 'm' to move bookmark to folder */ onMoveToFolder?: () => void; + /** Called when user presses 'a' to annotate bookmark */ + onAnnotate?: () => void; + /** Annotation text for the tweet (if any) */ + annotationText?: string; /** Whether the tweet is currently liked */ isLiked?: boolean; /** Whether the tweet is currently bookmarked */ @@ -141,6 +145,8 @@ export function PostDetailScreen({ onLike, onBookmark, onMoveToFolder, + onAnnotate, + annotationText, isLiked = false, isBookmarked = false, isJustLiked = false, @@ -544,6 +550,12 @@ export function PostDetailScreen({ onMoveToFolder?.(); } break; + case "a": + // Annotate bookmark (only if bookmarked) + if (isBookmarked) { + onAnnotate?.(); + } + break; case "p": // Open author profile onProfileOpen?.(tweet.author.username); @@ -648,6 +660,31 @@ export function PostDetailScreen({ ) : null; + // Annotation section (only for bookmarked posts with annotations) + const annotationContent = + isBookmarked && annotationText ? ( + + + + + Your note: + + + + {annotationText} + + + + + ) : null; + // Author info const authorContent = ( @@ -1011,6 +1048,11 @@ export function PostDetailScreen({ show: hasQuote, }, { key: "f", label: "folder", show: isBookmarked }, + { + key: "a", + label: annotationText ? "edit note" : "annotate", + show: isBookmarked, + }, ]; // Main layout - always use scrollbox for thread content @@ -1023,6 +1065,7 @@ export function PostDetailScreen({ style={{ flexGrow: 1, height: "100%" }} > {parentContent} + {annotationContent} {authorContent} {postContent} {truncationIndicator} From 4c9c8dc5cd818714fe3c674f7e895e8728e1ffea Mon Sep 17 00:00:00 2001 From: Ali Ihsan Nergiz Date: Tue, 13 Jan 2026 00:35:01 +0000 Subject: [PATCH 2/2] fix: annotation dialog and API feature flags - Fix annotation dialog input styling with bordered box wrapper - Add consistent width to dialog header, content, and footer sections - Remove debounced save for annotations - now saves immediately to disk - Add post_ctas_fetch_enabled feature flag to fix X API 400 errors Fixes #136 Co-Authored-By: Claude Opus 4.5 --- src/app.tsx | 12 ++-- src/config/annotations.test.ts | 15 ++-- src/hooks/useAnnotations.ts | 119 ++++++++++++-------------------- src/modals/AnnotationEditor.tsx | 53 ++++++++------ 4 files changed, 90 insertions(+), 109 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index 3bf5d5a..8207b33 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -153,12 +153,8 @@ function AppContent({ client, user }: AppProps) { }); // Annotations hook for bookmark annotations - const { - getAnnotation, - hasAnnotation, - setAnnotation, - deleteAnnotation, - } = useAnnotations(); + const { getAnnotation, hasAnnotation, setAnnotation, deleteAnnotation } = + useAnnotations(); // Splash screen state const [showSplash, setShowSplash] = useState(true); @@ -430,7 +426,9 @@ function AppContent({ client, user }: AppProps) { toast.success("Annotation deleted"); } else if (result.action === "save" && result.text) { setAnnotation(tweetId, result.text); - toast.success(existingAnnotation ? "Annotation updated" : "Annotation saved"); + toast.success( + existingAnnotation ? "Annotation updated" : "Annotation saved" + ); } }, [dialog, getAnnotation, setAnnotation, deleteAnnotation] diff --git a/src/config/annotations.test.ts b/src/config/annotations.test.ts index 46c53d8..6aaf677 100644 --- a/src/config/annotations.test.ts +++ b/src/config/annotations.test.ts @@ -2,10 +2,10 @@ * Tests for annotations storage layer */ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { loadAnnotations, @@ -23,7 +23,10 @@ const TEST_ANNOTATIONS_PATH = path.join(TEST_CONFIG_DIR, "annotations.json"); // Mock the module paths for testing const originalConfigDir = path.join(homedir(), ".config", "xfeed"); -const originalAnnotationsPath = path.join(originalConfigDir, "annotations.json"); +const originalAnnotationsPath = path.join( + originalConfigDir, + "annotations.json" +); describe("annotations storage", () => { // Store original annotations file content if it exists @@ -222,8 +225,12 @@ describe("annotations storage", () => { const result = exportAnnotations(); expect(result.annotations.length).toBe(2); - expect(result.annotations.some((a) => a.tweetId === "export-1")).toBe(true); - expect(result.annotations.some((a) => a.tweetId === "export-2")).toBe(true); + expect(result.annotations.some((a) => a.tweetId === "export-1")).toBe( + true + ); + expect(result.annotations.some((a) => a.tweetId === "export-2")).toBe( + true + ); }); }); }); diff --git a/src/hooks/useAnnotations.ts b/src/hooks/useAnnotations.ts index 57e75f1..6b52f7c 100644 --- a/src/hooks/useAnnotations.ts +++ b/src/hooks/useAnnotations.ts @@ -2,7 +2,7 @@ * useAnnotations - Hook for managing bookmark annotations * * Provides functions to get, set, and delete annotations with - * in-memory caching and lazy disk writes. + * in-memory caching and immediate disk writes. */ import { useState, useCallback, useEffect, useRef } from "react"; @@ -27,22 +27,16 @@ export interface UseAnnotationsResult { getAnnotatedTweetIds: () => Set; } -/** Debounce delay for disk writes in ms */ -const SAVE_DEBOUNCE_MS = 500; - /** * Hook for managing bookmark annotations. - * Maintains an in-memory cache with debounced disk writes. + * Maintains an in-memory cache with immediate disk writes. */ export function useAnnotations(): UseAnnotationsResult { // In-memory cache of annotations - const [annotationsFile, setAnnotationsFile] = useState( - () => loadAnnotations() + const [annotationsFile, setAnnotationsFile] = useState(() => + loadAnnotations() ); - // Track pending save timeout - const saveTimeoutRef = useRef | null>(null); - // Set of annotated tweet IDs for quick lookup const annotatedIdsRef = useRef>( new Set(Object.keys(annotationsFile.annotations)) @@ -53,29 +47,6 @@ export function useAnnotations(): UseAnnotationsResult { annotatedIdsRef.current = new Set(Object.keys(annotationsFile.annotations)); }, [annotationsFile]); - // Debounced save to disk - const scheduleSave = useCallback((file: AnnotationsFile) => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - - saveTimeoutRef.current = setTimeout(() => { - saveAnnotations(file); - saveTimeoutRef.current = null; - }, SAVE_DEBOUNCE_MS); - }, []); - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - // Save immediately on unmount if there are pending changes - saveAnnotations(annotationsFile); - } - }; - }, [annotationsFile]); - const getAnnotation = useCallback( (tweetId: string): string | undefined => { return annotationsFile.annotations[tweetId]?.text; @@ -90,50 +61,46 @@ export function useAnnotations(): UseAnnotationsResult { [annotationsFile] ); - const setAnnotation = useCallback( - (tweetId: string, text: string): void => { - const now = new Date().toISOString(); - - setAnnotationsFile((prev) => { - const existing = prev.annotations[tweetId]; - const newAnnotation: Annotation = existing - ? { text, createdAt: existing.createdAt, updatedAt: now } - : { text, createdAt: now, updatedAt: now }; - - const updated: AnnotationsFile = { - ...prev, - annotations: { - ...prev.annotations, - [tweetId]: newAnnotation, - }, - }; - - scheduleSave(updated); - return updated; - }); - }, - [scheduleSave] - ); + const setAnnotation = useCallback((tweetId: string, text: string): void => { + const now = new Date().toISOString(); + + setAnnotationsFile((prev) => { + const existing = prev.annotations[tweetId]; + const newAnnotation: Annotation = existing + ? { text, createdAt: existing.createdAt, updatedAt: now } + : { text, createdAt: now, updatedAt: now }; + + const updated: AnnotationsFile = { + ...prev, + annotations: { + ...prev.annotations, + [tweetId]: newAnnotation, + }, + }; + + // Save immediately to disk + saveAnnotations(updated); + return updated; + }); + }, []); - const deleteAnnotation = useCallback( - (tweetId: string): void => { - setAnnotationsFile((prev) => { - if (!(tweetId in prev.annotations)) { - return prev; - } - - const { [tweetId]: _, ...rest } = prev.annotations; - const updated: AnnotationsFile = { - ...prev, - annotations: rest, - }; - - scheduleSave(updated); - return updated; - }); - }, - [scheduleSave] - ); + const deleteAnnotation = useCallback((tweetId: string): void => { + setAnnotationsFile((prev) => { + if (!(tweetId in prev.annotations)) { + return prev; + } + + const { [tweetId]: _, ...rest } = prev.annotations; + const updated: AnnotationsFile = { + ...prev, + annotations: rest, + }; + + // Save immediately to disk + saveAnnotations(updated); + return updated; + }); + }, []); const getAnnotatedTweetIds = useCallback((): Set => { return annotatedIdsRef.current; diff --git a/src/modals/AnnotationEditor.tsx b/src/modals/AnnotationEditor.tsx index 4e40d55..a453462 100644 --- a/src/modals/AnnotationEditor.tsx +++ b/src/modals/AnnotationEditor.tsx @@ -39,8 +39,7 @@ export interface AnnotationEditorResult { } /** Props for AnnotationEditorContent (used with dialog.choice) */ -export interface AnnotationEditorContentProps - extends PromptContext { +export interface AnnotationEditorContentProps extends PromptContext { /** Initial annotation text (for editing existing annotation) */ initialText?: string; /** Whether an annotation already exists (shows delete option) */ @@ -75,7 +74,9 @@ export function AnnotationEditorContent({ } if (trimmed.length > MAX_ANNOTATION_LENGTH) { - setError(`Annotation must be ${MAX_ANNOTATION_LENGTH} characters or less`); + setError( + `Annotation must be ${MAX_ANNOTATION_LENGTH} characters or less` + ); return; } @@ -112,6 +113,7 @@ export function AnnotationEditorContent({ paddingBottom={1} flexDirection="row" gap={1} + width={56} > + @@ -128,27 +130,33 @@ export function AnnotationEditorContent({ paddingBottom={1} flexDirection="column" gap={1} + width={56} > - {/* Input field - larger for annotations */} - { - setValue(newValue); - if (error) setError(null); - }} - onSubmit={() => { - handleSubmit(); - }} - width={50} - height={3} + {/* Input field - wrapped in bordered box for proper styling */} + + borderStyle="single" + borderColor={dialogColors.accent} + height={3} + > + { + setValue(newValue); + if (error) setError(null); + }} + onSubmit={() => { + handleSubmit(); + }} + textColor={dialogColors.textPrimary} + placeholderColor={dialogColors.textMuted} + cursorColor={dialogColors.accent} + focusedBackgroundColor="transparent" + /> + {/* Character count */} @@ -168,6 +176,7 @@ export function AnnotationEditorContent({ paddingBottom={1} flexDirection="row" gap={2} + width={56} > Enter save