The Appview for the kipclip.com atproto bookmarking service

feat: move readingListTag from database to PDS preferences

Store readingListTag in the com.kipclip.preferences PDS record alongside
dateFormat instead of the Turso database. This syncs the preference across
devices and keeps the database for secrets (Instapaper credentials) only.

The reading list tag input now instant-applies on blur, consistent with
the date format picker. The Instapaper trigger in the bookmarks route
fetches preferences in parallel with settings to get the tag value.

+149 -152
+3 -4
frontend/components/ReadingList.tsx
··· 104 104 readingListSelectedTags, 105 105 toggleReadingListTag, 106 106 clearReadingListFilters, 107 - settings, 107 + preferences, 108 108 } = useApp(); 109 109 110 110 if (readingListTags.length === 0) { ··· 122 122 <div className="space-y-1"> 123 123 {readingListTags.map((tag) => { 124 124 const isSelected = readingListSelectedTags.has(tag); 125 - const isReadingListTag = tag === settings.readingListTag; 125 + const isReadingListTag = tag === preferences.readingListTag; 126 126 return ( 127 127 <button 128 128 type="button" ··· 213 213 filteredReadingList, 214 214 readingListBookmarks, 215 215 totalReadingList, 216 - settings, 217 216 preferences, 218 217 readingListSearchQuery, 219 218 setReadingListSearchQuery, ··· 225 224 if (readingListBookmarks.length === 0) { 226 225 return ( 227 226 <div className="flex flex-col md:flex-row flex-1 md:overflow-hidden"> 228 - <ReadingListEmpty tagName={settings.readingListTag} /> 227 + <ReadingListEmpty tagName={preferences.readingListTag} /> 229 228 </div> 230 229 ); 231 230 }
+54 -41
frontend/components/Settings.tsx
··· 15 15 const hash = globalThis.location?.hash; 16 16 return hash === "#import" ? "import" : "general"; 17 17 }); 18 - const [readingListTag, setReadingListTag] = useState(settings.readingListTag); 18 + const [readingListTag, setReadingListTag] = useState( 19 + preferences.readingListTag, 20 + ); 19 21 const [instapaperEnabled, setInstapaperEnabled] = useState( 20 22 settings.instapaperEnabled, 21 23 ); ··· 30 32 const [error, setError] = useState<string | null>(null); 31 33 const [success, setSuccess] = useState(false); 32 34 33 - // Sync local state when settings from context change 35 + // Sync local state when settings/preferences from context change 34 36 useEffect(() => { 35 - setReadingListTag(settings.readingListTag); 37 + setReadingListTag(preferences.readingListTag); 36 38 setInstapaperEnabled(settings.instapaperEnabled); 37 39 setInstapaperUsername(settings.instapaperUsername || ""); 38 40 }, [ 39 - settings.readingListTag, 41 + preferences.readingListTag, 40 42 settings.instapaperEnabled, 41 43 settings.instapaperUsername, 42 44 ]); ··· 57 59 58 60 try { 59 61 const updates: any = { 60 - readingListTag: readingListTag.trim(), 61 62 instapaperEnabled, 62 63 }; 63 64 ··· 83 84 } 84 85 } 85 86 86 - const hasChanges = readingListTag.trim() !== settings.readingListTag || 87 - instapaperEnabled !== settings.instapaperEnabled || 87 + const hasChanges = instapaperEnabled !== settings.instapaperEnabled || 88 88 (instapaperEnabled && 89 89 instapaperUsername.trim() !== (settings.instapaperUsername || "")) || 90 90 instapaperPassword.trim().length > 0; ··· 198 198 </div> 199 199 </section> 200 200 201 - <form onSubmit={handleSubmit}> 202 - <section className="bg-white rounded-lg shadow-md p-6 space-y-6"> 203 - <div> 204 - <h3 className="text-xl font-bold text-gray-800 mb-4"> 205 - Reading List 206 - </h3> 207 - <p className="text-gray-600 mb-4"> 208 - Your Reading List shows bookmarks with a specific tag. Use 209 - it to track articles you want to read later. 201 + {/* Reading List Tag — instant apply, no save button needed */} 202 + <section className="bg-white rounded-lg shadow-md p-6 space-y-4"> 203 + <div> 204 + <h3 className="text-xl font-bold text-gray-800 mb-1"> 205 + Reading List 206 + </h3> 207 + <p className="text-gray-600 text-sm mb-4"> 208 + Your Reading List shows bookmarks with a specific tag. Use it 209 + to track articles you want to read later. 210 + </p> 211 + 212 + <div className="space-y-2"> 213 + <label 214 + htmlFor="readingListTag" 215 + className="block text-sm font-medium text-gray-700" 216 + > 217 + Reading List Tag 218 + </label> 219 + <input 220 + type="text" 221 + id="readingListTag" 222 + value={readingListTag} 223 + onChange={(e) => { 224 + const value = e.target.value; 225 + setReadingListTag(value); 226 + }} 227 + onBlur={() => { 228 + const trimmed = readingListTag.trim(); 229 + if ( 230 + trimmed.length > 0 && 231 + trimmed !== preferences.readingListTag 232 + ) { 233 + updatePreferences({ readingListTag: trimmed }); 234 + } 235 + }} 236 + placeholder="toread" 237 + className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-coral/50 focus:border-coral" 238 + style={{ 239 + "--tw-ring-color": "rgba(230, 100, 86, 0.5)", 240 + } as any} 241 + /> 242 + <p className="text-sm text-gray-500"> 243 + Bookmarks tagged with "{readingListTag || "toread"}" will 244 + appear in your Reading List. 210 245 </p> 211 - 212 - <div className="space-y-2"> 213 - <label 214 - htmlFor="readingListTag" 215 - className="block text-sm font-medium text-gray-700" 216 - > 217 - Reading List Tag 218 - </label> 219 - <input 220 - type="text" 221 - id="readingListTag" 222 - value={readingListTag} 223 - onChange={(e) => setReadingListTag(e.target.value)} 224 - placeholder="toread" 225 - className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-coral/50 focus:border-coral" 226 - style={{ 227 - "--tw-ring-color": "rgba(230, 100, 86, 0.5)", 228 - } as any} 229 - /> 230 - <p className="text-sm text-gray-500"> 231 - Bookmarks tagged with "{readingListTag || "toread"}" will 232 - appear in your Reading List. 233 - </p> 234 - </div> 235 246 </div> 236 - </section> 247 + </div> 248 + </section> 237 249 250 + <form onSubmit={handleSubmit}> 238 251 {/* Instapaper Integration Section */} 239 252 <section className="bg-white rounded-lg shadow-md p-6 space-y-6"> 240 253 <div>
+10 -7
frontend/context/AppContext.tsx
··· 32 32 } 33 33 34 34 const DEFAULT_SETTINGS: UserSettings = { 35 - readingListTag: "toread", 36 35 instapaperEnabled: false, 37 36 }; 38 37 39 38 const DEFAULT_PREFERENCES: UserPreferences = { 40 39 dateFormat: "us", 40 + readingListTag: "toread", 41 41 }; 42 42 43 43 interface AppState { ··· 198 198 if (localFormat !== "us" && pdsFormat === "us") { 199 199 // localStorage has a user-chosen value, PDS still has default. 200 200 // Keep the local value and try to push it to PDS. 201 - setPreferences({ dateFormat: localFormat }); 201 + setPreferences({ 202 + ...data.preferences, 203 + dateFormat: localFormat, 204 + }); 202 205 apiPut("/api/preferences", { dateFormat: localFormat }) 203 206 .catch(() => { 204 207 // Silently ignore — user may lack new OAuth scope ··· 312 315 313 316 // Reading list: bookmarks with the configured reading list tag 314 317 const readingListBookmarks = useMemo( 315 - () => bookmarks.filter((b) => b.tags?.includes(settings.readingListTag)), 316 - [bookmarks, settings.readingListTag], 318 + () => bookmarks.filter((b) => b.tags?.includes(preferences.readingListTag)), 319 + [bookmarks, preferences.readingListTag], 317 320 ); 318 321 319 322 // Tags that appear on reading list bookmarks ··· 323 326 // Sort to show reading list tag first, then alphabetically 324 327 const tagArray = Array.from(tagSet); 325 328 return tagArray.sort((a, b) => { 326 - if (a === settings.readingListTag) return -1; 327 - if (b === settings.readingListTag) return 1; 329 + if (a === preferences.readingListTag) return -1; 330 + if (b === preferences.readingListTag) return 1; 328 331 return a.localeCompare(b); 329 332 }); 330 - }, [readingListBookmarks, settings.readingListTag]); 333 + }, [readingListBookmarks, preferences.readingListTag]); 331 334 332 335 // Filtered reading list based on additional tag selection and search 333 336 const filteredReadingList = useMemo(() => {
+5
lexicons/com/kipclip/preferences.json
··· 16 16 "description": "Date display format preference", 17 17 "knownValues": ["us", "eu", "eu-dot", "iso", "text"] 18 18 }, 19 + "readingListTag": { 20 + "type": "string", 21 + "description": "Tag used to identify reading list bookmarks", 22 + "maxLength": 64 23 + }, 19 24 "createdAt": { 20 25 "type": "string", 21 26 "format": "datetime",
+4
lib/preferences.ts
··· 8 8 9 9 const DEFAULT_PREFERENCES: UserPreferences = { 10 10 dateFormat: "us", 11 + readingListTag: "toread", 11 12 }; 12 13 13 14 /** ··· 36 37 const data = await res.json(); 37 38 return { 38 39 dateFormat: data.value?.dateFormat || DEFAULT_PREFERENCES.dateFormat, 40 + readingListTag: data.value?.readingListTag || 41 + DEFAULT_PREFERENCES.readingListTag, 39 42 }; 40 43 } catch { 41 44 return { ...DEFAULT_PREFERENCES }; ··· 64 67 rkey: "self", 65 68 record: { 66 69 dateFormat: merged.dateFormat, 70 + readingListTag: merged.readingListTag, 67 71 createdAt: new Date().toISOString(), 68 72 }, 69 73 }),
+6 -32
lib/settings.ts
··· 7 7 import type { UserSettings } from "../shared/types.ts"; 8 8 import { decrypt, encrypt } from "./encryption.ts"; 9 9 10 - const DEFAULT_READING_LIST_TAG = "toread"; 11 - 12 10 /** 13 11 * Get user settings by DID. 14 12 * Creates default settings if none exist. ··· 17 15 // Try to get existing settings 18 16 const result = await rawDb.execute({ 19 17 sql: `SELECT 20 - reading_list_tag, 21 18 instapaper_enabled, 22 19 instapaper_username_encrypted 23 20 FROM user_settings ··· 27 24 28 25 if (result.rows && result.rows.length > 0) { 29 26 const row = result.rows[0] as (string | number | null)[]; 30 - const readingListTag = String(row[0] || DEFAULT_READING_LIST_TAG); 31 - const instapaperEnabled = row[1] === 1 || row[1] === "1"; 32 - const encryptedUsername = row[2] ? String(row[2]) : null; 27 + const instapaperEnabled = row[0] === 1 || row[0] === "1"; 28 + const encryptedUsername = row[1] ? String(row[1]) : null; 33 29 34 30 // Decrypt username if available 35 31 let instapaperUsername: string | undefined; ··· 42 38 } 43 39 44 40 return { 45 - readingListTag, 46 41 instapaperEnabled, 47 42 instapaperUsername, 48 43 }; ··· 50 45 51 46 // Create default settings for new user 52 47 await rawDb.execute({ 53 - sql: "INSERT INTO user_settings (did, reading_list_tag) VALUES (?, ?)", 54 - args: [did, DEFAULT_READING_LIST_TAG], 48 + sql: "INSERT INTO user_settings (did) VALUES (?)", 49 + args: [did], 55 50 }); 56 51 57 52 return { 58 - readingListTag: DEFAULT_READING_LIST_TAG, 59 53 instapaperEnabled: false, 60 54 }; 61 55 } ··· 68 62 did: string, 69 63 updates: Partial<UserSettings> & { instapaperPassword?: string }, 70 64 ): Promise<UserSettings> { 71 - // Validate reading list tag if provided 72 - if (updates.readingListTag !== undefined) { 73 - const tag = updates.readingListTag.trim(); 74 - if (tag.length === 0 || tag.length > 64) { 75 - throw new Error("Tag must be 1-64 characters"); 76 - } 77 - if (!/^[a-zA-Z0-9_-]+$/.test(tag)) { 78 - throw new Error( 79 - "Tag can only contain letters, numbers, dashes, and underscores", 80 - ); 81 - } 82 - updates.readingListTag = tag; 83 - } 84 - 85 65 // Validate Instapaper settings 86 66 if (updates.instapaperEnabled) { 87 67 // Check if credentials are provided or already exist ··· 122 102 const updateFields: string[] = []; 123 103 const updateValues: (string | number)[] = []; 124 104 125 - if (updates.readingListTag !== undefined) { 126 - updateFields.push("reading_list_tag = ?"); 127 - updateValues.push(updates.readingListTag); 128 - } 129 - 130 105 if (updates.instapaperEnabled !== undefined) { 131 106 updateFields.push("instapaper_enabled = ?"); 132 107 updateValues.push(updates.instapaperEnabled ? 1 : 0); ··· 166 141 167 142 await rawDb.execute({ 168 143 sql: `INSERT INTO user_settings 169 - (did, reading_list_tag, instapaper_enabled, 144 + (did, instapaper_enabled, 170 145 instapaper_username_encrypted, instapaper_password_encrypted) 171 - VALUES (?, ?, ?, ?, ?)`, 146 + VALUES (?, ?, ?, ?)`, 172 147 args: [ 173 148 did, 174 - updates.readingListTag || DEFAULT_READING_LIST_TAG, 175 149 updates.instapaperEnabled ? 1 : 0, 176 150 encryptedUsername, 177 151 encryptedPassword,
+26 -20
routes/api/bookmarks.ts
··· 22 22 setSessionCookie, 23 23 } from "../../lib/route-utils.ts"; 24 24 import { getUserSettings } from "../../lib/settings.ts"; 25 + import { getUserPreferences } from "../../lib/preferences.ts"; 25 26 import { sendToInstapaperAsync } from "../../lib/instapaper.ts"; 26 27 import type { 27 28 AddBookmarkRequest, ··· 331 332 createdAt: currentRecord.value.createdAt, 332 333 }; 333 334 334 - // Run bookmark write, annotation write, and settings fetch in parallel 335 - const [putResult, annotationWritten, settings] = await Promise.all([ 336 - oauthSession.makeRequest( 337 - "POST", 338 - `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.putRecord`, 339 - { 340 - headers: { "Content-Type": "application/json" }, 341 - body: JSON.stringify({ 342 - repo: oauthSession.did, 343 - collection: BOOKMARK_COLLECTION, 344 - rkey, 345 - record, 346 - }), 347 - }, 348 - ), 349 - writeAnnotation(oauthSession, rkey, annotation), 350 - getUserSettings(oauthSession.did), 351 - ]); 335 + // Run bookmark write, annotation write, settings + preferences fetch in parallel 336 + const [putResult, annotationWritten, settings, preferences] = 337 + await Promise.all([ 338 + oauthSession.makeRequest( 339 + "POST", 340 + `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.putRecord`, 341 + { 342 + headers: { "Content-Type": "application/json" }, 343 + body: JSON.stringify({ 344 + repo: oauthSession.did, 345 + collection: BOOKMARK_COLLECTION, 346 + rkey, 347 + record, 348 + }), 349 + }, 350 + ), 351 + writeAnnotation(oauthSession, rkey, annotation), 352 + getUserSettings(oauthSession.did), 353 + getUserPreferences(oauthSession), 354 + ]); 352 355 353 356 if (!putResult.ok) { 354 357 const errorText = await putResult.text(); ··· 397 400 note: body.note, 398 401 }; 399 402 400 - const hasReadingListTag = record.tags.includes(settings.readingListTag); 403 + const hasReadingListTag = record.tags.includes( 404 + preferences.readingListTag, 405 + ); 401 406 const hadReadingListTag = 402 - currentRecord.value.tags?.includes(settings.readingListTag) || false; 407 + currentRecord.value.tags?.includes(preferences.readingListTag) || 408 + false; 403 409 404 410 if ( 405 411 settings.instapaperEnabled && hasReadingListTag && !hadReadingListTag
+21
routes/api/preferences.ts
··· 58 58 ); 59 59 } 60 60 61 + if (body.readingListTag !== undefined) { 62 + const tag = body.readingListTag.trim(); 63 + if (tag.length === 0 || tag.length > 64) { 64 + return Response.json( 65 + { success: false, error: "Tag must be 1-64 characters" }, 66 + { status: 400 }, 67 + ); 68 + } 69 + if (!/^[a-zA-Z0-9_-]+$/.test(tag)) { 70 + return Response.json( 71 + { 72 + success: false, 73 + error: 74 + "Tag can only contain letters, numbers, dashes, and underscores", 75 + }, 76 + { status: 400 }, 77 + ); 78 + } 79 + body.readingListTag = tag; 80 + } 81 + 61 82 const preferences = await updateUserPreferences(oauthSession, body); 62 83 return setSessionCookie( 63 84 Response.json({ success: true, preferences }),
+3 -2
shared/types.ts
··· 126 126 127 127 // User settings (stored in database) 128 128 export interface UserSettings { 129 - readingListTag: string; 130 129 instapaperEnabled: boolean; 131 130 instapaperUsername?: string; // Decrypted, only in memory (never includes password) 132 131 } ··· 138 137 139 138 // Settings API update request 140 139 export interface UpdateSettingsRequest { 141 - readingListTag?: string; 142 140 instapaperEnabled?: boolean; 143 141 instapaperUsername?: string; 144 142 instapaperPassword?: string; // Only when updating credentials ··· 163 161 // User preferences (stored on PDS as com.kipclip.preferences) 164 162 export interface PreferencesRecord { 165 163 dateFormat: string; 164 + readingListTag?: string; 166 165 createdAt: string; 167 166 } 168 167 169 168 export interface UserPreferences { 170 169 dateFormat: string; 170 + readingListTag: string; 171 171 } 172 172 173 173 export interface UpdatePreferencesRequest { 174 174 dateFormat?: string; 175 + readingListTag?: string; 175 176 } 176 177 177 178 export interface UpdatePreferencesResponse {
+15
tests/preferences.test.ts
··· 49 49 assertEquals(res.status, 401); 50 50 await res.json(); // consume body 51 51 }); 52 + 53 + Deno.test("PUT /api/preferences - rejects invalid readingListTag", async () => { 54 + // Without auth this returns 401 first, so this test verifies the 55 + // endpoint exists and responds. Full validation tested via integration. 56 + const req = new Request("https://kipclip.com/api/preferences", { 57 + method: "PUT", 58 + headers: { "Content-Type": "application/json" }, 59 + body: JSON.stringify({ readingListTag: "my tag!" }), 60 + }); 61 + const res = await handler(req); 62 + 63 + // Without auth, we get 401 before validation 64 + assertEquals(res.status, 401); 65 + await res.json(); // consume body 66 + });
+2 -46
tests/settings.test.ts
··· 28 28 const req = new Request("https://kipclip.com/api/settings", { 29 29 method: "PATCH", 30 30 headers: { "Content-Type": "application/json" }, 31 - body: JSON.stringify({ readingListTag: "readlater" }), 31 + body: JSON.stringify({ instapaperEnabled: false }), 32 32 }); 33 33 const res = await handler(req); 34 34 ··· 50 50 assertEquals(res.status, 200); 51 51 const body = await res.json(); 52 52 assertExists(body.settings); 53 - assertEquals(body.settings.readingListTag, "toread"); 54 - }, 55 - }); 56 - 57 - Deno.test({ 58 - name: "PATCH /api/settings - validates tag format", 59 - async fn() { 60 - setTestSessionProvider(() => 61 - Promise.resolve(createMockSessionResult({ pdsResponses: new Map() })) 62 - ); 63 - 64 - // Test empty tag 65 - const req = new Request("https://kipclip.com/api/settings", { 66 - method: "PATCH", 67 - headers: { "Content-Type": "application/json" }, 68 - body: JSON.stringify({ readingListTag: "" }), 69 - }); 70 - const res = await handler(req); 71 - 72 - assertEquals(res.status, 400); 73 - const body = await res.json(); 74 - assertEquals(body.success, false); 75 - assertExists(body.error); 76 - }, 77 - }); 78 - 79 - Deno.test({ 80 - name: "PATCH /api/settings - validates tag characters", 81 - async fn() { 82 - setTestSessionProvider(() => 83 - Promise.resolve(createMockSessionResult({ pdsResponses: new Map() })) 84 - ); 85 - 86 - // Test tag with invalid characters 87 - const req = new Request("https://kipclip.com/api/settings", { 88 - method: "PATCH", 89 - headers: { "Content-Type": "application/json" }, 90 - body: JSON.stringify({ readingListTag: "my tag!" }), 91 - }); 92 - const res = await handler(req); 93 - 94 - assertEquals(res.status, 400); 95 - const body = await res.json(); 96 - assertEquals(body.success, false); 97 - assertExists(body.error); 53 + assertEquals(body.settings.instapaperEnabled, false); 98 54 }, 99 55 }); 100 56