Compare commits

...

38 Commits

Author SHA1 Message Date
Stas Gavrylov
22672d9d3c Fix users list position 2025-10-15 14:52:35 +02:00
Stas Gavrylov
92cc317a43 Restyle users list 2025-10-15 14:33:42 +02:00
Chris Truter
697d5983c0 Authorize document writes 2025-10-15 13:48:59 +02:00
Edward Paget
b134ae94aa Update webhook 2025-10-15 13:40:37 +02:00
Stas Gavrylov
d97f7de801 Fix Provider type. remove bad color 2025-10-15 13:27:00 +02:00
Eliot Daigneault-Mestre
58055ba57b Add avatar for active users 2025-10-15 13:26:46 +02:00
Chris Truter
24230d8cc2 Derp 2025-10-15 13:05:33 +02:00
Chris Truter
501a3248f1 Event 2025-10-15 13:01:35 +02:00
Chris Truter
96d8f5d9d0 Whitespace 2025-10-15 12:11:05 +02:00
Stas Gavrylov
ca5e077a29 Disable leave confirmation logic and hide Save button for existing doc 2025-10-15 12:10:22 +02:00
Stas Gavrylov
8f2724441b disable undoredo 2025-10-15 12:03:53 +02:00
Stas Gavrylov
ea4e0dd42c ui cleanup 2025-10-15 11:53:19 +02:00
Edward Paget
a9b16e24a4 Fetch document content using oncreate 2025-10-15 11:48:37 +02:00
Oleksandr Solovyov
721b80050f Revert "longpolling for comments"
This reverts commit 21467911a5.
2025-10-15 11:42:22 +02:00
Chris Truter
918c34f5d6 Rename event to be less misleading 2025-10-15 11:39:43 +02:00
Stas Gavrylov
2601b1544e Fix dark mode 2025-10-15 11:39:29 +02:00
Oleksandr Solovyov
21467911a5 longpolling for comments 2025-10-15 11:33:39 +02:00
Stas Gavrylov
b457b58db7 Fix caret colors 2025-10-15 11:29:13 +02:00
Chris Truter
3f4bc1ce5c Track change in shape using extension instead of transformer 2025-10-15 11:22:32 +02:00
Edward Paget
5b51c265fe Send document binary 2025-10-15 11:13:15 +02:00
Chris Truter
0f39867383 Save the raw ydoc, if we have it 2025-10-15 11:09:59 +02:00
Stas Gavrylov
a40551af43 User names 2025-10-15 10:56:19 +02:00
Chris Truter
74745b44b2 Remove debug 2025-10-15 10:51:06 +02:00
Chris Truter
a61dcf627f scaffolding 2025-10-15 10:37:55 +02:00
Chris Truter
f4fd45698c Revert "Only hook on onchange"
This reverts commit a2b7b90f94.
2025-10-15 10:27:16 +02:00
Edward Paget
a2b7b90f94 Only hook on onchange 2025-10-15 10:21:07 +02:00
Edward Paget
10ceb67315 Fix webhook errors 2025-10-15 10:18:52 +02:00
Oleksandr Solovyov
0b4466fc01 generic url for hocuspocus 2025-10-15 10:09:07 +02:00
Edward Paget
bc85be5e27 Add webhook skeleton 2025-10-15 10:01:13 +02:00
Stas Gavrylov
99233769cc Fix cors 2025-10-15 09:56:42 +02:00
Stas Gavrylov
9e86b2d6e0 Update comments polling interval 2025-10-15 09:54:35 +02:00
Stas Gavrylov
90e26a1e62 ui deps 2025-10-15 09:50:21 +02:00
Edward Paget
c364e55074 Fix port 2025-10-15 09:50:02 +02:00
Edward Paget
5dffe4e1f1 Add port and fix module format 2025-10-15 09:49:15 +02:00
Eliot Daigneault-Mestre
8f38294de6 Add basic polling for comments 2025-10-15 09:47:08 +02:00
Edward Paget
e8f1745a67 Add hocuspocus server 2025-10-15 09:45:15 +02:00
Edward Paget
b7d9569bdd werk sockets 2025-10-15 09:14:30 +02:00
Edward Paget
5b35883e5a starting on websockets 2025-10-15 09:04:30 +02:00
19 changed files with 1784 additions and 44 deletions

1
.gitignore vendored
View File

@@ -163,3 +163,4 @@ CLAUDE.local.md
/logs
/.clojure-mcp
/document_collab_server/node_modules/

View File

@@ -0,0 +1,96 @@
import { Server } from "@hocuspocus/server";
import { Webhook, Events } from "@hocuspocus/extension-webhook";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
class WebhookWithBin extends Webhook {
async onLoadDocument(data) {
if (!this.configuration.events.includes(Events.onCreate)) {
return;
}
try {
const response = await this.sendRequest("load", {
documentName: data.documentName,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(data.requestParameters.entries()),
});
if (response.status !== 200 || !response.data) return;
const document =
typeof response.data === "string"
? JSON.parse(response.data)
: response.data;
if (document.ydoc) {
Y.applyUpdate(data.document, Buffer.from(document.ydoc, "base64"));
} else if (document.document) {
for (const fieldName in document.document) {
if (data.document.isEmpty(fieldName)) {
data.document.merge(
this.configuration.transformer.toYdoc(
document.document[fieldName],
fieldName,
),
);
}
}
}
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
}
}
async onChange(data) {
if (!this.configuration.events.includes(Events.onChange)) {
return;
}
const save = async () => {
try {
await this.sendRequest(Events.onChange, {
document: this.configuration.transformer.fromYdoc(data.document),
ydoc: Buffer.from(Y.encodeStateAsUpdate(data.document)).toString(
"base64",
),
documentName: data.documentName,
context: data.context,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(
data.requestParameters.entries(),
),
});
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
}
};
if (!this.configuration.debounce) {
return save();
}
this.debounce(data.documentName, save);
}
}
const server = new Server({
port: 3005,
extensions: [
new WebhookWithBin({
url: "http://localhost:3000/api/ee/document/webhook",
secret: "459824aaffa928e05f5b1caec411ae5f",
transformer: TiptapTransformer,
events: [
Events.onConnect,
Events.onCreate,
Events.onChange,
Events.onDisconnect,
],
debounce: 2000,
debounceMaxWait: 10000,
}),
],
});
server.listen();

1203
document_collab_server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "document_collab_server",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@hocuspocus/extension-webhook": "^3.2.5",
"@hocuspocus/server": "^3.2.5",
"@hocuspocus/transformer": "^3.2.5"
}
}

View File

@@ -2,9 +2,11 @@
"`/api/ee/document/` routes"
(:require
[metabase-enterprise.documents.api.document]
[metabase-enterprise.documents.api.webhook]
[metabase.api.util.handlers :as handlers]))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/document/` routes."
(handlers/routes
metabase-enterprise.documents.api.webhook/routes
metabase-enterprise.documents.api.document/routes))

View File

@@ -1,9 +1,11 @@
(ns metabase-enterprise.documents.api.document
"`/api/ee/document/` routes"
(:require
[compojure.response :as resp]
[metabase-enterprise.documents.models.document :as m.document]
[metabase-enterprise.documents.prose-mirror :as prose-mirror]
[metabase.api.common :as api]
[metabase.api.common.internal]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.collections.models.collection :as collection]
@@ -13,8 +15,13 @@
[metabase.query-permissions.core :as query-perms]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.jvm :as jvm]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[ring.util.response :as ring.resp]
[ring.websocket :as ring.ws]
[ring.websocket :as ws]
[toucan2.core :as t2]))
(def ^:private CardCreateSchema
@@ -54,8 +61,8 @@
(query-perms/check-run-permissions-for-query query)
(card/create-card! (assoc card :type :question :dashboard_id nil) creator))
(mu/defn- update-cards-in-ast :- [:map [:document :any]
[:content_type :string]]
(mu/defn update-cards-in-ast :- [:map [:document :any]
[:content_type :string]]
[document :- [:map
[:document :any]
@@ -69,7 +76,7 @@
(fn update-card-id [embed]
(update-in embed [:attrs :id] card-id-map)))))
(mu/defn- create-cards-for-document! :- [:map-of ms/NegativeInt ms/PositiveInt]
(mu/defn create-cards-for-document! :- [:map-of ms/NegativeInt ms/PositiveInt]
"Creates cards for a document from the cards map.
Returns a mapping from the original negative integer keys to the newly created card IDs.
@@ -100,7 +107,7 @@
{}
cards-to-create)))
(mu/defn- clone-cards-in-document! :- [:map-of ms/PositiveInt ms/PositiveInt]
(mu/defn clone-cards-in-document! :- [:map-of ms/PositiveInt ms/PositiveInt]
"Finds all cards in the document that are not associated with the document and clones the cards.
Args:
@@ -239,6 +246,37 @@
:user-id api/*current-user-id*})
api/generic-204-no-content))
(defonce docid->ws
(atom {}))
(defrecord WSResponse [handler]
metabase.api.common.internal/EndpointResponse
(wrap-response-if-needed [_]
{::ring.ws/listener handler}))
(api.macros/defendpoint :get "/:document-id/collab"
[{:keys [document-id]} :- [:map [:document-id ms/PositiveInt]] _ _ req]
(log/infof "Upgradable? %s" (ring.ws/upgrade-request? req))
(if (ring.ws/upgrade-request? req)
(->WSResponse
{:on-open (fn on-open [ws]
(log/infof "Openning socket for %s" document-id)
(swap! docid->ws assoc document-id ws)
(jvm/in-virtual-thread*
(while (ring.ws/open? ws)
(ws/ping ws nil)
(Thread/sleep 1000))))
:on-close (fn on-close [_ status-code reason]
(log/infof "Closing socket for %s; status=%s; reason=%s" document-id status-code reason)
(swap! docid->ws dissoc document-id))
:on-message (fn on-text [_ws text-message]
(log/info "received msg:" text-message))
:on-pong (fn on-pong [_ _]
(log/debug "pong"))
:on-error (fn on-error [_ throwable]
(log/errorf throwable "Error on socket for %s" document-id))})
{:status 404}))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/document/` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,73 @@
(ns metabase-enterprise.documents.api.webhook
(:require
[metabase-enterprise.documents.api.document :as document.api]
[metabase-enterprise.documents.prose-mirror :as prose-mirror]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.events.core :as events]
[metabase.models.interface :as mi]
[metabase.request.core :as request]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(defn- connect-event [payload]
;; nothing to do here, FE is initializing the user context on its own
)
(defn- load-event [user-id {id :documentName}]
;; Return the ydoc and the extracted version
(let [id (parse-long id)]
(request/with-current-user user-id
(let [document (t2/select-one :model/Document id)]
(api/check-403 (mi/can-write? document))
{:document {:default (:document document)}
:ydoc (:ydoc document)}))))
(defn- change-event [user-id {id :documentName, :keys [ydoc document]}]
;; work whether we have the new transformer to pass through the base64 ydoc too, or not
(let [id (parse-long id)]
(t2/update! :model/Document id {:document document
:ydoc ydoc})
(events/publish-event! :event/document-update
{:object (t2/select-one :model/Document id)
:user-id user-id})))
(defn- disconnect-event [payload]
;; nothing to do here
)
(def ^:private yuck-regex #"metabase\.SESSION=([^ ;]*)")
(api.macros/defendpoint :post "/webhook"
[_
_
{:keys [event _context payload]}]
(let [cookies-str (get-in payload [:requestHeaders :cookie])
session-key (second (re-find yuck-regex cookies-str))
user-id (:metabase-user-id (metabase.server.middleware.session/current-user-info-for-session session-key nil))]
;; #p [event payload _context]
(case event
"connect" (connect-event payload)
"load" (load-event user-id payload)
"change" (change-event user-id payload)
"disconnect" (disconnect-event payload)
(log/warn "Unexpected event" {:event event :payload payload}))))
(api.macros/defendpoint :post "/copy-cards"
[_ _ {document-id :document_id document :document}]
(let [collection-id (t2/select-one-fn :collection_id :model/Document :id (parse-long document-id))]
(->> (document.api/clone-cards-in-document!
{:id document-id
:collection_id collection-id
:document document
:content_type prose-mirror/prose-mirror-content-type})
(document.api/update-cards-in-ast
{:document document
:content_type prose-mirror/prose-mirror-content-type}))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/document/` routes."
(api.macros/ns-handler *ns*))

View File

@@ -114,24 +114,26 @@ export const DocumentHeader = ({
)}
</Flex>
<Flex gap="md" align="center" className={S.actionsContainer}>
<Transition
mounted={showSaveButton}
transition={saveButtonTransition}
duration={200}
keepMounted
>
{(style) => (
<Box
style={
style.display === "none" ? saveButtonTransition.out : style
}
>
<Button onClick={onSave} variant="filled" data-hide-on-print>
{t`Save`}
</Button>
</Box>
)}
</Transition>
{!isNewDocument ? null : (
<Transition
mounted={showSaveButton}
transition={saveButtonTransition}
duration={200}
keepMounted
>
{(style) => (
<Box
style={
style.display === "none" ? saveButtonTransition.out : style
}
>
<Button onClick={onSave} variant="filled" data-hide-on-print>
{t`Save`}
</Button>
</Box>
)}
</Transition>
)}
{!isNewDocument && hasComments && !isWithinIframe() && (
<Tooltip label={t`Show all comments`}>
<Box>

View File

@@ -1,15 +1,23 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { useForceUpdate } from "@mantine/hooks";
import type { JSONContent, Editor as TiptapEditor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import dayjs from "dayjs";
import type { Location } from "history";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import type { Route } from "react-router";
import { push, replace } from "react-router-redux";
import { usePrevious, useUnmount } from "react-use";
import useBeforeUnload from "react-use/lib/useBeforeUnload";
import { t } from "ttag";
import _ from "underscore";
import * as Y from "yjs";
import {
skipToken,
@@ -132,8 +140,21 @@ export const DocumentPage = ({
documentData = undefined;
}
const { ydoc, provider } = useMemo(() => {
const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
url: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:3005/`,
name: String(documentId),
document: ydoc,
});
return { ydoc, provider };
}, [documentId]);
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(documentData),
{
pollingInterval: 1000,
},
);
const hasComments =
!!commentsData?.comments && commentsData.comments.length > 0;
@@ -429,10 +450,11 @@ export const DocumentPage = ({
hasComments={hasComments}
/>
<Editor
ydoc={ydoc}
provider={provider}
onEditorReady={setEditorInstance}
onCardEmbedsChange={updateCardEmbeds}
onQuestionSelect={handleQuestionSelect}
initialContent={documentContent}
onChange={handleChange}
editable={canWrite}
isLoading={isDocumentLoading}
@@ -479,16 +501,16 @@ export const DocumentPage = ({
// `key` remounts this modal when navigating between different documents or to a new document.
// The `route` doesn't change in that scenario which prevents the modal from closing when you confirm you want to discard your changes.
key={location.key}
isEnabled={hasUnsavedChanges() && !isNavigationScheduled}
// isEnabled={hasUnsavedChanges() && !isNavigationScheduled}
isEnabled={isNewDocument && !isNavigationScheduled}
route={route}
/>
<LeaveConfirmModal
// only applies when going from /new -> /new
opened={
hasUnsavedChanges() &&
isNewDocument &&
location.key !== previousLocationKey
// hasUnsavedChanges() &&
isNewDocument && location.key !== previousLocationKey
}
onConfirm={resetDocument}
onClose={() => forceUpdate()}

View File

@@ -2,6 +2,7 @@
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.editorContent {
@@ -199,3 +200,57 @@
border-radius: var(--mantine-radius-xs);
}
}
/* Give a remote user a caret */
:global(.collaboration-carets__caret) {
border-left: 1px solid var(--mb-color-text-dark);
border-right: 1px solid var(--mb-color-text-dark);
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
/* Render the username above the caret */
:global(.collaboration-carets__label) {
display: flex;
border-radius: 3px 3px 3px 0;
color: var(--mb-color-text-dark);
font-size: 14px;
font-style: normal;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
.userCountDisplay {
position: absolute;
top: 0;
left: 100%;
transform: translate(-100%, -100%);
/* background-color: var(--mb-color-bg-light);
border-bottom: 1px solid var(--mb-color-border);
color: var(--mb-color-text-medium); */
font-size: 0.875rem;
font-weight: 500;
padding: 8px 16px;
display: flex;
justify-content: right;
align-items: right;
}
.usersList {
display: flex;
align-items: center;
flex-shrink: 0;
}
.usersList > div {
--avatar-color: var(--mb-color-text-dark) !important;
}

View File

@@ -1,3 +1,5 @@
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCaret from "@tiptap/extension-collaboration-caret";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import { Placeholder } from "@tiptap/extension-placeholder";
@@ -5,15 +7,17 @@ import type { JSONContent, Editor as TiptapEditor } from "@tiptap/react";
import { EditorContent, useEditor } from "@tiptap/react";
import cx from "classnames";
import type React from "react";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useLatest, usePrevious } from "react-use";
import { t } from "ttag";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { getCurrentUser } from "metabase/admin/datamodel/selectors";
import { DND_IGNORE_CLASS_NAME } from "metabase/common/components/dnd";
import CS from "metabase/css/core/index.css";
import { useSelector, useStore } from "metabase/lib/redux";
import { getSetting } from "metabase/selectors/settings";
import { Box, Loader } from "metabase/ui";
import { Avatar, Box, Flex, Loader, Tooltip } from "metabase/ui";
import { getMentionsCache } from "metabase-enterprise/documents/selectors";
import type { DocumentsStoreState } from "metabase-enterprise/documents/types";
import { isMetabotBlock } from "metabase-enterprise/documents/utils/editorNodeUtils";
@@ -42,6 +46,7 @@ import { SmartLink } from "metabase-enterprise/rich_text_editing/tiptap/extensio
import { createSuggestionRenderer } from "metabase-enterprise/rich_text_editing/tiptap/extensions/suggestionRenderer";
import S from "./Editor.module.css";
import { colors } from "./collab";
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
import type { CardEmbedRef } from "./types";
@@ -54,6 +59,8 @@ const BUBBLE_MENU_DISALLOWED_NODES: string[] = [
"codeBlock",
];
const MAX_ACTIVE_USERS = 5;
const getMetabotPromptSerializer =
(getState: () => DocumentsStoreState): PromptSerializer =>
(node) => {
@@ -87,6 +94,8 @@ export interface EditorProps {
onQuestionSelect?: (cardId: number | null) => void;
editable?: boolean;
isLoading?: boolean;
ydoc?: Y.Doc;
provider?: HocuspocusProvider;
}
export const Editor: React.FC<EditorProps> = ({
@@ -97,9 +106,16 @@ export const Editor: React.FC<EditorProps> = ({
editable = true,
onQuestionSelect,
isLoading = false,
ydoc,
provider,
}) => {
const siteUrl = useSelector((state) => getSetting(state, "site-url"));
const { getState } = useStore();
const currentUser = useSelector(getCurrentUser);
const [userCount, setUserCount] = useState(0);
const [activeUsers, setActiveUsers] = useState<
Array<{ name: string; color: string }>
>([]);
const extensions = useMemo(
() => [
@@ -108,6 +124,7 @@ export const Editor: React.FC<EditorProps> = ({
color: DROP_ZONE_COLOR,
width: 2,
},
undoRedo: false,
}),
Image.configure({
inline: false,
@@ -155,12 +172,37 @@ export const Editor: React.FC<EditorProps> = ({
}),
ResizeNode,
HandleEditorDrop,
Collaboration.extend().configure({
document: ydoc,
}),
CollaborationCaret.extend().configure({
provider,
render: (user: any) => {
const cursor = document.createElement("span");
cursor.classList.add("collaboration-carets__caret");
cursor.setAttribute("style", `border-color: ${user.color}`);
const label = document.createElement("div");
label.classList.add("collaboration-carets__label");
label.setAttribute("style", `background-color: ${user.color};`);
label.insertBefore(document.createTextNode(user.name), null);
cursor.insertBefore(label, null);
return cursor;
},
user: {
name: currentUser?.common_name,
color: colors[Math.floor(Math.random() * colors.length)],
},
}),
],
[siteUrl, getState],
[siteUrl, getState, ydoc, provider, currentUser?.common_name],
);
const editor = useEditor(
{
enableContentCheck: true,
extensions,
content: initialContent || "",
autofocus: false,
@@ -172,6 +214,16 @@ export const Editor: React.FC<EditorProps> = ({
onChange(currentContent);
}
},
onContentError: ({ disableCollaboration }) => {
disableCollaboration();
},
onCreate: ({ editor: currentEditor }) => {
provider.on("synced", () => {
if (currentEditor.isEmpty) {
currentEditor.commands.setContent(initialContent || "");
}
});
},
},
[],
);
@@ -192,6 +244,35 @@ export const Editor: React.FC<EditorProps> = ({
}
}, [editor, initialContent, previousContentRef]);
// Track collaboration users every 100ms
useEffect(() => {
if (!editor) {
return;
}
const updateUsers = () => {
const users = editor.storage?.collaborationCaret?.users;
if (users) {
setUserCount(users.length);
// Extract user data with names and colors
const userData = Object.values(users).map((user: any) => ({
name: user.name || "Unknown",
color: user.color || "",
}));
setActiveUsers(userData);
}
};
// Initial update
updateUsers();
// Set up interval to check every 100ms
const interval = setInterval(updateUsers, 100);
return () => {
clearInterval(interval);
};
}, [editor]);
// Notify parent when editor is ready
useEffect(() => {
if (editor && onEditorReady) {
@@ -223,10 +304,36 @@ export const Editor: React.FC<EditorProps> = ({
return (
<Box className={cx(S.editor, DND_IGNORE_CLASS_NAME)}>
{/* Active users display */}
{activeUsers.length > 0 && (
<Box className={S.userCountDisplay}>
<Flex align="center" gap="xs">
<span>{userCount}</span>
<span>{t`active users:`}</span>
<Flex gap="xs" className={S.usersList}>
{activeUsers.slice(0, MAX_ACTIVE_USERS).map((user, index) => (
<Tooltip key={index} label={user.name} withArrow position="top">
<Avatar
name={user.name}
color={user.color}
size="md"
// radius="xl"
/>
</Tooltip>
))}
{activeUsers.length > MAX_ACTIVE_USERS && (
<span>{t`+ ${activeUsers.length - MAX_ACTIVE_USERS} more`}</span>
)}
</Flex>
</Flex>
</Box>
)}
<Box
className={S.editorContent}
onClick={(e) => {
// Focus editor when clicking on empty space
const target = e.target as HTMLElement;
if (
target.classList.contains(S.editorContent) ||

View File

@@ -0,0 +1,24 @@
export const colors = [
"#958DF1",
"#F98181",
"#FBBC88",
"#FAF594",
"#70CFF8",
"#94FADB",
"#B9F18D",
"#C3E2C2",
"#EAECCC",
"#AFC8AD",
"#EEC759",
"#9BB8CD",
"#FF90BC",
"#FFC0D9",
"#DC8686",
"#7ED7C1",
"#F3EEEA",
"#89B9AD",
"#D0BFFF",
"#FFF8C9",
"#CBFFA9",
"#9BABB8",
];

View File

@@ -18,7 +18,8 @@ export const avatarOverrides = {
Avatar: Avatar.extend({
defaultProps: {
allowedInitialsColors: avatarColors,
color: "initials",
// color: "initials",
color: "",
size: rem(24),
variant: "filled",
},

View File

@@ -24,6 +24,7 @@
"@emotion/is-prop-valid": "^1.1.1",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@hocuspocus/provider": "^3.2.5",
"@juggle/resize-observer": "^3.4.0",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
@@ -38,6 +39,8 @@
"@tippyjs/react": "^4.2.6",
"@tiptap/core": "^3.2.0",
"@tiptap/extension-bubble-menu": "^3.2.0",
"@tiptap/extension-collaboration": "^3.7.0",
"@tiptap/extension-collaboration-caret": "^3.7.0",
"@tiptap/extension-image": "^3.2.0",
"@tiptap/extension-mention": "^3.3.0",
"@tiptap/extension-placeholder": "^3.2.0",
@@ -45,6 +48,7 @@
"@tiptap/react": "^3.2.0",
"@tiptap/starter-kit": "^3.2.0",
"@tiptap/suggestion": "^3.2.0",
"@tiptap/y-tiptap": "^3.0.0",
"@types/react-router": "3.0.27",
"@types/redux-auth-wrapper": "^2.0.16",
"@uiw/react-codemirror": "^4.23.6",
@@ -149,6 +153,8 @@
"ttag": "1.7.21",
"typescript": "^5.9.2",
"underscore": "~1.13.3",
"y-protocols": "^1.0.6",
"yjs": "^13.6.27",
"yup": "^0.32.11"
},
"devDependencies": {

View File

@@ -1966,6 +1966,26 @@ databaseChangeLog:
SET dataset_query = legacy_query
WHERE legacy_query IS NOT NULL;
- changeSet:
id: v57.2025-10-15T09:04:00
author: christruter
comment: Add ydoc column to document table for Y.js document state
preConditions:
- onFail: MARK_RAN
- not:
- columnExists:
tableName: document
columnName: ydoc
changes:
- addColumn:
tableName: document
columns:
- column:
name: ydoc
type: ${text.type}
remarks: Y.js document state for collaborative editing, in base64
constraints:
nullable: true
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<

View File

@@ -11,12 +11,15 @@
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[ring.adapter.jetty :as ring-jetty]
[ring.util.jakarta.servlet :as servlet])
[ring.util.jakarta.servlet :as servlet]
[ring.websocket :as ring.ws])
(:import
(jakarta.servlet AsyncContext)
(jakarta.servlet.http HttpServletRequest HttpServletResponse)
(org.eclipse.jetty.ee9.nested Request)
(org.eclipse.jetty.ee9.servlet ServletContextHandler ServletHandler)
(org.eclipse.jetty.ee9.websocket.server.config
JettyWebSocketServletContainerInitializer)
(org.eclipse.jetty.server Server)))
(set! *warn-on-reflection* true)
@@ -82,11 +85,14 @@
(handler
request-map
(fn [response-map]
(server.protocols/respond (:body response-map) {:request request
:request-map request-map
:async-context context
:response response
:response-map response-map}))
(if (ring.ws/websocket-response? response-map)
(do (#'ring-jetty/upgrade-to-websocket request response response-map {})
(.complete context))
(server.protocols/respond (:body response-map) {:request request
:request-map request-map
:async-context context
:response response
:response-map response-map})))
raise)
(catch Throwable e
(log/error e "Unexpected Exception in API request handler")
@@ -106,6 +112,7 @@
handler (async-proxy-handler handler timeout)
servlet-handler (doto (ServletContextHandler.)
(.setAllowNullPathInfo true)
(JettyWebSocketServletContainerInitializer/configure nil)
(.insertHandler (statistics-handler/new-handler))
(.setServletHandler handler))]
(doto ^Server (#'ring-jetty/create-server (assoc options :async? true))

View File

@@ -149,10 +149,10 @@
(map (partial format "'sha256-%s'") inline-js-hashes)))
:child-src ["'self'"
"https://accounts.google.com"]
:style-src ["'self'"
:style-src ["'self' 'unsafe-inline'"
;; See [[generate-nonce]]
(when nonce
(format "'nonce-%s'" nonce))
;; (when nonce
;; (format "'nonce-%s'" nonce))
;; for webpack hot reloading
(when config/is-dev?
frontend-address)
@@ -177,7 +177,8 @@
(str "*:" frontend-dev-port " ws://*:" frontend-dev-port))
;; CLJS REPL
(when config/is-dev?
"ws://*:9630")]
"ws://*:9630")
"ws://*:3005"]
:manifest-src ["'self'"]
:media-src ["www.metabase.com"]}]
(format "%s %s; " (name k) (str/join " " vs))))})

View File

@@ -167,7 +167,7 @@
[session-key]
(or (not session-key) (string/valid-uuid? session-key)))
(mu/defn- current-user-info-for-session :- [:maybe ::request.schema/current-user-info]
(mu/defn current-user-info-for-session :- [:maybe ::request.schema/current-user-info]
"Return User ID and superuser status for Session with `session-key` if it is valid and not expired."
[session-key anti-csrf-token]
(when (and session-key (valid-session-key? session-key) (init-status/complete?))

View File

@@ -1940,6 +1940,23 @@
"@shikijs/types" "^3.13.0"
"@shikijs/vscode-textmate" "^10.0.2"
"@hocuspocus/common@^3.2.5":
version "3.2.5"
resolved "https://registry.yarnpkg.com/@hocuspocus/common/-/common-3.2.5.tgz#e942c9f93bd26c1460bcf357c6a2fe710ac9adbe"
integrity sha512-unodX1+dBUp2sink7nKg/PSNDDUjM+AyMxg3WGpRTZb1LI0z/22DjjwFCgWWvoCNQALPVp9PFB/wyAI0xNo6GA==
dependencies:
lib0 "^0.2.87"
"@hocuspocus/provider@^3.2.5":
version "3.2.5"
resolved "https://registry.yarnpkg.com/@hocuspocus/provider/-/provider-3.2.5.tgz#4ade3b4627c644a9718f6ff606f04d9b9285a3d4"
integrity sha512-UZpmPYSp8T2ms1E049GayQcaUIVBAZCWiNIiuxfO/+lydTto8svqEgJVmIUqxmSJ3lO64zo9wsyQz9wXu2hlnQ==
dependencies:
"@hocuspocus/common" "^3.2.5"
"@lifeomic/attempt" "^3.0.2"
lib0 "^0.2.87"
ws "^8.17.1"
"@humanwhocodes/config-array@^0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"
@@ -2592,6 +2609,11 @@
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.0.0"
"@lifeomic/attempt@^3.0.2":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@lifeomic/attempt/-/attempt-3.1.0.tgz#7fc703559177b81a008b9d263e3d9a001d11d08a"
integrity sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==
"@loki/browser@^0.35.0":
version "0.35.0"
resolved "https://registry.yarnpkg.com/@loki/browser/-/browser-0.35.0.tgz#81e233f76276ab70ae337c08ceeba25156c05280"
@@ -4064,6 +4086,16 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.2.0.tgz#61927a22065d9ec20f590daab8bd9ac23baff638"
integrity sha512-8eUeWzT0YNxvT9yh6p9mRziUmZjiRqHeQhHbcRYDM7gdFKb0mgsWPxiaonns0sdj7WW1vP6g0M80PC+HYCIO+g==
"@tiptap/extension-collaboration-caret@^3.7.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration-caret/-/extension-collaboration-caret-3.7.0.tgz#bb31ce62958013b2c1e13a650eade1dc1b3b9a42"
integrity sha512-zJs4JFPLZbmoPm/8p2lmn7B8QCfQA4UPsuxfLfGvJU0E8nRsBm7KGlFAK2WDJGy/iQBeq6agJ4yBBMnBM6RmKg==
"@tiptap/extension-collaboration@^3.7.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration/-/extension-collaboration-3.7.0.tgz#bea078b19a2f7cdd19e6651244443383f89d081a"
integrity sha512-CoiRx4NGJ/RKm/QC7ox9EBSrirqA+Cxnl0WkmxRg4dyERJnNK2ToCXmnKg9bPloq/Lr7HdzlzyfaOxLbulRU5w==
"@tiptap/extension-document@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.2.0.tgz#94452997aec6d569bc8b37872bce7e9fbc87e5fc"
@@ -4242,6 +4274,13 @@
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-3.2.0.tgz#414dbd028539054a0ebf9d910b30018a50c783c3"
integrity sha512-AEkZPsn9Fn3p/3e6LIz8dyF3npU0JjVa2rCGqj/MmmvX9MD2fRYhiTCT1y7q9Q9/4wB4+CiOT7Mt4sWiijEkxw==
"@tiptap/y-tiptap@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@tiptap/y-tiptap/-/y-tiptap-3.0.0.tgz#e2cf46b9346dd4666f0ecceab45bb4bccf85f1ce"
integrity sha512-HIeJZCj+KYJde2x6fONzo4o6kd7gW7eonwhQsv2p2VQnUgwNXMVhN+D6Z3AH/2i541Sq33y1PO4U/1ThCPjqbA==
dependencies:
lib0 "^0.2.100"
"@tokenizer/token@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276"
@@ -13188,6 +13227,11 @@ isobject@^4.0.0:
resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0"
integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==
isomorphic.js@^0.2.4:
version "0.2.5"
resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz#13eecf36f2dba53e85d355e11bf9d4208c6f7f88"
integrity sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -14136,6 +14180,13 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lib0@^0.2.100, lib0@^0.2.85, lib0@^0.2.87, lib0@^0.2.99:
version "0.2.114"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.114.tgz#0b0e55c3ffa8768fe3d9efca971059f465db4baf"
integrity sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==
dependencies:
isomorphic.js "^0.2.4"
lighthouse-logger@^1.0.0:
version "1.4.2"
resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz#aef90f9e97cd81db367c7634292ee22079280aaa"
@@ -22124,7 +22175,7 @@ ws@^7, ws@^7.2.0, ws@^7.3.1, ws@^7.4.6, ws@^7.5.5:
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.13.0:
ws@^8.13.0, ws@^8.17.1:
version "8.18.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
@@ -22193,6 +22244,13 @@ xxhashjs@~0.2.2:
dependencies:
cuint "^0.2.2"
y-protocols@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.6.tgz#66dad8a95752623443e8e28c0e923682d2c0d495"
integrity sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==
dependencies:
lib0 "^0.2.85"
y18n@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4"
@@ -22368,6 +22426,13 @@ yauzl@^2.10.0:
buffer-crc32 "~0.2.3"
fd-slicer "~1.1.0"
yjs@^13.6.27:
version "13.6.27"
resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.27.tgz#8899be929d57da05a0aa112d044a5c204393ab7b"
integrity sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==
dependencies:
lib0 "^0.2.99"
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"