diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
index ce5a589e..26f93164 100644
--- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
@@ -162,6 +162,7 @@ const Replies = (props: {
let [replyBoxOpen, setReplyBoxOpen] = useState(false);
let [repliesOpen, setRepliesOpen] = useState(true);
+
let replies = props.comments
.filter(
(comment) =>
@@ -176,6 +177,9 @@ const Replies = (props: {
new Date(aRecord.createdAt).getTime()
);
});
+
+ let repliesOrReplyBoxOpen =
+ replyBoxOpen || (repliesOpen && replies.length > 0);
return (
<>
@@ -203,49 +207,64 @@ const Replies = (props: {
>
)}
-
- {replyBoxOpen && (
-
{
- setReplyBoxOpen(false);
- }}
- />
- )}
- {repliesOpen && replies.length > 0 && (
-
-
-
- {replies.map((reply) => {
- return (
-
- );
- })}
+ {repliesOrReplyBoxOpen && (
+
+ {replyBoxOpen && (
+
+
+
{
+ setReplyBoxOpen(false);
+ }}
+ />
-
- )}
-
+ )}
+ {repliesOpen && replies.length > 0 && (
+
+
+
+ {replies.map((reply) => {
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ )}
>
);
};
@@ -263,7 +282,9 @@ const DatePopover = (props: { date: string }) => {
return (
{timeAgoText}
+
+ {timeAgoText}
+
}
>
{fullDate}
--
2.51.2
From 046e4251bcdd0ea3b3c5f93f184b7abcd671f771 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Thu, 13 Nov 2025 15:56:53 -0500
Subject: [PATCH 03/18] extract out prosemirror mounting logic from textblock
---
components/Blocks/TextBlock/index.tsx | 214 ++----------------
.../Blocks/TextBlock/mountProsemirror.ts | 199 ++++++++++++++++
package-lock.json | 10 +-
package.json | 1 +
4 files changed, 223 insertions(+), 201 deletions(-)
create mode 100644 components/Blocks/TextBlock/mountProsemirror.ts
diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx
index a2b9d3c6..e143ac84 100644
--- a/components/Blocks/TextBlock/index.tsx
+++ b/components/Blocks/TextBlock/index.tsx
@@ -1,32 +1,17 @@
-import { useRef, useEffect, useState, useLayoutEffect } from "react";
+import { useRef, useEffect, useState } from "react";
import { elementId } from "src/utils/elementId";
-import { baseKeymap } from "prosemirror-commands";
-import { keymap } from "prosemirror-keymap";
-import * as Y from "yjs";
-import * as base64 from "base64-js";
-import { useReplicache, useEntity, ReplicacheMutators } from "src/replicache";
+import { useReplicache, useEntity } from "src/replicache";
import { isVisible } from "src/utils/isVisible";
-
import { EditorState, TextSelection } from "prosemirror-state";
-import { EditorView } from "prosemirror-view";
-
-import { ySyncPlugin } from "y-prosemirror";
-import { Replicache } from "replicache";
import { RenderYJSFragment } from "./RenderYJSFragment";
import { useInitialPageLoad } from "components/InitialPageLoadProvider";
import { BlockProps } from "../Block";
import { focusBlock } from "src/utils/focusBlock";
-import { TextBlockKeymap } from "./keymap";
-import { multiBlockSchema, schema } from "./schema";
import { useUIState } from "src/useUIState";
import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock";
import { BlockCommandBar } from "components/Blocks/BlockCommandBar";
import { useEditorStates } from "src/state/useEditorState";
import { useEntitySetContext } from "components/EntitySetProvider";
-import { useHandlePaste } from "./useHandlePaste";
-import { highlightSelectionPlugin } from "./plugins";
-import { inputrules } from "./inputRules";
-import { autolink } from "./autolink-plugin";
import { TooltipButton } from "components/Buttons";
import { blockCommands } from "../BlockCommands";
import { betterIsUrl } from "src/utils/isURL";
@@ -37,6 +22,7 @@ import { BlockImageSmall } from "components/Icons/BlockImageSmall";
import { isIOS } from "src/utils/isDevice";
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
import { DotLoader } from "components/utils/DotLoader";
+import { useMountProsemirror } from "./mountProsemirror";
const HeadingStyle = {
1: "text-xl font-bold",
@@ -177,21 +163,11 @@ export function RenderedTextBlock(props: {
}
export function BaseTextBlock(props: BlockProps & { className?: string }) {
- let mountRef = useRef(null);
- let actionTimeout = useRef(null);
- let repRef = useRef>(null);
let headingLevel = useEntity(props.entityID, "block/heading-level");
- let entity_set = useEntitySetContext();
let alignment =
useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
- let propsRef = useRef({ ...props, entity_set, alignment });
- useEffect(() => {
- propsRef.current = { ...props, entity_set, alignment };
- }, [props, entity_set, alignment]);
+
let rep = useReplicache();
- useEffect(() => {
- repRef.current = rep.rep;
- }, [rep?.rep]);
let selected = useUIState(
(s) => !!s.selectedBlocks.find((b) => b.value === props.entityID),
@@ -204,136 +180,13 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) {
justify: "text-justify",
}[alignment];
- let value = useYJSValue(props.entityID);
-
let editorState = useEditorStates(
(s) => s.editorStates[props.entityID],
)?.editor;
- let handlePaste = useHandlePaste(props.entityID, propsRef);
- useLayoutEffect(() => {
- if (!mountRef.current) return;
- let km = TextBlockKeymap(propsRef, repRef, rep.undoManager);
- let editor = EditorState.create({
- schema: schema,
- plugins: [
- ySyncPlugin(value),
- keymap(km),
- inputrules(propsRef, repRef),
- keymap(baseKeymap),
- highlightSelectionPlugin,
- autolink({
- type: schema.marks.link,
- shouldAutoLink: () => true,
- defaultProtocol: "https",
- }),
- ],
- });
-
- let unsubscribe = useEditorStates.subscribe((s) => {
- let editorState = s.editorStates[props.entityID];
- if (editorState?.initial) return;
- if (editorState?.editor)
- editorState.view?.updateState(editorState.editor);
- });
- let view = new EditorView(
- { mount: mountRef.current },
- {
- state: editor,
- handlePaste,
- handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
- if (!direct) return;
- if (node.nodeSize - 2 <= _pos) return;
- let mark =
- node
- .nodeAt(_pos - 1)
- ?.marks.find((f) => f.type === schema.marks.link) ||
- node
- .nodeAt(Math.max(_pos - 2, 0))
- ?.marks.find((f) => f.type === schema.marks.link);
- if (mark) {
- window.open(mark.attrs.href, "_blank");
- }
- },
- dispatchTransaction(tr) {
- useEditorStates.setState((s) => {
- let oldEditorState = this.state;
- let newState = this.state.apply(tr);
- let addToHistory = tr.getMeta("addToHistory");
- let isBulkOp = tr.getMeta("bulkOp");
- let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
- if (addToHistory !== false && docHasChanges) {
- if (actionTimeout.current) {
- window.clearTimeout(actionTimeout.current);
- } else {
- if (!isBulkOp) rep.undoManager.startGroup();
- }
-
- if (!isBulkOp)
- actionTimeout.current = window.setTimeout(() => {
- rep.undoManager.endGroup();
- actionTimeout.current = null;
- }, 200);
- rep.undoManager.add({
- redo: () => {
- useEditorStates.setState((oldState) => {
- let view = oldState.editorStates[props.entityID]?.view;
- if (!view?.hasFocus() && !isBulkOp) view?.focus();
- return {
- editorStates: {
- ...oldState.editorStates,
- [props.entityID]: {
- ...oldState.editorStates[props.entityID]!,
- editor: newState,
- },
- },
- };
- });
- },
- undo: () => {
- useEditorStates.setState((oldState) => {
- let view = oldState.editorStates[props.entityID]?.view;
- if (!view?.hasFocus() && !isBulkOp) view?.focus();
- return {
- editorStates: {
- ...oldState.editorStates,
- [props.entityID]: {
- ...oldState.editorStates[props.entityID]!,
- editor: oldEditorState,
- },
- },
- };
- });
- },
- });
- }
- return {
- editorStates: {
- ...s.editorStates,
- [props.entityID]: {
- editor: newState,
- view: this as unknown as EditorView,
- initial: false,
- keymap: km,
- },
- },
- };
- });
- },
- },
- );
- return () => {
- unsubscribe();
- view.destroy();
- useEditorStates.setState((s) => ({
- ...s,
- editorStates: {
- ...s.editorStates,
- [props.entityID]: undefined,
- },
- }));
- };
- }, [props.entityID, props.parent, value, handlePaste, rep]);
+ let { mountRef, actionTimeout } = useMountProsemirror({
+ props,
+ });
return (
<>
@@ -586,45 +439,14 @@ const CommandOptions = (props: BlockProps & { className?: string }) => {
);
};
-function useYJSValue(entityID: string) {
- const [ydoc] = useState(new Y.Doc());
- const docStateFromReplicache = useEntity(entityID, "block/text");
- let rep = useReplicache();
- const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
-
- if (docStateFromReplicache) {
- const update = base64.toByteArray(docStateFromReplicache.data.value);
- Y.applyUpdate(ydoc, update);
- }
-
- useEffect(() => {
- if (!rep.rep) return;
- let timeout = null as null | number;
- const updateReplicache = async () => {
- const update = Y.encodeStateAsUpdate(ydoc);
- await rep.rep?.mutate.assertFact({
- //These undos are handled above in the Prosemirror context
- ignoreUndo: true,
- entity: entityID,
- attribute: "block/text",
- data: {
- value: base64.fromByteArray(update),
- type: "text",
- },
- });
- };
- const f = async (events: Y.YEvent[], transaction: Y.Transaction) => {
- if (!transaction.origin) return;
- if (timeout) clearTimeout(timeout);
- timeout = window.setTimeout(async () => {
- updateReplicache();
- }, 300);
- };
-
- yText.observeDeep(f);
- return () => {
- yText.unobserveDeep(f);
- };
- }, [yText, entityID, rep, ydoc]);
- return yText;
-}
+const useMentionState = () => {
+ const [editorState, setEditorState] = useState(null);
+ const [mentionState, setMentionState] = useState<{
+ active: boolean;
+ range: { from: number; to: number } | null;
+ selectedMention: { handle: string; did: string } | null;
+ }>({ active: false, range: null, selectedMention: null });
+ const mentionStateRef = useRef(mentionState);
+ mentionStateRef.current = mentionState;
+ return { mentionStateRef };
+};
diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts
new file mode 100644
index 00000000..e77b36bd
--- /dev/null
+++ b/components/Blocks/TextBlock/mountProsemirror.ts
@@ -0,0 +1,199 @@
+import { useLayoutEffect, useRef, useEffect, useState } from "react";
+import { EditorState } from "prosemirror-state";
+import { EditorView } from "prosemirror-view";
+import { baseKeymap } from "prosemirror-commands";
+import { keymap } from "prosemirror-keymap";
+import { ySyncPlugin } from "y-prosemirror";
+import * as Y from "yjs";
+import * as base64 from "base64-js";
+import { Replicache } from "replicache";
+import { produce } from "immer";
+
+import { schema } from "./schema";
+import { TextBlockKeymap } from "./keymap";
+import { inputrules } from "./inputRules";
+import { highlightSelectionPlugin } from "./plugins";
+import { autolink } from "./autolink-plugin";
+import { useEditorStates } from "src/state/useEditorState";
+import {
+ useEntity,
+ useReplicache,
+ type ReplicacheMutators,
+} from "src/replicache";
+import { useHandlePaste } from "./useHandlePaste";
+import { BlockProps } from "../Block";
+import { useEntitySetContext } from "components/EntitySetProvider";
+
+export function useMountProsemirror({ props }: { props: BlockProps }) {
+ let { entityID, parent } = props;
+ let rep = useReplicache();
+ let mountRef = useRef(null);
+ const repRef = useRef | null>(null);
+ let value = useYJSValue(entityID);
+ let entity_set = useEntitySetContext();
+ let alignment =
+ useEntity(entityID, "block/text-alignment")?.data.value || "left";
+ let propsRef = useRef({ ...props, entity_set, alignment });
+ let handlePaste = useHandlePaste(entityID, propsRef);
+
+ const actionTimeout = useRef(null);
+
+ propsRef.current = { ...props, entity_set, alignment };
+ repRef.current = rep.rep;
+
+ useLayoutEffect(() => {
+ if (!mountRef.current) return;
+
+ const km = TextBlockKeymap(propsRef, repRef, rep.undoManager);
+ const editor = EditorState.create({
+ schema: schema,
+ plugins: [
+ ySyncPlugin(value),
+ keymap(km),
+ inputrules(propsRef, repRef),
+ keymap(baseKeymap),
+ highlightSelectionPlugin,
+ autolink({
+ type: schema.marks.link,
+ shouldAutoLink: () => true,
+ defaultProtocol: "https",
+ }),
+ ],
+ });
+
+ const view = new EditorView(
+ { mount: mountRef.current },
+ {
+ state: editor,
+ handlePaste,
+ handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
+ if (!direct) return;
+ if (node.nodeSize - 2 <= _pos) return;
+ let mark =
+ node
+ .nodeAt(_pos - 1)
+ ?.marks.find((f) => f.type === schema.marks.link) ||
+ node
+ .nodeAt(Math.max(_pos - 2, 0))
+ ?.marks.find((f) => f.type === schema.marks.link);
+ if (mark) {
+ window.open(mark.attrs.href, "_blank");
+ }
+ },
+ dispatchTransaction,
+ },
+ );
+
+ const unsubscribe = useEditorStates.subscribe((s) => {
+ let editorState = s.editorStates[entityID];
+ if (editorState?.initial) return;
+ if (editorState?.editor)
+ editorState.view?.updateState(editorState.editor);
+ });
+
+ return () => {
+ unsubscribe();
+ view.destroy();
+ useEditorStates.setState((s) => ({
+ ...s,
+ editorStates: {
+ ...s.editorStates,
+ [entityID]: undefined,
+ },
+ }));
+ };
+
+ function dispatchTransaction(this: EditorView, tr: any) {
+ useEditorStates.setState((s) => {
+ let oldEditorState = this.state;
+ let newState = this.state.apply(tr);
+ let addToHistory = tr.getMeta("addToHistory");
+ let isBulkOp = tr.getMeta("bulkOp");
+ let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
+
+ // Handle undo/redo history with timeout-based grouping
+ if (addToHistory !== false && docHasChanges) {
+ if (actionTimeout.current) window.clearTimeout(actionTimeout.current);
+ else if (!isBulkOp) rep.undoManager.startGroup();
+
+ if (!isBulkOp) {
+ actionTimeout.current = window.setTimeout(() => {
+ rep.undoManager.endGroup();
+ actionTimeout.current = null;
+ }, 200);
+ }
+
+ let setState = (s: EditorState) => () =>
+ useEditorStates.setState(
+ produce((draft) => {
+ let view = draft.editorStates[entityID]?.view;
+ if (!view?.hasFocus() && !isBulkOp) view?.focus();
+ draft.editorStates[entityID]!.editor = s;
+ }),
+ );
+
+ rep.undoManager.add({
+ redo: setState(newState),
+ undo: setState(oldEditorState),
+ });
+ }
+
+ return {
+ editorStates: {
+ ...s.editorStates,
+ [entityID]: {
+ editor: newState,
+ view: this as unknown as EditorView,
+ initial: false,
+ keymap: km,
+ },
+ },
+ };
+ });
+ }
+ }, [entityID, parent, value, handlePaste, rep]);
+ return { mountRef, actionTimeout };
+}
+
+function useYJSValue(entityID: string) {
+ const [ydoc] = useState(new Y.Doc());
+ const docStateFromReplicache = useEntity(entityID, "block/text");
+ let rep = useReplicache();
+ const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
+
+ if (docStateFromReplicache) {
+ const update = base64.toByteArray(docStateFromReplicache.data.value);
+ Y.applyUpdate(ydoc, update);
+ }
+
+ useEffect(() => {
+ if (!rep.rep) return;
+ let timeout = null as null | number;
+ const updateReplicache = async () => {
+ const update = Y.encodeStateAsUpdate(ydoc);
+ await rep.rep?.mutate.assertFact({
+ //These undos are handled above in the Prosemirror context
+ ignoreUndo: true,
+ entity: entityID,
+ attribute: "block/text",
+ data: {
+ value: base64.fromByteArray(update),
+ type: "text",
+ },
+ });
+ };
+ const f = async (events: Y.YEvent[], transaction: Y.Transaction) => {
+ if (!transaction.origin) return;
+ if (timeout) clearTimeout(timeout);
+ timeout = window.setTimeout(async () => {
+ updateReplicache();
+ }, 300);
+ };
+
+ yText.observeDeep(f);
+ return () => {
+ yText.unobserveDeep(f);
+ };
+ }, [yText, entityID, rep, ydoc]);
+ return yText;
+}
diff --git a/package-lock.json b/package-lock.json
index 0d806d73..48d6531c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,6 +44,7 @@
"feed": "^5.1.0",
"fractional-indexing": "^3.2.0",
"hono": "^4.7.11",
+ "immer": "^10.2.0",
"inngest": "^3.40.1",
"ioredis": "^5.6.1",
"katex": "^0.16.22",
@@ -10877,11 +10878,10 @@
}
},
"node_modules/immer": {
- "version": "10.1.1",
- "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
- "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
- "optional": true,
- "peer": true,
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+ "license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
diff --git a/package.json b/package.json
index 19bffcf9..74fbb5ee 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"feed": "^5.1.0",
"fractional-indexing": "^3.2.0",
"hono": "^4.7.11",
+ "immer": "^10.2.0",
"inngest": "^3.40.1",
"ioredis": "^5.6.1",
"katex": "^0.16.22",
--
2.51.2
From 3d47f0306456dad03390a5fa8608164ec24352ac Mon Sep 17 00:00:00 2001
From: celine
Date: Fri, 14 Nov 2025 15:16:46 -0500
Subject: [PATCH 04/18] added better empty state message for notifications
---
app/(home-pages)/notifications/NotificationList.tsx | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/app/(home-pages)/notifications/NotificationList.tsx b/app/(home-pages)/notifications/NotificationList.tsx
index 35a974ba..bc0f054b 100644
--- a/app/(home-pages)/notifications/NotificationList.tsx
+++ b/app/(home-pages)/notifications/NotificationList.tsx
@@ -23,10 +23,12 @@ export function NotificationList({
}, 500);
}, []);
- if (notifications.length === 0)
+ if (notifications.length !== 0)
return (
-
- no notifications yet...
+
+
no notifications yet...
+ Here, you'll find notifications about new follows, comments,
+ mentions, and replies!
);
return (
--
2.51.2
From df74efe5d21a5f156350660894aa7680b3a888e8 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Sun, 16 Nov 2025 23:44:54 -0500
Subject: [PATCH 05/18] render empty block as
---
components/Blocks/TextBlock/RenderYJSFragment.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx
index 132e72a7..94dc872f 100644
--- a/components/Blocks/TextBlock/RenderYJSFragment.tsx
+++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx
@@ -27,7 +27,7 @@ export function RenderYJSFragment({
return (
{children.length === 0 ? (
-
+
) : (
node.toArray().map((node, index) => {
if (node.constructor === XmlText) {
--
2.51.2
From 045e283b252eacea1f5b83406a6a0dfdf117dd12 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Sun, 16 Nov 2025 23:45:06 -0500
Subject: [PATCH 06/18] mark yjs external package
---
next.config.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/next.config.js b/next.config.js
index f54ed91f..68b3188e 100644
--- a/next.config.js
+++ b/next.config.js
@@ -21,6 +21,7 @@ const nextConfig = {
},
];
},
+ serverExternalPackages: ["yjs"],
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
images: {
loader: "custom",
--
2.51.2
From a613e397efca2cb6b7ca41e2c8765cf80e70350c Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 01:25:49 -0500
Subject: [PATCH 07/18] add alternate names to block commands
---
components/Blocks/BlockCommandBar.tsx | 9 +++++++--
components/Blocks/BlockCommands.tsx | 4 ++++
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/components/Blocks/BlockCommandBar.tsx b/components/Blocks/BlockCommandBar.tsx
index 62bf0e87..2aaf7f99 100644
--- a/components/Blocks/BlockCommandBar.tsx
+++ b/components/Blocks/BlockCommandBar.tsx
@@ -47,9 +47,14 @@ export const BlockCommandBar = ({
};
let commandResults = blockCommands.filter((command) => {
- const matchesSearch = command.name
+ const lowerSearchValue = searchValue.toLocaleLowerCase();
+ const matchesName = command.name
.toLocaleLowerCase()
- .includes(searchValue.toLocaleLowerCase());
+ .includes(lowerSearchValue);
+ const matchesAlternate = command.alternateNames?.some((altName) =>
+ altName.toLocaleLowerCase().includes(lowerSearchValue)
+ ) ?? false;
+ const matchesSearch = matchesName || matchesAlternate;
const isVisible = !pub || !command.hiddenInPublication;
return matchesSearch && isVisible;
});
diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx
index 9e8a78f8..326defaa 100644
--- a/components/Blocks/BlockCommands.tsx
+++ b/components/Blocks/BlockCommands.tsx
@@ -102,6 +102,7 @@ type Command = {
name: string;
icon: React.ReactNode;
type: string;
+ alternateNames?: string[];
hiddenInPublication?: boolean;
onSelect: (
rep: Replicache,
@@ -125,6 +126,7 @@ export const blockCommands: Command[] = [
name: "Title",
icon: ,
type: "text",
+ alternateNames: ["h1"],
onSelect: async (rep, props, um) => {
await setHeaderCommand(1, rep, props);
},
@@ -133,6 +135,7 @@ export const blockCommands: Command[] = [
name: "Header",
icon: ,
type: "text",
+ alternateNames: ["h2"],
onSelect: async (rep, props, um) => {
await setHeaderCommand(2, rep, props);
},
@@ -141,6 +144,7 @@ export const blockCommands: Command[] = [
name: "Subheader",
icon: ,
type: "text",
+ alternateNames: ["h3"],
onSelect: async (rep, props, um) => {
await setHeaderCommand(3, rep, props);
},
--
2.51.2
From b70eb51d3c5454edf8dd06c44541e235b565d553 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 12:51:40 -0500
Subject: [PATCH 08/18] use cloudflare browser api instead of microlink
---
app/[leaflet_id]/publish/publishBskyPost.ts | 17 +++----
app/api/link_previews/route.ts | 16 +++----
src/utils/getMicroLinkOgImage.ts | 49 ++++++++++++++++++---
3 files changed, 57 insertions(+), 25 deletions(-)
diff --git a/app/[leaflet_id]/publish/publishBskyPost.ts b/app/[leaflet_id]/publish/publishBskyPost.ts
index 7fa16dd6..9fc2efb9 100644
--- a/app/[leaflet_id]/publish/publishBskyPost.ts
+++ b/app/[leaflet_id]/publish/publishBskyPost.ts
@@ -12,6 +12,10 @@ import { AtpBaseClient, PubLeafletDocument } from "lexicons/api";
import { createOauthClient } from "src/atproto-oauth";
import { supabaseServerClient } from "supabase/serverClient";
import { Json } from "supabase/database.types";
+import {
+ getMicroLinkOgImage,
+ getWebpageImage,
+} from "src/utils/getMicroLinkOgImage";
export async function publishPostToBsky(args: {
text: string;
@@ -31,14 +35,11 @@ export async function publishPostToBsky(args: {
credentialSession.fetchHandler.bind(credentialSession),
);
let newPostUrl = args.url;
- let preview_image = await fetch(
- `https://pro.microlink.io/?url=${newPostUrl}&screenshot=true&viewport.width=1400&viewport.height=733&meta=false&embed=screenshot.url&force=true`,
- {
- headers: {
- "x-api-key": process.env.MICROLINK_API_KEY!,
- },
- },
- );
+ let preview_image = await getWebpageImage(newPostUrl, {
+ width: 1400,
+ height: 733,
+ noCache: true,
+ });
let binary = await preview_image.blob();
let resized_preview_image = await sharp(await binary.arrayBuffer())
diff --git a/app/api/link_previews/route.ts b/app/api/link_previews/route.ts
index 090674d9..9b7ad6b2 100644
--- a/app/api/link_previews/route.ts
+++ b/app/api/link_previews/route.ts
@@ -5,6 +5,10 @@ import { NextRequest } from "next/server";
import * as z from "zod";
import { createClient } from "@supabase/supabase-js";
import { Database } from "supabase/database.types";
+import {
+ getMicroLinkOgImage,
+ getWebpageImage,
+} from "src/utils/getMicroLinkOgImage";
let supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
process.env.SUPABASE_SERVICE_ROLE_KEY as string,
@@ -27,17 +31,7 @@ export type LinkPreviewMetadataResult = ReturnType;
export type LinkPreviewImageResult = ReturnType;
async function get_link_image_preview(url: string) {
- let image = await fetch(
- `https://pro.microlink.io/?url=${url}&screenshot&viewport.width=1400&viewport.height=1213&embed=screenshot.url&meta=false&force=true`,
- {
- headers: {
- "x-api-key": process.env.MICROLINK_API_KEY!,
- },
- next: {
- revalidate: 600,
- },
- },
- );
+ let image = await getWebpageImage(url, { width: 1400, height: 1213 });
let key = await hash(url);
if (image.status === 200) {
await supabase.storage
diff --git a/src/utils/getMicroLinkOgImage.ts b/src/utils/getMicroLinkOgImage.ts
index 4ccf6ece..fac40494 100644
--- a/src/utils/getMicroLinkOgImage.ts
+++ b/src/utils/getMicroLinkOgImage.ts
@@ -2,21 +2,58 @@ import { headers } from "next/headers";
export async function getMicroLinkOgImage(
path: string,
- options?: { width?: number; height?: number; deviceScaleFactor?: number },
+ options?: {
+ width?: number;
+ height?: number;
+ deviceScaleFactor?: number;
+ noCache?: boolean;
+ },
) {
const headersList = await headers();
const hostname = headersList.get("x-forwarded-host");
let protocol = headersList.get("x-forwarded-proto");
let full_path = `${protocol}://${hostname}${path}`;
+ return getWebpageImage(full_path, options);
+}
+
+export async function getWebpageImage(
+ url: string,
+ options?: {
+ width?: number;
+ height?: number;
+ deviceScaleFactor?: number;
+ noCache?: boolean;
+ },
+) {
let response = await fetch(
- `https://pro.microlink.io/?url=${encodeURIComponent(full_path)}&screenshot=true&viewport.width=${options?.width || 1400}&viewport.height=${options?.height || 733}&viewport.deviceScaleFactor=${options?.deviceScaleFactor || 1}&meta=false&embed=screenshot.url&force=true`,
+ `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT}/browser-rendering/screenshot`,
{
+ method: "POST",
headers: {
- "x-api-key": process.env.MICROLINK_API_KEY!,
- },
- next: {
- revalidate: 600,
+ "Content-type": "application/json",
+ Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
},
+ body: JSON.stringify({
+ url,
+ addStyleTag: [
+ {
+ content: `* {overflow: hidden !important; }`,
+ },
+ ],
+ gotoOptions: {
+ waitUntil: "load",
+ },
+ viewport: {
+ width: options?.width || 1400,
+ height: options?.height || 733,
+ deviceScaleFactor: options?.deviceScaleFactor,
+ },
+ }),
+ next: !options?.noCache
+ ? undefined
+ : {
+ revalidate: 600,
+ },
},
);
const clonedResponse = response.clone();
--
2.51.2
From 86030fa61064c9a68d99adbc49c2ed198a65d80c Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 12:59:42 -0500
Subject: [PATCH 09/18] don't modify cache headers for cloudflare browser
images
---
src/utils/getMicroLinkOgImage.ts | 13 +------------
1 file changed, 1 insertion(+), 12 deletions(-)
diff --git a/src/utils/getMicroLinkOgImage.ts b/src/utils/getMicroLinkOgImage.ts
index fac40494..2d71e21c 100644
--- a/src/utils/getMicroLinkOgImage.ts
+++ b/src/utils/getMicroLinkOgImage.ts
@@ -56,17 +56,6 @@ export async function getWebpageImage(
},
},
);
- const clonedResponse = response.clone();
- if (clonedResponse.status == 200) {
- clonedResponse.headers.set(
- "CDN-Cache-Control",
- "s-maxage=600, stale-while-revalidate=3600",
- );
- clonedResponse.headers.set(
- "Cache-Control",
- "s-maxage=600, stale-while-revalidate=3600",
- );
- }
- return clonedResponse;
+ return response;
}
--
2.51.2
From 604cc827f41d311ea156e25d012de024b06a3af9 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 16:18:39 -0500
Subject: [PATCH 10/18] remove serverExternalPackages yjs
---
next.config.js | 1 -
1 file changed, 1 deletion(-)
diff --git a/next.config.js b/next.config.js
index 68b3188e..f54ed91f 100644
--- a/next.config.js
+++ b/next.config.js
@@ -21,7 +21,6 @@ const nextConfig = {
},
];
},
- serverExternalPackages: ["yjs"],
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
images: {
loader: "custom",
--
2.51.2
From 4e8d3d34b508ebaab441aa5c584bd94f9b8e5a3e Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 16:20:36 -0500
Subject: [PATCH 11/18] Revert "render empty block as
"
This reverts commit df74efe5d21a5f156350660894aa7680b3a888e8.
---
components/Blocks/TextBlock/RenderYJSFragment.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx
index 94dc872f..132e72a7 100644
--- a/components/Blocks/TextBlock/RenderYJSFragment.tsx
+++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx
@@ -27,7 +27,7 @@ export function RenderYJSFragment({
return (
{children.length === 0 ? (
-
+
) : (
node.toArray().map((node, index) => {
if (node.constructor === XmlText) {
--
2.51.2
From 26eff9f5fb032ee330badb0943f48d3185d80c90 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 16:21:33 -0500
Subject: [PATCH 12/18] Revert "remove serverExternalPackages yjs"
This reverts commit 604cc827f41d311ea156e25d012de024b06a3af9.
---
next.config.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/next.config.js b/next.config.js
index f54ed91f..68b3188e 100644
--- a/next.config.js
+++ b/next.config.js
@@ -21,6 +21,7 @@ const nextConfig = {
},
];
},
+ serverExternalPackages: ["yjs"],
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
images: {
loader: "custom",
--
2.51.2
From 96b2c0b1485785027de30cef4d940f722ccc5e76 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 16:27:10 -0500
Subject: [PATCH 13/18] scroll page in browser screenshot
---
src/utils/getMicroLinkOgImage.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/utils/getMicroLinkOgImage.ts b/src/utils/getMicroLinkOgImage.ts
index 2d71e21c..c0b38592 100644
--- a/src/utils/getMicroLinkOgImage.ts
+++ b/src/utils/getMicroLinkOgImage.ts
@@ -35,6 +35,7 @@ export async function getWebpageImage(
},
body: JSON.stringify({
url,
+ scrollPage: true,
addStyleTag: [
{
content: `* {overflow: hidden !important; }`,
--
2.51.2
From 9105cbfdf48231a2ededd043c988913468d80c79 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 16:49:19 -0500
Subject: [PATCH 14/18] catch editor state done before subscribing
---
components/Blocks/TextBlock/mountProsemirror.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts
index e77b36bd..f4a59e32 100644
--- a/components/Blocks/TextBlock/mountProsemirror.ts
+++ b/components/Blocks/TextBlock/mountProsemirror.ts
@@ -91,6 +91,10 @@ export function useMountProsemirror({ props }: { props: BlockProps }) {
editorState.view?.updateState(editorState.editor);
});
+ let editorState = useEditorStates.getState().editorStates[entityID];
+ if (editorState?.editor && !editorState.initial)
+ editorState.view?.updateState(editorState.editor);
+
return () => {
unsubscribe();
view.destroy();
--
2.51.2
From 416b6959b833ab2af354f618a2843317d68d77d9 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 17:10:57 -0500
Subject: [PATCH 15/18] make server time different from local
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 74fbb5ee..e7b52160 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
- "dev": "next dev --turbo",
+ "dev": "TZ=UTC next dev --turbo",
"publish-lexicons": "tsx lexicons/publish.ts",
"generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta",
"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;",
--
2.51.2
From cb5781ee29b7a53775612680cccf2b923064fcb1 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 17:32:39 -0500
Subject: [PATCH 16/18] fix scrollbar styling in screenshots
---
src/utils/getMicroLinkOgImage.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/utils/getMicroLinkOgImage.ts b/src/utils/getMicroLinkOgImage.ts
index c0b38592..f9386088 100644
--- a/src/utils/getMicroLinkOgImage.ts
+++ b/src/utils/getMicroLinkOgImage.ts
@@ -10,8 +10,12 @@ export async function getMicroLinkOgImage(
},
) {
const headersList = await headers();
- const hostname = headersList.get("x-forwarded-host");
+ let hostname = headersList.get("x-forwarded-host");
let protocol = headersList.get("x-forwarded-proto");
+ if (process.env.NODE_ENV === "development") {
+ protocol === "https";
+ hostname = "leaflet.pub";
+ }
let full_path = `${protocol}://${hostname}${path}`;
return getWebpageImage(full_path, options);
}
@@ -38,7 +42,7 @@ export async function getWebpageImage(
scrollPage: true,
addStyleTag: [
{
- content: `* {overflow: hidden !important; }`,
+ content: `* {scrollbar-width:none; }`,
},
],
gotoOptions: {
--
2.51.2
From 76438c3d29a837f78e6ed069a1ffbe12ba977565 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 17 Nov 2025 17:45:20 -0500
Subject: [PATCH 17/18] rename confusing hook and reverse datetime pageload
logic
Squashed commit of the following:
commit c3deb565892c3a87102ba2ba11a698d6907e4955
Author: Jared Pereira
Date: Mon Nov 17 17:45:07 2025 -0500
remove logs
commit 3ff4755bd48b52cef44f1e88a3f0ffda12670f28
Author: Jared Pereira
Date: Mon Nov 17 17:38:04 2025 -0500
rename has page loaded and switch logic
commit 558d9b7cb8c08a07277ac14bab3c465571ff0e7e
Author: Jared Pereira
Date: Mon Nov 17 17:19:00 2025 -0500
log effectivetz and locale
commit 2748feba61f5e495480c92e108496da427be9727
Author: Jared Pereira
Date: Mon Nov 17 17:11:17 2025 -0500
are we getting tz on server/client?
---
.../[publication]/[rkey]/PublishBskyPostBlock.tsx | 4 ++--
components/Blocks/BlueskyPostBlock/index.tsx | 1 -
components/Blocks/DateTimeBlock.tsx | 4 ++--
components/Blocks/TextBlock/index.tsx | 4 ++--
components/InitialPageLoadProvider.tsx | 4 ++--
components/Providers/RequestHeadersProvider.tsx | 6 +++++-
src/hooks/useLocalizedDate.ts | 10 +++++-----
7 files changed, 18 insertions(+), 15 deletions(-)
diff --git a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
index 914c082e..596e12e2 100644
--- a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
@@ -7,7 +7,7 @@ import { elementId } from "src/utils/elementId";
import { focusBlock } from "src/utils/focusBlock";
import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api";
import { Separator } from "components/Layout";
-import { useInitialPageLoad } from "components/InitialPageLoadProvider";
+import { useHasPageLoaded } from "components/InitialPageLoadProvider";
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
import { CommentTiny } from "components/Icons/CommentTiny";
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
@@ -123,7 +123,7 @@ export const PubBlueskyPostBlock = (props: {
};
const ClientDate = (props: { date?: string }) => {
- let pageLoaded = useInitialPageLoad();
+ let pageLoaded = useHasPageLoaded();
const formattedDate = useLocalizedDate(
props.date || new Date().toISOString(),
{
diff --git a/components/Blocks/BlueskyPostBlock/index.tsx b/components/Blocks/BlueskyPostBlock/index.tsx
index d9169daa..bb04a7e3 100644
--- a/components/Blocks/BlueskyPostBlock/index.tsx
+++ b/components/Blocks/BlueskyPostBlock/index.tsx
@@ -10,7 +10,6 @@ import { BlueskyEmbed, PostNotAvailable } from "./BlueskyEmbed";
import { BlueskyPostEmpty } from "./BlueskyEmpty";
import { BlueskyRichText } from "./BlueskyRichText";
import { Separator } from "components/Layout";
-import { useInitialPageLoad } from "components/InitialPageLoadProvider";
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
import { CommentTiny } from "components/Icons/CommentTiny";
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
diff --git a/components/Blocks/DateTimeBlock.tsx b/components/Blocks/DateTimeBlock.tsx
index 6e202355..29430a33 100644
--- a/components/Blocks/DateTimeBlock.tsx
+++ b/components/Blocks/DateTimeBlock.tsx
@@ -8,14 +8,14 @@ import { useUIState } from "src/useUIState";
import { setHours, setMinutes } from "date-fns";
import { Separator } from "react-aria-components";
import { Checkbox } from "components/Checkbox";
-import { useInitialPageLoad } from "components/InitialPageLoadProvider";
+import { useHasPageLoaded } from "components/InitialPageLoadProvider";
import { useSpring, animated } from "@react-spring/web";
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
export function DateTimeBlock(props: BlockProps) {
const [isClient, setIsClient] = useState(false);
- let initialPageLoad = useInitialPageLoad();
+ let initialPageLoad = useHasPageLoaded();
useEffect(() => {
setIsClient(true);
diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx
index e143ac84..842efd10 100644
--- a/components/Blocks/TextBlock/index.tsx
+++ b/components/Blocks/TextBlock/index.tsx
@@ -4,7 +4,7 @@ import { useReplicache, useEntity } from "src/replicache";
import { isVisible } from "src/utils/isVisible";
import { EditorState, TextSelection } from "prosemirror-state";
import { RenderYJSFragment } from "./RenderYJSFragment";
-import { useInitialPageLoad } from "components/InitialPageLoadProvider";
+import { useHasPageLoaded } from "components/InitialPageLoadProvider";
import { BlockProps } from "../Block";
import { focusBlock } from "src/utils/focusBlock";
import { useUIState } from "src/useUIState";
@@ -37,7 +37,7 @@ export function TextBlock(
},
) {
let isLocked = useEntity(props.entityID, "block/is-locked");
- let initialized = useInitialPageLoad();
+ let initialized = useHasPageLoaded();
let first = props.previousBlock === null;
let permission = useEntitySetContext().permissions.write;
diff --git a/components/InitialPageLoadProvider.tsx b/components/InitialPageLoadProvider.tsx
index a40eb2ac..7a408740 100644
--- a/components/InitialPageLoadProvider.tsx
+++ b/components/InitialPageLoadProvider.tsx
@@ -2,11 +2,11 @@
import { useEffect } from "react";
import { create } from "zustand";
-export const useInitialPageLoad = create(() => false);
+export const useHasPageLoaded = create(() => false);
export function InitialPageLoad(props: { children: React.ReactNode }) {
useEffect(() => {
setTimeout(() => {
- useInitialPageLoad.setState(() => true);
+ useHasPageLoaded.setState(() => true);
}, 80);
}, []);
return <>{props.children}>;
diff --git a/components/Providers/RequestHeadersProvider.tsx b/components/Providers/RequestHeadersProvider.tsx
index f85a374a..7915e569 100644
--- a/components/Providers/RequestHeadersProvider.tsx
+++ b/components/Providers/RequestHeadersProvider.tsx
@@ -21,7 +21,11 @@ export const RequestHeadersProvider = (props: {
}) => {
return (
{props.children}
diff --git a/src/hooks/useLocalizedDate.ts b/src/hooks/useLocalizedDate.ts
index 4fab87f3..ad81a4cc 100644
--- a/src/hooks/useLocalizedDate.ts
+++ b/src/hooks/useLocalizedDate.ts
@@ -2,7 +2,7 @@
import { useContext, useMemo } from "react";
import { DateTime } from "luxon";
import { RequestHeadersContext } from "components/Providers/RequestHeadersProvider";
-import { useInitialPageLoad } from "components/InitialPageLoadProvider";
+import { useHasPageLoaded } from "components/InitialPageLoadProvider";
/**
* Hook that formats a date string using Luxon with timezone and locale from request headers.
@@ -20,14 +20,14 @@ export function useLocalizedDate(
options?: Intl.DateTimeFormatOptions,
): string {
const { timezone, language } = useContext(RequestHeadersContext);
- const isInitialPageLoad = useInitialPageLoad();
+ const hasPageLoaded = useHasPageLoaded();
return useMemo(() => {
// Parse the date string to Luxon DateTime
let dateTime = DateTime.fromISO(dateString);
// On initial page load, use header timezone. After hydration, use system timezone
- const effectiveTimezone = isInitialPageLoad
+ const effectiveTimezone = !hasPageLoaded
? timezone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -39,7 +39,7 @@ export function useLocalizedDate(
// On initial page load, use header locale. After hydration, use system locale
// Parse locale from accept-language header (take first locale)
// accept-language format: "en-US,en;q=0.9,es;q=0.8"
- const effectiveLocale = isInitialPageLoad
+ const effectiveLocale = !hasPageLoaded
? language?.split(",")[0]?.split(";")[0]?.trim() || "en-US"
: Intl.DateTimeFormat().resolvedOptions().locale;
@@ -49,5 +49,5 @@ export function useLocalizedDate(
// Fallback to en-US if locale is invalid
return dateTime.toLocaleString(options, { locale: "en-US" });
}
- }, [dateString, options, timezone, language, isInitialPageLoad]);
+ }, [dateString, options, timezone, language, hasPageLoaded]);
}
--
2.51.2
From 260781c6687a70653ae71d14208d0bb10adf27fb Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 18 Nov 2025 13:57:49 -0500
Subject: [PATCH 18/18] create leaflet_to_documents table
---
drizzle/relations.ts | 24 ++++---
drizzle/schema.ts | 12 +++-
supabase/database.types.ts | 39 ++++++++++++
...251118185507_add_leaflets_to_documents.sql | 63 +++++++++++++++++++
4 files changed, 128 insertions(+), 10 deletions(-)
create mode 100644 supabase/migrations/20251118185507_add_leaflets_to_documents.sql
diff --git a/drizzle/relations.ts b/drizzle/relations.ts
index ee948385..1e08d4fe 100644
--- a/drizzle/relations.ts
+++ b/drizzle/relations.ts
@@ -1,19 +1,15 @@
import { relations } from "drizzle-orm/relations";
-import { identities, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema";
+import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema";
-export const publicationsRelations = relations(publications, ({one, many}) => ({
+export const notificationsRelations = relations(notifications, ({one}) => ({
identity: one(identities, {
- fields: [publications.identity_did],
+ fields: [notifications.recipient],
references: [identities.atp_did]
}),
- subscribers_to_publications: many(subscribers_to_publications),
- documents_in_publications: many(documents_in_publications),
- publication_domains: many(publication_domains),
- leaflets_in_publications: many(leaflets_in_publications),
- publication_subscriptions: many(publication_subscriptions),
}));
export const identitiesRelations = relations(identities, ({one, many}) => ({
+ notifications: many(notifications),
publications: many(publications),
email_auth_tokens: many(email_auth_tokens),
bsky_profiles: many(bsky_profiles),
@@ -39,6 +35,18 @@ export const identitiesRelations = relations(identities, ({one, many}) => ({
publication_subscriptions: many(publication_subscriptions),
}));
+export const publicationsRelations = relations(publications, ({one, many}) => ({
+ identity: one(identities, {
+ fields: [publications.identity_did],
+ references: [identities.atp_did]
+ }),
+ subscribers_to_publications: many(subscribers_to_publications),
+ documents_in_publications: many(documents_in_publications),
+ publication_domains: many(publication_domains),
+ leaflets_in_publications: many(leaflets_in_publications),
+ publication_subscriptions: many(publication_subscriptions),
+}));
+
export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({
document: one(documents, {
fields: [comments_on_documents.document],
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 4745bf86..153b82e6 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -1,4 +1,4 @@
-import { pgTable, pgEnum, text, jsonb, index, foreignKey, timestamp, uuid, bigint, boolean, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core"
+import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3'])
@@ -15,7 +15,7 @@ export const key_type = pgEnum("key_type", ['aead-ietf', 'aead-det', 'hmacsha512
export const rsvp_status = pgEnum("rsvp_status", ['GOING', 'NOT_GOING', 'MAYBE'])
export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR'])
export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in'])
-export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS'])
+export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS', 'VECTOR'])
export const oauth_state_store = pgTable("oauth_state_store", {
@@ -23,6 +23,14 @@ export const oauth_state_store = pgTable("oauth_state_store", {
state: jsonb("state").notNull(),
});
+export const notifications = pgTable("notifications", {
+ recipient: text("recipient").notNull().references(() => identities.atp_did, { onDelete: "cascade", onUpdate: "cascade" } ),
+ created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
+ read: boolean("read").default(false).notNull(),
+ data: jsonb("data").notNull(),
+ id: uuid("id").primaryKey().notNull(),
+});
+
export const publications = pgTable("publications", {
uri: text("uri").primaryKey().notNull(),
indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
diff --git a/supabase/database.types.ts b/supabase/database.types.ts
index ae0c93b0..93efcbe3 100644
--- a/supabase/database.types.ts
+++ b/supabase/database.types.ts
@@ -624,6 +624,45 @@ export type Database = {
},
]
}
+ leaflets_to_documents: {
+ Row: {
+ created_at: string
+ description: string
+ document: string
+ leaflet: string
+ title: string
+ }
+ Insert: {
+ created_at?: string
+ description?: string
+ document: string
+ leaflet: string
+ title?: string
+ }
+ Update: {
+ created_at?: string
+ description?: string
+ document?: string
+ leaflet?: string
+ title?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "leaflets_to_documents_document_fkey"
+ columns: ["document"]
+ isOneToOne: false
+ referencedRelation: "documents"
+ referencedColumns: ["uri"]
+ },
+ {
+ foreignKeyName: "leaflets_to_documents_leaflet_fkey"
+ columns: ["leaflet"]
+ isOneToOne: false
+ referencedRelation: "permission_tokens"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
notifications: {
Row: {
created_at: string
diff --git a/supabase/migrations/20251118185507_add_leaflets_to_documents.sql b/supabase/migrations/20251118185507_add_leaflets_to_documents.sql
new file mode 100644
index 00000000..f79aa7cd
--- /dev/null
+++ b/supabase/migrations/20251118185507_add_leaflets_to_documents.sql
@@ -0,0 +1,63 @@
+create table "public"."leaflets_to_documents" (
+ "leaflet" uuid not null,
+ "document" text not null,
+ "created_at" timestamp with time zone not null default now(),
+ "title" text not null default ''::text,
+ "description" text not null default ''::text
+);
+
+alter table "public"."leaflets_to_documents" enable row level security;
+
+CREATE UNIQUE INDEX leaflets_to_documents_pkey ON public.leaflets_to_documents USING btree (leaflet, document);
+
+alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_pkey" PRIMARY KEY using index "leaflets_to_documents_pkey";
+
+alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid;
+
+alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_document_fkey";
+
+alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_leaflet_fkey" FOREIGN KEY (leaflet) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
+
+alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_leaflet_fkey";
+
+grant delete on table "public"."leaflets_to_documents" to "anon";
+
+grant insert on table "public"."leaflets_to_documents" to "anon";
+
+grant references on table "public"."leaflets_to_documents" to "anon";
+
+grant select on table "public"."leaflets_to_documents" to "anon";
+
+grant trigger on table "public"."leaflets_to_documents" to "anon";
+
+grant truncate on table "public"."leaflets_to_documents" to "anon";
+
+grant update on table "public"."leaflets_to_documents" to "anon";
+
+grant delete on table "public"."leaflets_to_documents" to "authenticated";
+
+grant insert on table "public"."leaflets_to_documents" to "authenticated";
+
+grant references on table "public"."leaflets_to_documents" to "authenticated";
+
+grant select on table "public"."leaflets_to_documents" to "authenticated";
+
+grant trigger on table "public"."leaflets_to_documents" to "authenticated";
+
+grant truncate on table "public"."leaflets_to_documents" to "authenticated";
+
+grant update on table "public"."leaflets_to_documents" to "authenticated";
+
+grant delete on table "public"."leaflets_to_documents" to "service_role";
+
+grant insert on table "public"."leaflets_to_documents" to "service_role";
+
+grant references on table "public"."leaflets_to_documents" to "service_role";
+
+grant select on table "public"."leaflets_to_documents" to "service_role";
+
+grant trigger on table "public"."leaflets_to_documents" to "service_role";
+
+grant truncate on table "public"."leaflets_to_documents" to "service_role";
+
+grant update on table "public"."leaflets_to_documents" to "service_role";