From d1b0d51ec954a7f770c9a872fb87a81ca031959d Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 15 Feb 2026 00:00:12 +0100 Subject: [PATCH] feat: add stacked public notes --- src/bus/publicNoteEventBus.ts | 2 +- src/components/StackedPublicNote.vue | 191 +++++++++++++++++++++++ src/hooks/useATProtoLinks.hook.ts | 20 ++- src/modules/atproto/getAka.ts | 2 - src/modules/atproto/parseAtUri.ts | 7 + src/modules/atproto/publicNote.types.ts | 29 ++++ src/modules/atproto/withATProtoImages.ts | 13 ++ src/views/PublicNoteView.vue | 103 +++++------- 8 files changed, 289 insertions(+), 78 deletions(-) create mode 100644 src/components/StackedPublicNote.vue create mode 100644 src/modules/atproto/parseAtUri.ts create mode 100644 src/modules/atproto/publicNote.types.ts create mode 100644 src/modules/atproto/withATProtoImages.ts diff --git a/src/bus/publicNoteEventBus.ts b/src/bus/publicNoteEventBus.ts index 341842b..d5537c1 100644 --- a/src/bus/publicNoteEventBus.ts +++ b/src/bus/publicNoteEventBus.ts @@ -1,7 +1,7 @@ import { createEventBus } from "retrobus" interface EventBusParams { - path: string + atUri: string currentNoteRkey?: string } diff --git a/src/components/StackedPublicNote.vue b/src/components/StackedPublicNote.vue new file mode 100644 index 0000000..0621152 --- /dev/null +++ b/src/components/StackedPublicNote.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/src/hooks/useATProtoLinks.hook.ts b/src/hooks/useATProtoLinks.hook.ts index 87baa09..59de63b 100644 --- a/src/hooks/useATProtoLinks.hook.ts +++ b/src/hooks/useATProtoLinks.hook.ts @@ -1,36 +1,34 @@ import { ComputedRef, onUnmounted, Ref, toValue } from "vue" import { isExternalLink } from "@/utils/link" -import { publicNoteEventBus } from "@/bus/publicNoteEventBus" +import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" export const useATProtoLinks = ( className: ComputedRef | string, - rkey?: Ref | string, + currentAtUri?: Ref | string, ) => { + const { addStackedNote } = useRouteQueryStackedNotes() const linkNote: EventListener = (event) => { const target = event.target as HTMLElement - const href = target.getAttribute("href") + const atUri = target.getAttribute("href") - if (!href) { + if (!atUri) { return } - if (href.startsWith("#")) { + if (atUri.startsWith("#")) { return } event.preventDefault() event.stopPropagation() - if (isExternalLink(href)) { - window.open(href, "_blank") + if (isExternalLink(atUri)) { + window.open(atUri, "_blank") return } - publicNoteEventBus.emit({ - path: href, - currentNoteRkey: toValue(rkey), - }) + addStackedNote(toValue(currentAtUri) ?? "", atUri) } const LINK_SELECTOR = `.${toValue(className)} a` diff --git a/src/modules/atproto/getAka.ts b/src/modules/atproto/getAka.ts index e83ce5d..e6b8628 100644 --- a/src/modules/atproto/getAka.ts +++ b/src/modules/atproto/getAka.ts @@ -2,8 +2,6 @@ export type Author = { alias: string; endpoint: string } const correspondanceCache = new Map() -console.log({ correspondanceCache }) - export const getUniqueAka = async (did: string): Promise => { if (correspondanceCache.has(did)) { return correspondanceCache.get(did) as Author diff --git a/src/modules/atproto/parseAtUri.ts b/src/modules/atproto/parseAtUri.ts new file mode 100644 index 0000000..64595b0 --- /dev/null +++ b/src/modules/atproto/parseAtUri.ts @@ -0,0 +1,7 @@ +export const parseAtUri = (atUri: string): { did: string; rkey: string } => { + const match = atUri.match(/^at:\/\/(did:[^/]+)\/[^/]+\/(.+)$/) + if (!match) { + throw new Error(`Invalid AT URI: ${atUri}`) + } + return { did: match[1], rkey: match[2] } +} diff --git a/src/modules/atproto/publicNote.types.ts b/src/modules/atproto/publicNote.types.ts new file mode 100644 index 0000000..07e4c0b --- /dev/null +++ b/src/modules/atproto/publicNote.types.ts @@ -0,0 +1,29 @@ +export interface PublicNoteRecord { + uri: string + cid: string + value: PublicNote +} + +export interface PublicNote { + $type: string + title: string + images: PublicNoteImage[] + content: string + createdAt: string + publishedAt: string + theme?: string + fontFamily?: string + fontSize?: string +} + +export interface PublicNoteImage { + alt: string + image: PublicNoteBlob +} + +export interface PublicNoteBlob { + $type: string + ref: { $link: string } + mimeType: string + size: number +} diff --git a/src/modules/atproto/withATProtoImages.ts b/src/modules/atproto/withATProtoImages.ts new file mode 100644 index 0000000..3353482 --- /dev/null +++ b/src/modules/atproto/withATProtoImages.ts @@ -0,0 +1,13 @@ +export const withATProtoImages = ( + markdown: string, + { endpoint, did }: { endpoint: string; did: string }, +): string => { + const imageLinkPattern = /!\[([^\]]*)\]\((bafkrei[a-z0-9]+)\)/g + + return markdown.replace(imageLinkPattern, (_, altText, cid) => { + const imageUrl = new URL("/xrpc/com.atproto.sync.getBlob", endpoint) + imageUrl.searchParams.set("did", did) + imageUrl.searchParams.set("cid", cid) + return `![${altText}](${imageUrl.toString()})` + }) +} diff --git a/src/views/PublicNoteView.vue b/src/views/PublicNoteView.vue index 6ba5f56..4a030b3 100644 --- a/src/views/PublicNoteView.vue +++ b/src/views/PublicNoteView.vue @@ -2,103 +2,70 @@ import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook" import { markdownBuilder } from "@/hooks/useMarkdown.hook" import BackButton from "@/components/BackButton.vue" +import StackedPublicNote from "@/components/StackedPublicNote.vue" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { getUniqueAka } from "@/modules/atproto/getAka" +import type { PublicNoteRecord } from "@/modules/atproto/publicNote.types" +import { withATProtoImages } from "@/modules/atproto/withATProtoImages" import { getUrl } from "@/modules/atproto/getUrl" import { downloadFont } from "@/utils/downloadFont" import { computedAsync } from "@vueuse/core" import { computed, nextTick, watch } from "vue" - -export interface Root { - uri: string - cid: string - value: Value -} - -export interface Value { - $type: string - title: string - images: Image[] - content: string - createdAt: string - publishedAt: string - theme?: string - fontFamily?: string - fontSize?: string -} - -export interface Image { - alt: string - image: Image2 -} - -export interface Image2 { - $type: string - ref: Ref - mimeType: string - size: number -} - -export interface Ref { - $link: string -} +import { useResizeContainer } from "@/hooks/useResizeContainer.hook" +import { publicNoteEventBus } from "@/bus/publicNoteEventBus" +import { errorMessage } from "@/utils/notif" const props = defineProps<{ did: string; rkey: string }>() const did = computed(() => props.did) const rkey = computed(() => props.rkey) -const { scrollToFocusedNote } = useRouteQueryStackedNotes() const author = computedAsync(async () => getUniqueAka(did.value)) const url = computedAsync(async () => getUrl({ did: did.value, rkey: rkey.value }), ) -const article = computedAsync(async () => - url.value ? ((await fetch(url.value).then()).json() as Promise) : null, +const noteRecord = computedAsync(async () => + url.value + ? ((await fetch(url.value).then()).json() as Promise) + : null, ) -watch(article, () => { - if (article.value?.value.fontFamily) { - downloadFont(article.value.value.fontFamily) +watch(noteRecord, () => { + if (noteRecord.value?.value.fontFamily) { + downloadFont(noteRecord.value.value.fontFamily) } - if (article.value?.value.fontSize) { + if (noteRecord.value?.value.fontSize) { const root = document.documentElement - root.style.setProperty("--font-size", `${article.value.value.fontSize}pt`) + root.style.setProperty( + "--font-size", + `${noteRecord.value.value.fontSize}pt`, + ) } }) const { toHTML } = markdownBuilder() -const withATProtoImages = (markdown: string) => { - if (!author.value) { - return markdown - } - const endpoint = author.value.endpoint - - const imageLinkPattern = /!\[([^\]]*)\]\((bafkrei[a-z0-9]+)\)/g - - return markdown.replace(imageLinkPattern, (_, altText, cid) => { - const imageUrl = new URL("/xrpc/com.atproto.sync.getBlob", endpoint) - imageUrl.searchParams.set("did", did.value) - imageUrl.searchParams.set("cid", cid) - return `![${altText}](${imageUrl.toString()})` - }) -} - -const title = computed(() => article.value?.value.title) +const title = computed(() => noteRecord.value?.value.title) const content = computed(() => - article.value?.value.content - ? toHTML(withATProtoImages(article.value?.value.content)) + noteRecord.value?.value.content && author.value + ? toHTML( + withATProtoImages(noteRecord.value.value.content, { + endpoint: author.value.endpoint, + did: did.value, + }), + ) : "", ) const publishedAt = computed(() => - article.value?.value.publishedAt - ? new Date(article.value?.value.publishedAt).toLocaleDateString() + noteRecord.value?.value.publishedAt + ? new Date(noteRecord.value?.value.publishedAt).toLocaleDateString() : null, ) +const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes() const { listenToClick } = useATProtoLinks("note-display") +useResizeContainer("note-container", stackedNotes) watch( content, @@ -111,7 +78,7 @@ watch(