🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add a bookmark implementation first version

juprodh 10341576 99c686a5

+290 -16
+4 -4
lexicons/wiki.lichen.bookmark.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "A bookmark for a note. Stored on the user's PDS, portable across appviews.", 7 + "description": "A bookmark for a wiki. Stored on the user's PDS, portable across appviews.", 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["noteRef", "createdAt"], 11 + "required": ["wikiRef", "createdAt"], 12 12 "properties": { 13 - "noteRef": { 13 + "wikiRef": { 14 14 "type": "string", 15 15 "format": "at-uri", 16 - "description": "AT-URI of the bookmarked note." 16 + "description": "AT-URI of the bookmarked wiki." 17 17 }, 18 18 "createdAt": { 19 19 "type": "string",
+2 -2
src/atproto/pds.ts
··· 122 122 agent: Agent, 123 123 did: string, 124 124 tid: string, 125 - noteRef: string, 125 + wikiRef: string, 126 126 createdAt: string, 127 127 ): Promise<PdsWriteResult> { 128 128 return putRecord(agent, did, COLLECTIONS.bookmark, tid, { 129 - noteRef, 129 + wikiRef, 130 130 createdAt, 131 131 }); 132 132 }
+25
src/firehose/handlers.ts
··· 3 3 import { COLLECTIONS } from "../lib/constants.ts"; 4 4 import { 5 5 applyRevisionFromFirehose, 6 + deleteBookmarkByUri, 6 7 deleteMembershipByUri, 7 8 deleteNoteByAtUri, 8 9 deleteRequestByUri, 9 10 deleteWikiByAtUri, 10 11 getNoteByAtUri, 11 12 getWikiByAtUri, 13 + upsertBookmark, 12 14 upsertMembership, 13 15 upsertNote, 14 16 upsertRequest, ··· 52 54 createdAt: string; 53 55 } 54 56 57 + interface BookmarkRecord { 58 + wikiRef: string; 59 + createdAt: string; 60 + } 61 + 55 62 function isValidRecord(record: unknown): record is Record<string, unknown> { 56 63 return typeof record === "object" && record !== null; 57 64 } ··· 80 87 break; 81 88 case COLLECTIONS.memberRequest: 82 89 handleMemberRequest(evt, record as unknown as MemberRequestRecord); 90 + break; 91 + case COLLECTIONS.bookmark: 92 + handleBookmark(evt, record as unknown as BookmarkRecord); 83 93 break; 84 94 } 85 95 } ··· 189 199 upsertRequest(wiki.slug, evt.did, atUri, record.createdAt); 190 200 } 191 201 202 + function handleBookmark(evt: CommitEvt, record: BookmarkRecord): void { 203 + if (!record.wikiRef || !record.createdAt) return; 204 + 205 + const atUri = evt.uri.toString(); 206 + if (!didOwnsUri(evt.did, atUri)) return; 207 + 208 + const wiki = getWikiByAtUri(record.wikiRef); 209 + if (!wiki) return; 210 + 211 + upsertBookmark(evt.did, record.wikiRef, atUri, record.createdAt); 212 + } 213 + 192 214 function handleDelete(evt: CommitEvt): void { 193 215 const atUri = evt.uri.toString(); 194 216 if (!didOwnsUri(evt.did, atUri)) return; ··· 205 227 break; 206 228 case COLLECTIONS.memberRequest: 207 229 deleteRequestByUri(atUri); 230 + break; 231 + case COLLECTIONS.bookmark: 232 + deleteBookmarkByUri(atUri); 208 233 break; 209 234 } 210 235 }
+3 -1
src/lib/i18n/en.ts
··· 104 104 bookmarks: "Bookmarks", 105 105 noOwnedWikis: "You haven't created any wikis yet.", 106 106 noCollaboratingWikis: "You're not collaborating on any wikis yet.", 107 - bookmarksComingSoon: "Bookmarks are coming soon.", 107 + noBookmarks: "No bookmarks yet.", 108 + bookmark: "Bookmark", 109 + removeBookmark: "Remove bookmark", 108 110 }, 109 111 error: { 110 112 titleRequired: "Title is required.",
+3 -1
src/lib/i18n/fr.ts
··· 105 105 bookmarks: "Signets", 106 106 noOwnedWikis: "Vous n'avez pas encore cree de wiki.", 107 107 noCollaboratingWikis: "Vous ne collaborez sur aucun wiki pour le moment.", 108 - bookmarksComingSoon: "Les signets arrivent bientot.", 108 + noBookmarks: "Aucun signet pour le moment.", 109 + bookmark: "Ajouter aux signets", 110 + removeBookmark: "Retirer des signets", 109 111 }, 110 112 error: { 111 113 titleRequired: "Le titre est requis.",
+3 -1
src/lib/i18n/index.ts
··· 105 105 bookmarks: string; 106 106 noOwnedWikis: string; 107 107 noCollaboratingWikis: string; 108 - bookmarksComingSoon: string; 108 + noBookmarks: string; 109 + bookmark: string; 110 + removeBookmark: string; 109 111 }; 110 112 error: { 111 113 titleRequired: string;
+53
src/lib/orchestrators/bookmark.ts
··· 1 + import { deleteRecord, writeBookmarkRecord } from "../../atproto/pds.ts"; 2 + import { getAgent, type Session } from "../../atproto/session.ts"; 3 + import { 4 + deleteBookmarkByWiki, 5 + upsertBookmark, 6 + } from "../../server/db/queries/index.ts"; 7 + import { parseAtUri } from "../at-uri.ts"; 8 + import { COLLECTIONS } from "../constants.ts"; 9 + import { currentTimestamp, generateTid } from "../tid.ts"; 10 + import { withPdsError } from "./helpers.ts"; 11 + 12 + export async function addBookmarkAction( 13 + did: string, 14 + wikiAtUri: string, 15 + session: Session | null, 16 + ): Promise<void> { 17 + const now = currentTimestamp(); 18 + const tid = generateTid(); 19 + const atUri = `at://${did}/${COLLECTIONS.bookmark}/${tid}`; 20 + 21 + if (session) { 22 + await withPdsError("add bookmark", async () => { 23 + const agent = await getAgent(session); 24 + await writeBookmarkRecord(agent, session.did, tid, wikiAtUri, now); 25 + }); 26 + } 27 + 28 + upsertBookmark(did, wikiAtUri, atUri, now); 29 + } 30 + 31 + export async function removeBookmarkAction( 32 + did: string, 33 + wikiAtUri: string, 34 + session: Session | null, 35 + ): Promise<void> { 36 + const bookmarkAtUri = deleteBookmarkByWiki(did, wikiAtUri); 37 + if (!bookmarkAtUri) return; 38 + 39 + if (session) { 40 + const parsed = parseAtUri(bookmarkAtUri); 41 + if (parsed) { 42 + await withPdsError("remove bookmark", async () => { 43 + const agent = await getAgent(session); 44 + await deleteRecord( 45 + agent, 46 + parsed.did, 47 + COLLECTIONS.bookmark, 48 + parsed.rkey, 49 + ); 50 + }); 51 + } 52 + } 53 + }
+9 -3
src/lib/profile.ts
··· 37 37 * for profile data. This can be swapped when the community standardizes profile 38 38 * resolution across appviews. 39 39 */ 40 - export async function resolveProfile(did: string): Promise<ProfileInfo> { 40 + export async function resolveProfile( 41 + did: string, 42 + fetchFn: typeof fetch = fetch, 43 + ): Promise<ProfileInfo> { 41 44 try { 42 45 const doc = await idResolver.did.resolve(did); 43 46 const alsoKnownAs = doc?.alsoKnownAs ?? []; ··· 46 49 47 50 // Fetch display name and avatar from Bluesky public AppView 48 51 const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`; 49 - const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); 52 + const res = await fetchFn(url, { signal: AbortSignal.timeout(5000) }); 50 53 if (!res.ok) { 51 54 return { handle, displayName: null, avatar: null }; 52 55 } ··· 72 75 */ 73 76 export async function resolveProfiles( 74 77 dids: string[], 78 + fetchFn: typeof fetch = fetch, 75 79 ): Promise<Map<string, ProfileInfo>> { 76 80 const unique = [...new Set(dids)]; 77 81 const results = await Promise.all( 78 - unique.map(async (did) => [did, await resolveProfile(did)] as const), 82 + unique.map( 83 + async (did) => [did, await resolveProfile(did, fetchFn)] as const, 84 + ), 79 85 ); 80 86 return new Map(results); 81 87 }
+62
src/server/db/queries/bookmark.ts
··· 1 + import { getDb } from "../index.ts"; 2 + import type { WikiWithNoteCount } from "./wiki.ts"; 3 + 4 + export function upsertBookmark( 5 + did: string, 6 + wikiAtUri: string, 7 + atUri: string, 8 + createdAt: string, 9 + ): void { 10 + const db = getDb(); 11 + db.run( 12 + `INSERT INTO bookmarks (did, wiki_at_uri, at_uri, created_at) 13 + VALUES (?, ?, ?, ?) 14 + ON CONFLICT(did, wiki_at_uri) DO UPDATE SET 15 + at_uri = excluded.at_uri`, 16 + [did, wikiAtUri, atUri, createdAt], 17 + ); 18 + } 19 + 20 + export function deleteBookmarkByUri(atUri: string): void { 21 + const db = getDb(); 22 + db.run("DELETE FROM bookmarks WHERE at_uri = ?", [atUri]); 23 + } 24 + 25 + export function deleteBookmarkByWiki( 26 + did: string, 27 + wikiAtUri: string, 28 + ): string | null { 29 + const db = getDb(); 30 + const row = db 31 + .query("SELECT at_uri FROM bookmarks WHERE did = ? AND wiki_at_uri = ?") 32 + .get(did, wikiAtUri) as { at_uri: string } | null; 33 + if (!row) return null; 34 + db.run("DELETE FROM bookmarks WHERE did = ? AND wiki_at_uri = ?", [ 35 + did, 36 + wikiAtUri, 37 + ]); 38 + return row.at_uri; 39 + } 40 + 41 + export function isBookmarked(did: string, wikiAtUri: string): boolean { 42 + const db = getDb(); 43 + const row = db 44 + .query("SELECT 1 FROM bookmarks WHERE did = ? AND wiki_at_uri = ?") 45 + .get(did, wikiAtUri); 46 + return row !== null; 47 + } 48 + 49 + export function getBookmarksForUser(did: string): WikiWithNoteCount[] { 50 + const db = getDb(); 51 + return db 52 + .query( 53 + `SELECT w.*, COUNT(n.slug) AS note_count 54 + FROM bookmarks b 55 + JOIN wikis w ON w.at_uri = b.wiki_at_uri 56 + LEFT JOIN notes n ON n.wiki_slug = w.slug 57 + WHERE b.did = ? 58 + GROUP BY w.slug 59 + ORDER BY b.created_at DESC`, 60 + ) 61 + .all(did) as WikiWithNoteCount[]; 62 + }
+8
src/server/db/queries/index.ts
··· 2 2 export type { 3 3 BacklinkRow, 4 4 BlobRow, 5 + BookmarkRow, 5 6 CurrentNoteRow, 6 7 MembershipRow, 7 8 NoteRow, ··· 10 11 WikiRow, 11 12 } from "../types.ts"; 12 13 export { getBlobsByCids, insertBlob } from "./blob.ts"; 14 + export { 15 + deleteBookmarkByUri, 16 + deleteBookmarkByWiki, 17 + getBookmarksForUser, 18 + isBookmarked, 19 + upsertBookmark, 20 + } from "./bookmark.ts"; 13 21 export { getCursor, setCursor } from "./cursor.ts"; 14 22 export { 15 23 deleteMembership,
+12
src/server/db/schema.ts
··· 87 87 `); 88 88 89 89 db.run(` 90 + CREATE TABLE IF NOT EXISTS bookmarks ( 91 + id INTEGER PRIMARY KEY AUTOINCREMENT, 92 + did TEXT NOT NULL, 93 + wiki_at_uri TEXT NOT NULL, 94 + at_uri TEXT NOT NULL UNIQUE, 95 + created_at TEXT NOT NULL DEFAULT (datetime('now')), 96 + UNIQUE (did, wiki_at_uri) 97 + ) 98 + `); 99 + 100 + db.run(` 90 101 CREATE TABLE IF NOT EXISTS backlinks ( 91 102 source_note_uri TEXT NOT NULL, 92 103 target_note_slug TEXT NOT NULL, ··· 147 158 db.run( 148 159 "CREATE INDEX IF NOT EXISTS idx_blobs_revision ON blobs(revision_at_uri)", 149 160 ); 161 + db.run("CREATE INDEX IF NOT EXISTS idx_bookmarks_did ON bookmarks(did)"); 150 162 }
+8
src/server/db/types.ts
··· 48 48 created_at: string; 49 49 } 50 50 51 + export interface BookmarkRow { 52 + id: number; 53 + did: string; 54 + wiki_at_uri: string; 55 + at_uri: string; 56 + created_at: string; 57 + } 58 + 51 59 export interface MembershipRow { 52 60 id: number; 53 61 wiki_slug: string;
+2
src/server/index.ts
··· 5 5 import { AppError } from "../lib/errors.ts"; 6 6 import { getDb } from "./db/index.ts"; 7 7 import { blobRoutes } from "./routes/blob.ts"; 8 + import { bookmarkRoutes } from "./routes/bookmark.ts"; 8 9 import { exploreRoutes } from "./routes/explore.ts"; 9 10 import { homeRoute } from "./routes/home.ts"; 10 11 import { localeRoutes } from "./routes/locale.ts"; ··· 34 35 .use(staticPlugin({ prefix: "/public", assets: "public" })) 35 36 .use(atprotoRoutes()) 36 37 .use(blobRoutes) 38 + .use(bookmarkRoutes) 37 39 .use(localeRoutes) 38 40 .use(profileRoutes) 39 41 .use(homeRoute)
+31
src/server/routes/bookmark.ts
··· 1 + import { Elysia } from "elysia"; 2 + import { resolveRequestContext } from "../../lib/access.ts"; 3 + import { t } from "../../lib/i18n/index.ts"; 4 + import { 5 + addBookmarkAction, 6 + removeBookmarkAction, 7 + } from "../../lib/orchestrators/bookmark.ts"; 8 + import { bookmarkButton } from "../../views/bookmark.ts"; 9 + 10 + export const bookmarkRoutes = new Elysia().post( 11 + "/api/bookmark", 12 + async ({ request }) => { 13 + const ctx = await resolveRequestContext(request); 14 + 15 + const formData = await request.formData(); 16 + const wikiAtUri = formData.get("wikiAtUri") as string; 17 + const action = formData.get("action") as string; 18 + 19 + if (action === "add") { 20 + await addBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); 21 + } else { 22 + await removeBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); 23 + } 24 + 25 + const isNowBookmarked = action === "add"; 26 + const msg = t(ctx.locale); 27 + return new Response(bookmarkButton(wikiAtUri, isNowBookmarked, msg), { 28 + headers: { "Content-Type": "text/html" }, 29 + }); 30 + }, 31 + );
+12 -1
src/server/routes/note.ts
··· 12 12 } from "../../lib/orchestrators/note.ts"; 13 13 import { htmlResponse } from "../../lib/response.ts"; 14 14 import { noteUrl, redirect, wikiUrl } from "../../lib/urls.ts"; 15 + import { bookmarkButton } from "../../views/bookmark.ts"; 15 16 import { editNotePage } from "../../views/edit-note.ts"; 16 17 import { newNotePage } from "../../views/new-note.ts"; 17 18 import { notePage } from "../../views/note.ts"; 18 - import { getNoteWithCurrent, getSidebarNotes } from "../db/queries/index.ts"; 19 + import { 20 + getNoteWithCurrent, 21 + getSidebarNotes, 22 + isBookmarked, 23 + } from "../db/queries/index.ts"; 19 24 20 25 export const noteRoutes = new Elysia({ prefix: "/wiki" }) 21 26 .get("/:wikiSlug/new", async ({ params, request }) => { ··· 114 119 params.wikiSlug, 115 120 ); 116 121 const sidebarNotes = getSidebarNotes(params.wikiSlug); 122 + 123 + const msg = t(ctx.locale); 124 + const bookmarked = isBookmarked(ctx.effectiveDid, ctx.wiki.at_uri); 125 + const bmHtml = bookmarkButton(ctx.wiki.at_uri, bookmarked, msg); 126 + 117 127 return htmlResponse( 118 128 notePage( 119 129 ctx.wiki.name, ··· 127 137 currentNoteSlug: params.noteSlug, 128 138 locale: ctx.locale, 129 139 accessLevel: ctx.access, 140 + bookmarkHtml: bmHtml, 130 141 }, 131 142 ctx.wiki.language, 132 143 ),
+7 -2
src/server/routes/profile.ts
··· 5 5 import { htmlResponse } from "../../lib/response.ts"; 6 6 import { profileUrl, redirect } from "../../lib/urls.ts"; 7 7 import { profilePage } from "../../views/profile.ts"; 8 - import { listCollaboratingWikis, listOwnedWikis } from "../db/queries/index.ts"; 8 + import { 9 + getBookmarksForUser, 10 + listCollaboratingWikis, 11 + listOwnedWikis, 12 + } from "../db/queries/index.ts"; 9 13 10 14 export const profileRoutes = new Elysia() 11 15 .get("/@:handle", async ({ params, request }) => { ··· 29 33 const profile = await resolveProfile(did); 30 34 const ownedWikis = listOwnedWikis(did); 31 35 const collaboratingWikis = listCollaboratingWikis(did); 36 + const bookmarks = getBookmarksForUser(did); 32 37 33 38 return htmlResponse( 34 - profilePage(profile, ownedWikis, collaboratingWikis, { 39 + profilePage(profile, ownedWikis, collaboratingWikis, bookmarks, { 35 40 session: ctx.session, 36 41 locale: ctx.locale, 37 42 }),
+8
src/server/routes/wiki.ts
··· 17 17 import { htmlResponse } from "../../lib/response.ts"; 18 18 import { redirect, wikiUrl } from "../../lib/urls.ts"; 19 19 import { accessDeniedPage } from "../../views/access-denied.ts"; 20 + import { bookmarkButton } from "../../views/bookmark.ts"; 20 21 import { newWikiPage } from "../../views/new-wiki.ts"; 21 22 import { notePage } from "../../views/note.ts"; 22 23 import { settingsPage } from "../../views/settings.ts"; ··· 24 25 import { 25 26 getNoteWithCurrent, 26 27 getSidebarNotes, 28 + isBookmarked, 27 29 listMembers, 28 30 listRequests, 29 31 } from "../db/queries/index.ts"; ··· 150 152 const sidebarNotes = getSidebarNotes(params.wikiSlug); 151 153 const homeData = getNoteWithCurrent(params.wikiSlug, "home"); 152 154 155 + const msg = t(ctx.locale); 156 + const bookmarked = isBookmarked(ctx.effectiveDid, ctx.wiki.at_uri); 157 + const bmHtml = bookmarkButton(ctx.wiki.at_uri, bookmarked, msg); 158 + 153 159 if (homeData) { 154 160 const { html, hasViz } = renderMarkdown( 155 161 homeData.current.content, ··· 168 174 currentNoteSlug: "home", 169 175 locale: ctx.locale, 170 176 accessLevel: ctx.access, 177 + bookmarkHtml: bmHtml, 171 178 }, 172 179 ctx.wiki.language, 173 180 ), ··· 184 191 sidebarNotes, 185 192 locale: ctx.locale, 186 193 accessLevel: ctx.access, 194 + bookmarkHtml: bmHtml, 187 195 }, 188 196 ctx.wiki.language, 189 197 ),
+29
src/views/bookmark.ts
··· 1 + import { escapeHtml } from "../lib/html.ts"; 2 + import type { Messages } from "../lib/i18n/index.ts"; 3 + import { THEME } from "./theme.ts"; 4 + 5 + export function bookmarkButton( 6 + wikiAtUri: string, 7 + isBookmarked: boolean, 8 + msg: Messages, 9 + ): string { 10 + const action = isBookmarked ? "remove" : "add"; 11 + const label = isBookmarked 12 + ? msg.profile.removeBookmark 13 + : msg.profile.bookmark; 14 + const fillClass = isBookmarked ? "fill-current" : ""; 15 + 16 + return `<form id="bookmark-form" hx-post="/api/bookmark" hx-swap="outerHTML" 17 + class="inline"> 18 + <input type="hidden" name="wikiAtUri" value="${escapeHtml(wikiAtUri)}"> 19 + <input type="hidden" name="action" value="${action}"> 20 + <button type="submit" 21 + class="inline-flex items-center gap-1.5 px-2 py-1 text-sm ${THEME.textMuted} ${THEME.accentLightHoverBg} rounded" 22 + title="${escapeHtml(label)}"> 23 + <svg class="w-4 h-4 ${fillClass}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 24 + <path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" /> 25 + </svg> 26 + ${escapeHtml(label)} 27 + </button> 28 + </form>`; 29 + }
+2
src/views/layout.ts
··· 27 27 pageTitle?: string; 28 28 locale?: Locale; 29 29 accessLevel?: AccessLevel; 30 + bookmarkHtml?: string; 30 31 } 31 32 32 33 function renderSearchButton(locale: Locale = "en"): string { ··· 75 76 return `<div class="w-full max-w-6xl mx-auto px-6 py-8 flex flex-col md:flex-row md:flex-1 gap-8"> 76 77 <aside class="order-2 md:order-first w-full md:w-44 md:shrink-0 md:sticky md:top-0 md:max-h-screen md:py-4 flex flex-col"> 77 78 <div class="shrink-0"> 79 + ${options.bookmarkHtml ? `<div class="mb-3">${options.bookmarkHtml}</div>` : ""} 78 80 ${renderSearchButton(locale)} 79 81 ${editLink} 80 82 ${newNoteLink}
+7 -1
src/views/profile.ts
··· 10 10 profile: ProfileInfo, 11 11 ownedWikis: WikiWithNoteCount[], 12 12 collaboratingWikis: WikiWithNoteCount[], 13 + bookmarkedWikis: WikiWithNoteCount[], 13 14 options?: LayoutOptions, 14 15 ): string { 15 16 const locale = options?.locale ?? "en"; ··· 43 44 ? wikiGridCards(collaboratingWikis, locale) 44 45 : `<p class="${THEME.textMuted} text-sm">${msg.profile.noCollaboratingWikis}</p>`; 45 46 47 + const bookmarkSection = 48 + bookmarkedWikis.length > 0 49 + ? wikiGridCards(bookmarkedWikis, locale) 50 + : `<p class="${THEME.textMuted} text-sm">${msg.profile.noBookmarks}</p>`; 51 + 46 52 return layout( 47 53 msg.profile.myWikis, 48 54 ` ··· 67 73 68 74 <section class="mb-10"> 69 75 <h2 class="text-lg font-semibold ${THEME.textPrimary} mb-4">${msg.profile.bookmarks}</h2> 70 - <p class="${THEME.textMuted} text-sm">${msg.profile.bookmarksComingSoon}</p> 76 + ${bookmarkSection} 71 77 </section> 72 78 </div> 73 79 `,