Compare commits

...

7 Commits

Author SHA1 Message Date
Philipinho 7861b5b186 fix: add RedisModule to CollabAppModule 2026-02-09 18:50:31 -08:00
Philipinho 3a9bdfbb06 fix(deps): update vite and nx 2026-02-09 18:32:09 -08:00
Philipinho ab7999a946 v0.25.3 2026-02-09 18:27:55 -08:00
Philip Okugbe 0f02261ee6 feat: page version history improvements (#1925)
* Refactor: use queue for page history

* feat: save multiple version contributors

* display contributor avatars in history list

* fix interval
2026-02-09 18:25:35 -08:00
Philip Okugbe aff8dba2cb fix: diagrams SVG content length (#1928) 2026-02-09 18:20:09 -08:00
Olivier Lambert f6a8247c48 fix: cursor jumps to end of text when editing a comment (#1924)
* fix: cursor jumps to end of text when editing a comment

When editing a comment mid-text, the cursor would jump to the end after
every keystroke, making it impossible to insert text at any position
other than the end.

Root cause: on each keystroke, the comment editor's onUpdate callback
updated parent state (setContent), which changed the defaultContent prop
passed back to CommentEditor. A useEffect watching defaultContent then
called commentEditor.commands.setContent(), which reset the entire
editor content and moved the cursor to the end.

Fix:
- Store in-progress edits in a ref instead of state to avoid triggering
  React re-renders and the prop->effect->setContent cascade
- Read from the ref when saving the comment
- Sync the ref back into state after a successful save so the read-only
  view updates immediately
- Guard the setContent useEffect to only run for read-only editors, so
  websocket-driven updates from other browsers still work

Fixes #1791

Functionally tested on Firefox and Chrome: mid-text editing, saving,
cross-browser live updates via websocket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix stale content on edit cancel

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-02-09 15:16:40 -08:00
Philip Okugbe 7879e1f600 fix: add execCommand fallback for clipboard (#1927)
* fix: add execCommand fallback for clipboard
2026-02-09 14:44:27 -08:00
37 changed files with 1025 additions and 880 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.25.2", "version": "0.25.3",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -25,7 +25,7 @@
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.2", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@@ -0,0 +1,33 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
// modified to use the polyfilled clipboard api
import React from "react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useProps } from "@mantine/core";
interface CopyButtonProps {
/** Children callback, provides current status and copy function as an argument */
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
/** Value that is copied to the clipboard when the button is clicked */
value: string;
/** Copied status timeout in ms @default `1000` */
timeout?: number;
}
const defaultProps = {
timeout: 1000,
} satisfies Partial<CopyButtonProps>;
export function CopyButton(props: CopyButtonProps) {
const { children, timeout, value, ...others } = useProps(
"CopyButton",
defaultProps,
props,
);
const clipboard = useClipboard({ timeout });
const copy = () => clipboard.copy(value);
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
}
CopyButton.displayName = "@mantine/core/CopyButton";
+2 -1
View File
@@ -1,4 +1,5 @@
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -8,10 +8,10 @@ import {
Group, Group,
List, List,
Code, Code,
CopyButton,
Alert, Alert,
PasswordInput, PasswordInput,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconRefresh, IconRefresh,
IconCopy, IconCopy,
@@ -11,7 +11,6 @@ import {
PinInput, PinInput,
Alert, Alert,
List, List,
CopyButton,
ActionIcon, ActionIcon,
Tooltip, Tooltip,
Paper, Paper,
@@ -20,6 +19,7 @@ import {
Collapse, Collapse,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconQrcode, IconQrcode,
IconShieldCheck, IconShieldCheck,
@@ -84,9 +84,14 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });
// Sync content from props for read-only editors (e.g. when updated via
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => { useEffect(() => {
commentEditor.commands.setContent(defaultContent); if (!editable && commentEditor && defaultContent) {
}, [defaultContent]); commentEditor.commands.setContent(defaultContent);
}
}, [defaultContent, editable, commentEditor]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
@@ -1,5 +1,5 @@
import { Group, Text, Box, Badge } from "@mantine/core"; import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
@@ -40,6 +40,7 @@ function CommentListItem({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom); const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content); const [content, setContent] = useState<string>(comment.content);
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation(); const resolveCommentMutation = useResolveCommentMutation();
@@ -56,9 +57,13 @@ function CommentListItem({
setIsLoading(true); setIsLoading(true);
const commentToUpdate = { const commentToUpdate = {
commentId: comment.id, commentId: comment.id,
content: JSON.stringify(content), content: JSON.stringify(editContentRef.current ?? content),
}; };
await updateCommentMutation.mutateAsync(commentToUpdate); await updateCommentMutation.mutateAsync(commentToUpdate);
if (editContentRef.current) {
setContent(editContentRef.current);
editContentRef.current = null;
}
setIsEditing(false); setIsEditing(false);
emit({ emit({
@@ -128,6 +133,7 @@ function CommentListItem({
setIsEditing(true); setIsEditing(true);
} }
function cancelEdit() { function cancelEdit() {
editContentRef.current = null;
setIsEditing(false); setIsEditing(false);
} }
@@ -194,7 +200,7 @@ function CommentListItem({
<CommentEditor <CommentEditor
defaultContent={content} defaultContent={content}
editable={true} editable={true}
onUpdate={(newContent: any) => setContent(newContent)} onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
onSave={handleUpdateComment} onSave={handleUpdateComment}
autofocus={true} autofocus={true}
/> />
@@ -1,5 +1,6 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core"; import { ActionIcon, Group, Select, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
@@ -1,4 +1,4 @@
import { Text, Group, UnstyledButton } from "@mantine/core"; import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time"; import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css"; import classes from "./css/history.module.css";
@@ -6,6 +6,8 @@ import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types"; import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react"; import { memo, useCallback } from "react";
const MAX_VISIBLE_AVATARS = 5;
interface HistoryItemProps { interface HistoryItemProps {
historyItem: IPageHistory; historyItem: IPageHistory;
index: number; index: number;
@@ -31,6 +33,9 @@ const HistoryItem = memo(function HistoryItem({
onHover?.(historyItem.id, index); onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]); }, [onHover, historyItem.id, index]);
const contributors = historyItem.contributors;
const hasContributors = contributors && contributors.length > 0;
return ( return (
<UnstyledButton <UnstyledButton
p="xs" p="xs"
@@ -39,25 +44,54 @@ const HistoryItem = memo(function HistoryItem({
onMouseLeave={onHoverEnd} onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })} className={clsx(classes.history, { [classes.active]: isActive })}
> >
<Group wrap="nowrap"> <Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<div>
<Text size="sm">
{formattedDate(new Date(historyItem.createdAt))}
</Text>
<div style={{ flex: 1 }}> <Group gap={6} wrap="nowrap" mt={4}>
<Group gap={4} wrap="nowrap"> {hasContributors ? (
<CustomAvatar <>
size="sm" <Tooltip.Group openDelay={300} closeDelay={100}>
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl} <Avatar.Group spacing={8}>
name={historyItem.lastUpdatedBy?.name} {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
/> <Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
<div key={c.id}>{c.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name} {contributors[0].name}
</Text> </Text>
</Group> )}
</div> </>
</div> ) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group> </Group>
</UnstyledButton> </UnstyledButton>
); );
@@ -62,11 +62,18 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
const selectData = useMemo( const selectData = useMemo(
() => () =>
historyItems.map((item) => ({ historyItems.map((item) => {
value: item.id, const contributors = item.contributors;
label: formattedDate(new Date(item.createdAt)), const hasContributors = contributors && contributors.length > 0;
userName: item.lastUpdatedBy?.name, const names = hasContributors
})), ? contributors.map((c) => c.name).join(", ")
: item.lastUpdatedBy?.name;
return {
value: item.id,
label: formattedDate(new Date(item.createdAt)),
userName: names,
};
}),
[historyItems], [historyItems],
); );
@@ -18,4 +18,5 @@ export interface IPageHistory {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
lastUpdatedBy: IPageHistoryUser; lastUpdatedBy: IPageHistoryUser;
contributors?: IPageHistoryUser[];
} }
@@ -17,7 +17,8 @@ import React, { useEffect, useRef, useState } from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks"; import { useDisclosure, useHotkeys } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { import {
useClipboard,
useDisclosure, useDisclosure,
useElementSize, useElementSize,
useMergedRef, useMergedRef,
} from "@mantine/hooks"; } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { dfs } from "react-arborist/dist/module/utils"; import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -13,7 +13,7 @@ import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts"; import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
@@ -8,7 +8,7 @@ import {
} from "@/features/workspace/queries/workspace-query.ts"; } from "@/features/workspace/queries/workspace-query.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@/hooks/use-clipboard";
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts"; import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
@@ -1,7 +1,8 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core"; import { Button, Group, Text, TextInput } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function WorkspaceInviteSection() { export default function WorkspaceInviteSection() {
+60
View File
@@ -0,0 +1,60 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
// polyfilled to support execCommand fallback
import { useState } from "react";
import { execCommandCopy } from "@docmost/editor-ext";
export type UseClipboardOptions = {
timeout?: number;
};
export type UseClipboardReturnValue = {
copy: (value: string) => void;
reset: () => void;
error: Error | null;
copied: boolean;
};
export function useClipboard(
options: UseClipboardOptions = { timeout: 2000 },
): UseClipboardReturnValue {
const [error, setError] = useState<Error | null>(null);
const [copied, setCopied] = useState(false);
const [copyTimeout, setCopyTimeout] = useState<number | null>(null);
const handleCopyResult = (value: boolean) => {
window.clearTimeout(copyTimeout!);
setCopyTimeout(window.setTimeout(() => setCopied(false), options.timeout));
setCopied(value);
};
const copy = (value: string) => {
if ("clipboard" in navigator) {
navigator.clipboard
.writeText(value)
.then(() => handleCopyResult(true))
.catch(() => {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
});
} else {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
}
};
const reset = () => {
setCopied(false);
setError(null);
window.clearTimeout(copyTimeout!);
};
return { copy, reset, error, copied };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.25.2", "version": "0.25.3",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -7,9 +7,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http'; import { IncomingMessage } from 'http';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import { TokenModule } from '../core/auth/token.module'; import { TokenModule } from '../core/auth/token.module';
import { HistoryListener } from './listeners/history.listener'; import { HistoryProcessor } from './processors/history.processor';
import { LoggerExtension } from './extensions/logger.extension'; import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler'; import { CollaborationHandler } from './collaboration.handler';
import { CollabHistoryService } from './services/collab-history.service';
@Module({ @Module({
providers: [ providers: [
@@ -17,7 +18,8 @@ import { CollaborationHandler } from './collaboration.handler';
AuthenticationExtension, AuthenticationExtension,
PersistenceExtension, PersistenceExtension,
LoggerExtension, LoggerExtension,
HistoryListener, HistoryProcessor,
CollabHistoryService,
CollaborationHandler, CollaborationHandler,
], ],
exports: [CollaborationGateway], exports: [CollaborationGateway],
@@ -0,0 +1,3 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
@@ -13,7 +13,6 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
@@ -22,8 +21,17 @@ import {
extractPageMentions, extractPageMentions,
} from '../../common/helpers/prosemirror/utils'; } from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface'; import {
IPageBacklinkJob,
IPageHistoryJob,
} from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service';
import {
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
@Injectable() @Injectable()
export class PersistenceExtension implements Extension { export class PersistenceExtension implements Extension {
@@ -33,9 +41,10 @@ export class PersistenceExtension implements Extension {
constructor( constructor(
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
private readonly collabHistory: CollabHistoryService,
) {} ) {}
async onLoadDocument(data: onLoadDocumentPayload) { async onLoadDocument(data: onLoadDocumentPayload) {
@@ -101,6 +110,7 @@ export class PersistenceExtension implements Extension {
} }
let page: Page = null; let page: Page = null;
const editingUserIds = this.consumeContributors(documentName);
try { try {
await executeTx(this.db, async (trx) => { await executeTx(this.db, async (trx) => {
@@ -123,13 +133,9 @@ export class PersistenceExtension implements Extension {
let contributorIds = undefined; let contributorIds = undefined;
try { try {
const existingContributors = page.contributorIds || []; const existingContributors = page.contributorIds || [];
const contributorSet = this.contributors.get(documentName);
contributorSet.add(page.creatorId);
const newContributors = [...contributorSet];
contributorIds = Array.from( contributorIds = Array.from(
new Set([...existingContributors, ...newContributors]), new Set([...existingContributors, ...editingUserIds, page.creatorId]),
); );
this.contributors.delete(documentName);
} catch (err) { } catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']); //this.logger.debug('Contributors error:' + err?.['message']);
} }
@@ -153,13 +159,7 @@ export class PersistenceExtension implements Extension {
} }
if (page) { if (page) {
this.eventEmitter.emit('collab.page.updated', { await this.collabHistory.addContributors(pageId, editingUserIds);
page: {
...page,
content: tiptapJson,
lastUpdatedById: context.user.id,
},
});
const mentions = extractMentions(tiptapJson); const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions); const pageMentions = extractPageMentions(mentions);
@@ -174,6 +174,8 @@ export class PersistenceExtension implements Extension {
pageIds: [pageId], pageIds: [pageId],
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
}); });
await this.enqueuePageHistory(page);
} }
} }
@@ -193,4 +195,26 @@ export class PersistenceExtension implements Extension {
const documentName = data.documentName; const documentName = data.documentName;
this.contributors.delete(documentName); this.contributors.delete(documentName);
} }
private consumeContributors(documentName: string): string[] {
const contributorSet = this.contributors.get(documentName);
if (!contributorSet) return [];
const userIds = [...contributorSet];
this.contributors.delete(documentName);
return userIds;
}
private async enqueuePageHistory(page: Page): Promise<void> {
const pageAge = Date.now() - new Date(page.createdAt).getTime();
const delay =
pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
await this.historyQueue.add(
QueueJob.PAGE_HISTORY,
{ pageId: page.id } as IPageHistoryJob,
{ jobId: page.id, delay },
);
}
} }
@@ -1,52 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { Page } from '@docmost/db/types/entity.types';
import { isDeepStrictEqual } from 'node:util';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class UpdatedPageEvent {
page: Page;
}
@Injectable()
export class HistoryListener {
private readonly logger = new Logger(HistoryListener.name);
constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly environmentService: EnvironmentService,
) {}
@OnEvent('collab.page.updated')
async handleCreatePageHistory(event: UpdatedPageEvent) {
const { page } = event;
const pageCreationTime = new Date(page.createdAt).getTime();
const currentTime = Date.now();
const FIVE_MINUTES = this.environmentService.isDevelopment()
? 60 * 1000
: 5 * 60 * 1000;
if (currentTime - pageCreationTime < FIVE_MINUTES) {
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id, {
includeContent: true,
});
if (
!lastHistory ||
(!isDeepStrictEqual(lastHistory.content, page.content) &&
currentTime - new Date(lastHistory.createdAt).getTime() >= FIVE_MINUTES)
) {
try {
await this.pageHistoryRepo.saveHistory(page);
this.logger.debug(`New history created for: ${page.id}`);
} catch (err) {
this.logger.error(`Failed to create history for page: ${page.id}`, err);
}
}
}
}
@@ -0,0 +1,84 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(HistoryProcessor.name);
constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
) {
super();
}
async process(job: Job<IPageHistoryJob, void>): Promise<void> {
if (job.name !== QueueJob.PAGE_HISTORY) return;
try {
const { pageId } = job.data;
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (!page) {
this.logger.warn(`Page ${pageId} not found, skipping history`);
await this.collabHistory.clearContributors(pageId);
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true },
);
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds =
await this.collabHistory.popContributors(pageId);
try {
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(
pageId,
contributorIds,
);
throw err;
}
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} for page: ${job.data.pageId}`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Failed ${job.name} for page: ${job.data.pageId}. Reason: ${job.failedReason}`,
);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -9,6 +9,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from '../../integrations/health/health.module'; import { HealthModule } from '../../integrations/health/health.module';
import { CollaborationController } from './collaboration.controller'; import { CollaborationController } from './collaboration.controller';
import { LoggerModule } from '../../common/logger/logger.module'; import { LoggerModule } from '../../common/logger/logger.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
@Module({ @Module({
imports: [ imports: [
@@ -19,6 +21,9 @@ import { LoggerModule } from '../../common/logger/logger.module';
QueueModule, QueueModule,
HealthModule, HealthModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
], ],
controllers: [ controllers: [
AppController, AppController,
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
const REDIS_KEY_PREFIX = 'history:contributors:';
@Injectable()
export class CollabHistoryService {
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
async addContributors(pageId: string, userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
await this.redis.sadd(REDIS_KEY_PREFIX + pageId, ...userIds);
}
async popContributors(pageId: string): Promise<string[]> {
const key = REDIS_KEY_PREFIX + pageId;
const count = await this.redis.scard(key);
if (count === 0) return [];
return await this.redis.spop(key, count);
}
async clearContributors(pageId: string): Promise<void> {
await this.redis.del(REDIS_KEY_PREFIX + pageId);
}
}
@@ -464,7 +464,8 @@ export class AttachmentController {
'Cache-Control': `${cacheScope}, max-age=3600`, 'Cache-Control': `${cacheScope}, max-age=3600`,
}); });
if (fileSize) { const isSvg = attachment.fileExt === '.svg';
if (fileSize && !isSvg) {
res.header('Content-Length', fileSize); res.header('Content-Length', fileSize);
} }
@@ -99,6 +99,7 @@ export class AttachmentService {
if (isUpdate) { if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment( attachment = await this.attachmentRepo.updateAttachment(
{ {
fileSize: preparedFile.fileSize,
updatedAt: new Date(), updatedAt: new Date(),
}, },
attachmentId, attachmentId,
@@ -0,0 +1,15 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.dropColumn('contributor_ids')
.execute();
}
@@ -9,8 +9,8 @@ import {
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder } from 'kysely'; import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
@Injectable() @Injectable()
@@ -25,6 +25,7 @@ export class PageHistoryRepo {
'icon', 'icon',
'coverPhoto', 'coverPhoto',
'lastUpdatedById', 'lastUpdatedById',
'contributorIds',
'spaceId', 'spaceId',
'workspaceId', 'workspaceId',
'createdAt', 'createdAt',
@@ -44,6 +45,7 @@ export class PageHistoryRepo {
.select(this.baseFields) .select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content')) .$if(opts?.includeContent, (qb) => qb.select('content'))
.select((eb) => this.withLastUpdatedBy(eb)) .select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('id', '=', pageHistoryId) .where('id', '=', pageHistoryId)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -60,7 +62,10 @@ export class PageHistoryRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async saveHistory(page: Page, trx?: KyselyTransaction): Promise<void> { async saveHistory(
page: Page,
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
): Promise<void> {
await this.insertPageHistory( await this.insertPageHistory(
{ {
pageId: page.id, pageId: page.id,
@@ -70,10 +75,11 @@ export class PageHistoryRepo {
icon: page.icon, icon: page.icon,
coverPhoto: page.coverPhoto, coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId, lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
contributorIds: opts?.contributorIds,
spaceId: page.spaceId, spaceId: page.spaceId,
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
}, },
trx, opts?.trx,
); );
} }
@@ -82,6 +88,7 @@ export class PageHistoryRepo {
.selectFrom('pageHistory') .selectFrom('pageHistory')
.select(this.baseFields) .select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb)) .select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('pageId', '=', pageId); .where('pageId', '=', pageId);
return executeWithCursorPagination(query, { return executeWithCursorPagination(query, {
@@ -120,4 +127,17 @@ export class PageHistoryRepo {
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'), .whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
).as('lastUpdatedBy'); ).as('lastUpdatedBy');
} }
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef(
'users.id',
'=',
sql`ANY(${eb.ref('pageHistory.contributorIds')})`,
),
).as('contributors');
}
} }
+1
View File
@@ -199,6 +199,7 @@ export interface GroupUsers {
export interface PageHistory { export interface PageHistory {
content: Json | null; content: Json | null;
contributorIds: Generated<string[] | null>;
coverPhoto: string | null; coverPhoto: string | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
icon: string | null; icon: string | null;
@@ -6,6 +6,7 @@ export enum QueueName {
FILE_TASK_QUEUE = '{file-task-queue}', FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}', SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}', AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
} }
export enum QueueJob { export enum QueueJob {
@@ -58,4 +59,6 @@ export enum QueueJob {
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings', GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings', DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
PAGE_HISTORY = 'page-history',
} }
@@ -9,4 +9,8 @@ export interface IPageBacklinkJob {
export interface IStripeSeatsSyncJob { export interface IStripeSeatsSyncJob {
workspaceId: string; workspaceId: string;
}
export interface IPageHistoryJob {
pageId: string;
} }
@@ -73,6 +73,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 1, attempts: 1,
}, },
}), }),
BullModule.registerQueue({
name: QueueName.HISTORY_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
], ],
exports: [BullModule], exports: [BullModule],
providers: [BacklinksProcessor], providers: [BacklinksProcessor],
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.25.2", "version": "0.25.3",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
@@ -78,12 +78,12 @@
"yjs": "^13.6.29" "yjs": "^13.6.29"
}, },
"devDependencies": { "devDependencies": {
"@nx/js": "20.4.5", "@nx/js": "22.5.0",
"@types/bytes": "^3.1.5", "@types/bytes": "^3.1.5",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"nx": "20.4.5", "nx": "22.5.0",
"tsx": "^4.19.3" "tsx": "^4.19.3"
}, },
"workspaces": { "workspaces": {
@@ -4,6 +4,7 @@ import TiptapHeading, {
import { mergeAttributes } from "@tiptap/react"; import { mergeAttributes } from "@tiptap/react";
import { Decoration, DecorationSet } from "prosemirror-view"; import { Decoration, DecorationSet } from "prosemirror-view";
import { Plugin } from "prosemirror-state"; import { Plugin } from "prosemirror-state";
import { copyToClipboard } from "../utils";
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`; const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="color: forestgreen;" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>`; const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="color: forestgreen;" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>`;
@@ -41,7 +42,7 @@ export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
const id = node.attrs.id; const id = node.attrs.id;
const baseUrl = window.location.href.split('#')[0]; const baseUrl = window.location.href.split('#')[0];
const url = `${baseUrl}#${id}`; const url = `${baseUrl}#${id}`;
navigator.clipboard.writeText(url); copyToClipboard(url);
linkBtnContent.innerHTML = successIcon; linkBtnContent.innerHTML = successIcon;
setTimeout( setTimeout(
() => (linkBtnContent.innerHTML = copyIcon), () => (linkBtnContent.innerHTML = copyIcon),
+22
View File
@@ -384,3 +384,25 @@ export function sanitizeUrl(url: string | undefined): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz"; const alphabet = "abcdefghijklmnopqrstuvwxyz";
export const generateNodeId = customAlphabet(alphabet, 12); export const generateNodeId = customAlphabet(alphabet, 12);
export function copyToClipboard(text: string): void {
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text).catch(() => {
execCommandCopy(text);
});
} else {
execCommandCopy(text);
}
}
export function execCommandCopy(text: string): void {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
+586 -763
View File
File diff suppressed because it is too large Load Diff