Skip to content

Commit 18df06e

Browse files
committed
feat(versioning): resolve version authors reactively via the user store
Version snapshots now carry raw author user-ids on a new `by` field instead of a pre-resolved `secondaryLabel` string, and resolving those ids to usernames becomes a view concern: - `VersioningExtension` accepts `resolveUsers` and exposes the normalized `userStore` on its instance (mirroring CommentsExtension); `CollaborationExtension` passes its shared store in. - The YHub endpoints just split the activity `by` field into ids — no more reaching into the attribution extension's user store. `create` stamps the optimistic snapshot with the cursor user's id. - New `useVersionUsers`/`useSnapshotLabel` React hooks (mirroring `useCommentUsers`) resolve `by` ids reactively, so author labels update as user info loads into the store — no re-list needed. - `UserStore` gains `setUser` and resolvers now receive the store, so a resolver can return partial info synchronously and fill in the rest later.
1 parent 734249c commit 18df06e

11 files changed

Lines changed: 313 additions & 93 deletions

File tree

examples/07-collaboration/13-versioning-yjs14/src/userdata.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { User } from "@blocknote/core";
1+
import type { User, UserStore } from "@blocknote/core";
22

33
// Integer-like ids make it obvious if username resolution ever breaks: the
44
// version sidebar / diff tooltips would show a bare number (e.g. "1") instead
@@ -18,6 +18,15 @@ export const USERS: User[] = [
1818
* diff tooltips) by name instead of id. Mirrors the `resolveUsers` you'd
1919
* normally back with your own user database.
2020
*/
21-
export async function resolveUsers(userIds: string[]): Promise<User[]> {
22-
return USERS.filter((u) => userIds.includes(u.id));
21+
export async function resolveUsers(
22+
userIds: string[],
23+
store: UserStore<any>,
24+
): Promise<User[]> {
25+
setTimeout(
26+
() => {
27+
store.setUser(USERS.filter((u) => userIds.includes(u.id)));
28+
},
29+
Math.random() * 200 + 300,
30+
);
31+
return [USERS[0]];
2332
}

packages/core/src/extensions/Versioning/Versioning.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "vite-plus/test";
1212

1313
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
14+
import type { UserStoreOrResolver } from "../../user/index.js";
1415
import { sortSnapshotsNewestFirst, VersioningExtension } from "./Versioning.js";
1516
import type { VersionSnapshot } from "./Versioning.js";
1617
import {
@@ -56,6 +57,7 @@ function setup(opts?: {
5657
initialText?: string;
5758
withoutRestore?: boolean;
5859
withoutUpdateName?: boolean;
60+
resolveUsers?: UserStoreOrResolver;
5961
}) {
6062
const editor = createEditor();
6163
setEditorText(editor, opts?.initialText ?? "initial doc");
@@ -74,6 +76,7 @@ function setup(opts?: {
7476
endpoints,
7577
preview,
7678
getCurrentDocument: () => editor.document,
79+
resolveUsers: opts?.resolveUsers,
7780
})({ editor });
7881

7982
/** Seed a snapshot into the backend by capturing the current editor doc. */
@@ -347,6 +350,57 @@ describe("VersioningExtension", () => {
347350
});
348351
});
349352

353+
// -------------------------------------------------------------------------
354+
// User store (author resolution for `VersionSnapshot.by`)
355+
// -------------------------------------------------------------------------
356+
357+
describe("user store", () => {
358+
it("exposes an empty user store when no resolveUsers is provided", async () => {
359+
expect(ctx.ext.userStore).toBeDefined();
360+
await ctx.ext.userStore.loadUsers(["u1"]);
361+
expect(ctx.ext.userStore.getUser("u1")).toBeUndefined();
362+
});
363+
364+
it("builds a de-duped user store from a resolveUsers callback", async () => {
365+
const resolveUsers = vi.fn(async (ids: string[]) =>
366+
ids.map((id) => ({ id, username: `name-${id}`, avatarUrl: "" })),
367+
);
368+
const withUsers = setup({ resolveUsers });
369+
370+
await withUsers.ext.userStore.loadUsers(["u1", "u2"]);
371+
expect(withUsers.ext.userStore.getUser("u1")?.username).toBe("name-u1");
372+
expect(withUsers.ext.userStore.getUser("u2")?.username).toBe("name-u2");
373+
374+
// Already-cached ids are not re-fetched.
375+
await withUsers.ext.userStore.loadUsers(["u1"]);
376+
expect(resolveUsers).toHaveBeenCalledTimes(1);
377+
378+
withUsers.editor.unmount();
379+
});
380+
381+
it("passes `by` author ids through list() untouched", async () => {
382+
const editor = createEditor();
383+
const ext = VersioningExtension({
384+
endpoints: {
385+
list: async () => [snap("1", 100, { by: ["u1", "u2"] })],
386+
getContent: async () => [],
387+
},
388+
preview: createInMemoryPreviewController(editor),
389+
getCurrentDocument: () => editor.document,
390+
})({ editor });
391+
392+
const result = await ext.list();
393+
394+
// Raw ids are preserved — resolving them to user info is the view
395+
// layer's job (via `ext.userStore`), never the extension's.
396+
expect(result[0]!.by).toEqual(["u1", "u2"]);
397+
expect(result[0]!.secondaryLabel).toBeUndefined();
398+
expect(ext.store.state.snapshots).toEqual(result);
399+
400+
editor.unmount();
401+
});
402+
});
403+
350404
// -------------------------------------------------------------------------
351405
// End-to-end workflow
352406
// -------------------------------------------------------------------------

packages/core/src/extensions/Versioning/Versioning.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import {
44
createStore,
55
type ExtensionOptions,
66
} from "../../editor/BlockNoteExtension.js";
7+
import {
8+
normalizeToUserStore,
9+
type User,
10+
type UserStoreOrResolver,
11+
} from "../../user/index.js";
712

813
/**
914
* Represents a single snapshot of a document's history, including metadata and content information.
@@ -34,11 +39,24 @@ export interface VersionSnapshot {
3439
updatedAt: number;
3540

3641
/**
37-
* An optional secondary label for the snapshot, which can display additional information such as the author or a custom description.
42+
* An optional secondary label for the snapshot, which can display additional information such as a custom description.
3843
* This is for display purposes only and is not used for any logic in the versioning system.
44+
*
45+
* For author attribution, prefer {@link by}: it holds raw user ids that the
46+
* view layer resolves to user info (and keeps up to date as users load).
47+
* When both are set, `secondaryLabel` wins.
3948
*/
4049
secondaryLabel?: string;
4150

51+
/**
52+
* The id(s) of the user(s) that authored this version, as raw user ids —
53+
* never pre-resolved to display names. The view layer resolves them via the
54+
* {@link VersioningExtension}'s user store (see
55+
* {@link VersioningExtensionOptions.resolveUsers}), reactively updating as
56+
* user info loads. Only used when {@link secondaryLabel} is unset.
57+
*/
58+
by?: User["id"] | User["id"][];
59+
4260
/**
4361
* The ID of the previous snapshot that this snapshot was restored from.
4462
*/
@@ -274,6 +292,19 @@ export type VersioningExtensionOptions<
274292
* extension's `canPreviewCurrent` flag.
275293
*/
276294
serializeCurrentContent?: () => Output | Promise<Output>;
295+
/**
296+
* Resolve user information for the author ids in {@link VersionSnapshot.by},
297+
* used by the view layer to render version-author labels.
298+
*
299+
* Either a resolver function (called with the ids of users that are not yet
300+
* cached, returning their information — a user store is built from it
301+
* internally) or a pre-built user store (see `createUserStore`). Pass the
302+
* same store you give the comments/collaboration extensions so a single
303+
* de-duped user cache is shared across features.
304+
*
305+
* @note omit and author ids are displayed as-is.
306+
*/
307+
resolveUsers?: UserStoreOrResolver;
277308
};
278309

279310
function snapshotNotFoundError(
@@ -296,12 +327,16 @@ export const VersioningExtension = createExtension(
296327
preview,
297328
getCurrentDocument,
298329
serializeCurrentContent,
330+
resolveUsers,
299331
} = typeof optionsOrFactory === "function"
300332
? optionsOrFactory(editor)
301333
: optionsOrFactory;
302334

303335
const endpoints =
304336
typeof endpointsRaw === "function" ? endpointsRaw(editor) : endpointsRaw;
337+
// With no resolver this is an empty store: `getUser` always misses, so the
338+
// view layer falls back to showing the raw ids from `VersionSnapshot.by`.
339+
const userStore = normalizeToUserStore(resolveUsers);
305340
const store = createStore<{
306341
snapshots: VersionSnapshot[];
307342
/**
@@ -463,6 +498,7 @@ export const VersioningExtension = createExtension(
463498
return {
464499
key: "versioning",
465500
store,
501+
userStore,
466502
list: async (): Promise<VersionSnapshot[]> => {
467503
return await updateSnapshots();
468504
},

packages/core/src/user/UserStore.ts

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,35 @@ export type UserStore<U extends User = User> = {
5858
* The user has to be loaded via `loadUsers` first.
5959
*/
6060
getUser: (userId: User["id"]) => U | undefined;
61+
/**
62+
* Manually set information about a user. This is useful if you have a
63+
* resolver that returns partial information (e.g. just the username) and you
64+
* want to fill in the rest later (e.g. avatarUrl).
65+
*/
66+
setUser: (user: U | U[]) => void;
6167
};
6268

69+
export type UserStoreResolver<U extends User = User> = (
70+
/**
71+
* The user ids to resolve. The resolver should return information for all of
72+
* these users, or an empty array if none could be resolved.
73+
*/
74+
userIds: User["id"][],
75+
/**
76+
* The {@link UserStore} that is calling this resolver. This allows you to return a user synchronously, and update the store later if you need to fetch additional information asynchronously.
77+
*/
78+
store: UserStore<any>,
79+
) => Promise<U[]>;
80+
81+
/**
82+
* A resolver callback or an already-built {@link UserStore} — the shape that
83+
* user-facing options (comments, collaboration) accept so callers can either let
84+
* the extension build a store or pass a shared one.
85+
*/
86+
export type UserStoreOrResolver<U extends User = User> =
87+
| ((userIds: User["id"][], store: UserStore<any>) => Promise<U[]>)
88+
| UserStore<any>;
89+
6390
/**
6491
* Creates a {@link UserStore} that retrieves and caches information about users.
6592
*
@@ -76,7 +103,7 @@ export type UserStore<U extends User = User> = {
76103
* See [Comments](https://www.blocknotejs.org/docs/features/collaboration/comments) for more info.
77104
*/
78105
export function createUserStore<U extends User = User>(
79-
resolveUsers: (userIds: User["id"][]) => Promise<U[]>,
106+
resolveUsers: (userIds: User["id"][], store: UserStore<any>) => Promise<U[]>,
80107
): UserStore<U> {
81108
if (!resolveUsers) {
82109
throw new Error("resolveUsers is required to create a user store");
@@ -89,6 +116,33 @@ export function createUserStore<U extends User = User>(
89116
// not state that consumers need to subscribe to.
90117
const loadingUsers = new Set<User["id"]>();
91118

119+
const userStore: UserStore<U> = {
120+
store,
121+
async loadUsers(userIds) {
122+
const missingUsers = userIds.filter(
123+
(id) => !store.state.has(id) && !loadingUsers.has(id),
124+
);
125+
await fetchUsers(missingUsers);
126+
},
127+
async refetchUsers(userIds) {
128+
const usersToFetch = userIds.filter((id) => !loadingUsers.has(id));
129+
await fetchUsers(usersToFetch);
130+
},
131+
getUser(userId) {
132+
return store.state.get(userId);
133+
},
134+
setUser(users) {
135+
const usersArray = Array.isArray(users) ? users : [users];
136+
store.setState((prevState) => {
137+
const nextState = new Map(prevState);
138+
for (const user of usersArray) {
139+
nextState.set(user.id, user);
140+
}
141+
return nextState;
142+
});
143+
},
144+
};
145+
92146
async function fetchUsers(userIds: User["id"][]) {
93147
if (userIds.length === 0) {
94148
return;
@@ -99,7 +153,7 @@ export function createUserStore<U extends User = User>(
99153
}
100154

101155
try {
102-
const users = await resolveUsers(userIds);
156+
const users = await resolveUsers(userIds, userStore);
103157
// Only update the store if any users were actually resolved. Emitting
104158
// an update when nothing changed (e.g. when the resolver can't find a
105159
// user) would needlessly notify subscribers and, combined with a
@@ -124,33 +178,9 @@ export function createUserStore<U extends User = User>(
124178
}
125179
}
126180

127-
return {
128-
store,
129-
async loadUsers(userIds: User["id"][]) {
130-
const missingUsers = userIds.filter(
131-
(id) => !store.state.has(id) && !loadingUsers.has(id),
132-
);
133-
await fetchUsers(missingUsers);
134-
},
135-
async refetchUsers(userIds: User["id"][]) {
136-
const usersToFetch = userIds.filter((id) => !loadingUsers.has(id));
137-
await fetchUsers(usersToFetch);
138-
},
139-
getUser(userId: User["id"]): U | undefined {
140-
return store.state.get(userId);
141-
},
142-
};
181+
return userStore;
143182
}
144183

145-
/**
146-
* A resolver callback or an already-built {@link UserStore} — the shape that
147-
* user-facing options (comments, collaboration) accept so callers can either let
148-
* the extension build a store or pass a shared one.
149-
*/
150-
export type UserStoreOrResolver<U extends User = User> =
151-
| ((userIds: User["id"][]) => Promise<U[]>)
152-
| UserStore<any>;
153-
154184
/**
155185
* Normalize a {@link UserStoreOrResolver} to a {@link UserStore}:
156186
* - an existing store is returned as-is (so a single de-duped cache can be

packages/core/src/y/extensions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export const CollaborationExtension = createExtension(
104104
? VersioningExtension({
105105
...createYjsVersioningAdapter(editor, options.fragment),
106106
endpoints: options.versioningEndpoints,
107+
resolveUsers: userStore,
107108
})
108109
: null,
109110
AttributionExtension({

0 commit comments

Comments
 (0)