@liveblocks/react
Version:
A set of React hooks and providers to use Liveblocks declaratively. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.
1,592 lines (1,576 loc) • 163 kB
JavaScript
// src/contexts.ts
import { raise } from "@liveblocks/core";
import { createContext, useContext } from "react";
var ClientContext = createContext(null);
function useClientOrNull() {
return useContext(ClientContext);
}
function useClient() {
return useClientOrNull() ?? raise("LiveblocksProvider is missing from the React tree.");
}
var RoomContext = createContext(null);
function useRoomOrNull() {
return useContext(RoomContext);
}
function useIsInsideRoom() {
const room = useRoomOrNull();
return room !== null;
}
// src/lib/use-latest.ts
import { useEffect, useRef } from "react";
function useLatest(value) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
// src/ai.tsx
import { kInternal, nanoid } from "@liveblocks/core";
import { memo, useEffect as useEffect2, useId, useState } from "react";
function useAi() {
return useClient()[kInternal].ai;
}
function useRandom() {
return useState(nanoid)[0];
}
var RegisterAiKnowledge = memo(function RegisterAiKnowledge2(props) {
const layerId = useId();
const ai = useAi();
const { description, value, chatId } = props;
const [layerKey, setLayerKey] = useState();
useEffect2(() => {
const { layerKey: layerKey2, deregister } = ai.registerKnowledgeLayer(layerId, chatId);
setLayerKey(layerKey2);
return () => {
deregister();
setLayerKey(void 0);
};
}, [ai, layerId, chatId]);
const randomKey = useRandom();
const knowledgeKey = props.id ?? randomKey;
useEffect2(() => {
if (layerKey !== void 0) {
ai.updateKnowledge(
layerKey,
{ description, value },
knowledgeKey,
chatId
);
}
}, [ai, layerKey, knowledgeKey, description, value, chatId]);
return null;
});
var RegisterAiTool = memo(function RegisterAiTool2({
chatId,
name,
tool,
enabled
}) {
const client = useClient();
const ai = client[kInternal].ai;
useEffect2(() => {
const toolWithEnabled = enabled !== void 0 ? { ...tool, enabled } : tool;
return ai.registerTool(name, toolWithEnabled, chatId);
}, [ai, chatId, name, tool, enabled]);
return null;
});
// src/use-sync-external-store-with-selector.ts
import {
useDebugValue,
useEffect as useEffect3,
useMemo,
useRef as useRef2,
useSyncExternalStore
} from "react";
function is(x, y) {
return x === y && (x !== 0 || 1 / x === 1 / y) || x !== x && y !== y;
}
function useSyncExternalStoreWithSelector(subscribe, getSnapshot, getServerSnapshot, selector, isEqual) {
const instRef = useRef2(null);
let inst;
if (instRef.current === null) {
inst = {
hasValue: false,
value: null
};
instRef.current = inst;
} else {
inst = instRef.current;
}
const [getSelection, getServerSelection] = useMemo(() => {
let hasMemo = false;
let memoizedSnapshot;
let memoizedSelection;
const memoizedSelector = (nextSnapshot) => {
if (!hasMemo) {
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection2 = selector(nextSnapshot);
if (isEqual !== void 0) {
if (inst.hasValue) {
const currentSelection = inst.value;
if (isEqual(currentSelection, nextSelection2)) {
memoizedSelection = currentSelection;
return currentSelection;
}
}
}
memoizedSelection = nextSelection2;
return nextSelection2;
}
const prevSnapshot = memoizedSnapshot;
const prevSelection = memoizedSelection;
if (is(prevSnapshot, nextSnapshot)) {
return prevSelection;
}
const nextSelection = selector(nextSnapshot);
if (isEqual !== void 0 && isEqual(prevSelection, nextSelection)) {
memoizedSnapshot = nextSnapshot;
return prevSelection;
}
memoizedSnapshot = nextSnapshot;
memoizedSelection = nextSelection;
return nextSelection;
};
const maybeGetServerSnapshot = getServerSnapshot === void 0 ? null : getServerSnapshot;
const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
const getServerSnapshotWithSelector = maybeGetServerSnapshot === null ? void 0 : () => memoizedSelector(maybeGetServerSnapshot());
return [getSnapshotWithSelector, getServerSnapshotWithSelector];
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
const value = useSyncExternalStore(
subscribe,
getSelection,
getServerSelection
);
useEffect3(() => {
inst.hasValue = true;
inst.value = value;
}, [value]);
useDebugValue(value);
return value;
}
// src/use-signal.ts
import { MutableSignal } from "@liveblocks/core";
var identity = (value) => value;
function useSignal(signal, selector, isEqual) {
if (signal instanceof MutableSignal) {
throw new Error(
"Using a mutable Signal with useSignal will likely not work as expected."
);
}
return useSyncExternalStoreWithSelector(
signal.subscribe,
signal.get,
signal.get,
selector ?? identity,
isEqual
);
}
// src/liveblocks.tsx
import {
assert,
console as console2,
createClient,
DefaultMap as DefaultMap2,
HttpError,
kInternal as kInternal3,
makePoller,
raise as raise2,
shallow as shallow3
} from "@liveblocks/core";
import {
useCallback as useCallback2,
useEffect as useEffect4,
useMemo as useMemo3,
useState as useState2,
useSyncExternalStore as useSyncExternalStore2
} from "react";
// src/config.ts
var SECONDS = 1e3;
var MINUTES = 60 * SECONDS;
var config = {
SMOOTH_DELAY: 1 * SECONDS,
NOTIFICATIONS_POLL_INTERVAL: 1 * MINUTES,
NOTIFICATIONS_MAX_STALE_TIME: 5 * SECONDS,
ROOM_THREADS_POLL_INTERVAL: 5 * MINUTES,
ROOM_THREADS_MAX_STALE_TIME: 5 * SECONDS,
USER_THREADS_POLL_INTERVAL: 1 * MINUTES,
USER_THREADS_MAX_STALE_TIME: 30 * SECONDS,
HISTORY_VERSIONS_POLL_INTERVAL: 1 * MINUTES,
HISTORY_VERSIONS_MAX_STALE_TIME: 5 * SECONDS,
ROOM_SUBSCRIPTION_SETTINGS_POLL_INTERVAL: 1 * MINUTES,
ROOM_SUBSCRIPTION_SETTINGS_MAX_STALE_TIME: 5 * SECONDS,
USER_NOTIFICATION_SETTINGS_INTERVAL: 5 * MINUTES,
USER_NOTIFICATION_SETTINGS_MAX_STALE_TIME: 1 * MINUTES
};
// src/lib/AsyncResult.ts
var ASYNC_LOADING = Object.freeze({ isLoading: true });
var ASYNC_ERR = (error) => Object.freeze({ isLoading: false, error });
function ASYNC_OK(fieldOrData, data) {
if (arguments.length === 1) {
return Object.freeze({ isLoading: false, data: fieldOrData });
} else {
return Object.freeze({ isLoading: false, [fieldOrData]: data });
}
}
// src/lib/ssr.ts
function ensureNotServerSide() {
if (typeof window === "undefined") {
throw new Error(
"You cannot use the Suspense version of Liveblocks hooks server side. Make sure to only call them client side by using a ClientSideSuspense wrapper.\nFor tips, see https://liveblocks.io/docs/api-reference/liveblocks-react#ClientSideSuspense"
);
}
}
// src/lib/use-initial.ts
import { useCallback, useMemo as useMemo2 } from "react";
function useInitial(value, roomId) {
return useMemo2(() => value, [roomId]);
}
function useInitialUnlessFunction(latestValue, roomId) {
const frozenValue = useInitial(latestValue, roomId);
const ref = useLatest(latestValue);
const wrapper = useCallback(
(...args) => ref.current(...args),
[ref]
);
if (typeof frozenValue === "function") {
return wrapper;
} else {
return frozenValue;
}
}
// src/lib/use-polyfill.ts
import * as React from "react";
var reactUse = React[" use ".trim().toString()];
var use = reactUse ?? ((promise) => {
if (promise.status === "pending") {
throw promise;
} else if (promise.status === "fulfilled") {
return promise.value;
} else if (promise.status === "rejected") {
throw promise.reason;
} else {
promise.status = "pending";
promise.then(
(v) => {
promise.status = "fulfilled";
promise.value = v;
},
(e) => {
promise.status = "rejected";
promise.reason = e;
}
);
throw promise;
}
});
// src/umbrella-store.ts
import {
assertNever,
autoRetry,
batch as batch2,
compactObject,
console,
createNotificationSettings,
DefaultMap,
DerivedSignal,
getSubscriptionKey as getSubscriptionKey2,
kInternal as kInternal2,
MutableSignal as MutableSignal3,
nanoid as nanoid2,
nn,
patchNotificationSettings,
shallow,
shallow2,
Signal,
stableStringify
} from "@liveblocks/core";
// src/lib/autobind.ts
function autobind(self) {
const seen = /* @__PURE__ */ new Set();
seen.add("constructor");
let obj = self.constructor.prototype;
do {
for (const key of Reflect.ownKeys(obj)) {
if (seen.has(key)) continue;
const descriptor = Reflect.getOwnPropertyDescriptor(obj, key);
if (typeof descriptor?.value === "function") {
seen.add(key);
self[key] = self[key].bind(self);
}
}
} while ((obj = Reflect.getPrototypeOf(obj)) && obj !== Object.prototype);
}
// src/lib/itertools.ts
function find(it, predicate) {
for (const item of it) {
if (predicate(item)) return item;
}
return void 0;
}
// src/lib/querying.ts
import {
getSubscriptionKey,
isNumberOperator,
isStartsWithOperator
} from "@liveblocks/core";
function makeThreadsFilter(query, subscriptions) {
return (thread) => matchesThreadsQuery(thread, query, subscriptions) && matchesMetadata(thread, query);
}
function matchesThreadsQuery(thread, q, subscriptions) {
let subscription = void 0;
if (subscriptions) {
subscription = subscriptions?.[getSubscriptionKey("thread", thread.id)];
}
return (q.resolved === void 0 || thread.resolved === q.resolved) && (q.subscribed === void 0 || q.subscribed === true && subscription !== void 0 || q.subscribed === false && subscription === void 0);
}
function matchesMetadata(thread, q) {
const metadata = thread.metadata;
return q.metadata === void 0 || Object.entries(q.metadata).every(
([key, op]) => (
// Ignore explicit-undefined filters
// Boolean logic: op? => value matches the operator
op === void 0 || matchesOperator(metadata[key], op)
)
);
}
function matchesOperator(value, op) {
if (op === null) {
return value === void 0;
} else if (isStartsWithOperator(op)) {
return typeof value === "string" && value.startsWith(op.startsWith);
} else if (isNumberOperator(op)) {
return typeof value === "number" && matchesNumberOperator(value, op);
} else {
return value === op;
}
}
function matchesNumberOperator(value, op) {
return (op.lt === void 0 || value < op.lt) && (op.gt === void 0 || value > op.gt) && (op.lte === void 0 || value <= op.lte) && (op.gte === void 0 || value >= op.gte);
}
function makeInboxNotificationsFilter(query) {
return (inboxNotification) => matchesInboxNotificationsQuery(inboxNotification, query);
}
function matchesInboxNotificationsQuery(inboxNotification, q) {
return (q.roomId === void 0 || q.roomId === inboxNotification.roomId) && (q.kind === void 0 || q.kind === inboxNotification.kind);
}
// src/ThreadDB.ts
import { batch, MutableSignal as MutableSignal2, SortedList } from "@liveblocks/core";
function sanitizeThread(thread) {
if (thread.deletedAt) {
if (thread.comments.length > 0) {
return { ...thread, comments: [] };
}
}
const hasComment = thread.comments.some((c) => !c.deletedAt);
if (!hasComment) {
return { ...thread, deletedAt: /* @__PURE__ */ new Date(), comments: [] };
}
return thread;
}
var ThreadDB = class _ThreadDB {
#byId;
#asc;
#desc;
// This signal will be notified on every mutation
signal;
constructor() {
this.#asc = SortedList.from([], (t1, t2) => {
const d1 = t1.createdAt;
const d2 = t2.createdAt;
return d1 < d2 ? true : d1 === d2 ? t1.id < t2.id : false;
});
this.#desc = SortedList.from([], (t1, t2) => {
const d2 = t2.updatedAt;
const d1 = t1.updatedAt;
return d2 < d1 ? true : d2 === d1 ? t2.id < t1.id : false;
});
this.#byId = /* @__PURE__ */ new Map();
this.signal = new MutableSignal2(this);
}
//
// Public APIs
//
clone() {
const newPool = new _ThreadDB();
newPool.#byId = new Map(this.#byId);
newPool.#asc = this.#asc.clone();
newPool.#desc = this.#desc.clone();
return newPool;
}
/** Returns an existing thread by ID. Will never return a deleted thread. */
get(threadId) {
const thread = this.getEvenIfDeleted(threadId);
return thread?.deletedAt ? void 0 : thread;
}
/** Returns the (possibly deleted) thread by ID. */
getEvenIfDeleted(threadId) {
return this.#byId.get(threadId);
}
/** Adds or updates a thread in the DB. If the newly given thread is a deleted one, it will get deleted. */
upsert(thread) {
this.signal.mutate(() => {
thread = sanitizeThread(thread);
const id = thread.id;
const toRemove = this.#byId.get(id);
if (toRemove) {
if (toRemove.deletedAt) return false;
this.#asc.remove(toRemove);
this.#desc.remove(toRemove);
}
if (!thread.deletedAt) {
this.#asc.add(thread);
this.#desc.add(thread);
}
this.#byId.set(id, thread);
return true;
});
}
/** Like .upsert(), except it won't update if a thread by this ID already exists. */
// TODO Consider renaming this to just .upsert(). I'm not sure if we really
// TODO need the raw .upsert(). Would be nice if this behavior was the default.
upsertIfNewer(thread) {
const existing = this.get(thread.id);
if (!existing || thread.updatedAt >= existing.updatedAt) {
this.upsert(thread);
}
}
applyDelta(newThreads, deletedThreads) {
batch(() => {
for (const thread of newThreads) {
this.upsertIfNewer(thread);
}
for (const { id, deletedAt } of deletedThreads) {
const existing = this.getEvenIfDeleted(id);
if (!existing) continue;
this.delete(id, deletedAt);
}
});
}
/**
* Marks a thread as deleted. It will no longer pop up in .findMany()
* queries, but it can still be accessed via `.getEvenIfDeleted()`.
*/
delete(threadId, deletedAt) {
const existing = this.#byId.get(threadId);
if (existing && !existing.deletedAt) {
this.upsert({ ...existing, deletedAt, updatedAt: deletedAt });
}
}
/**
* Returns all threads matching a given roomId and query. If roomId is not
* specified, it will return all threads matching the query, across all
* rooms.
*
* Returns the results in the requested order. Please note:
* 'asc' means by createdAt ASC
* 'desc' means by updatedAt DESC
*
* Will never return deleted threads in the result.
*
* Subscriptions are needed to filter threads based on the user's subscriptions.
*/
findMany(roomId, query, direction, subscriptions) {
const index = direction === "desc" ? this.#desc : this.#asc;
const crit = [];
if (roomId !== void 0) {
crit.push((t) => t.roomId === roomId);
}
if (query !== void 0) {
crit.push(makeThreadsFilter(query, subscriptions));
}
return Array.from(index.filter((t) => crit.every((pred) => pred(t))));
}
};
// src/umbrella-store.ts
function makeRoomThreadsQueryKey(roomId, query) {
return stableStringify([roomId, query ?? {}]);
}
function makeUserThreadsQueryKey(query) {
return stableStringify(query ?? {});
}
function makeAiChatsQueryKey(query) {
return stableStringify(query ?? {});
}
function makeInboxNotificationsQueryKey(query) {
return stableStringify(query ?? {});
}
function usify(promise) {
if ("status" in promise) {
return promise;
}
const usable = promise;
usable.status = "pending";
usable.then(
(value) => {
usable.status = "fulfilled";
usable.value = value;
},
(err) => {
usable.status = "rejected";
usable.reason = err;
}
);
return usable;
}
var noop = Promise.resolve();
var PaginatedResource = class {
#signal;
signal;
#fetchPage;
#pendingFetchMore;
constructor(fetchPage) {
this.#signal = new Signal(ASYNC_LOADING);
this.#fetchPage = fetchPage;
this.#pendingFetchMore = null;
this.signal = this.#signal.asReadonly();
autobind(this);
}
get() {
return this.#signal.get();
}
#patch(patch) {
const state = this.#signal.get();
if (state.data === void 0) return;
this.#signal.set(ASYNC_OK({ ...state.data, ...patch }));
}
async #fetchMore() {
const state = this.#signal.get();
if (!state.data?.cursor || state.data.isFetchingMore) {
return;
}
this.#patch({ isFetchingMore: true });
try {
const nextCursor = await this.#fetchPage(state.data.cursor);
this.#patch({
cursor: nextCursor,
hasFetchedAll: nextCursor === null,
fetchMoreError: void 0,
isFetchingMore: false
});
} catch (err) {
this.#patch({
isFetchingMore: false,
fetchMoreError: err
});
}
}
fetchMore() {
const state = this.#signal.get();
if (!state.data?.cursor) return noop;
if (!this.#pendingFetchMore) {
this.#pendingFetchMore = this.#fetchMore().finally(() => {
this.#pendingFetchMore = null;
});
}
return this.#pendingFetchMore;
}
#cachedPromise = null;
waitUntilLoaded() {
if (this.#cachedPromise) {
return this.#cachedPromise;
}
const initialPageFetch$ = autoRetry(
() => this.#fetchPage(
/* cursor */
void 0
),
5,
[5e3, 5e3, 1e4, 15e3]
);
const promise = usify(initialPageFetch$);
promise.then(
(cursor) => {
this.#signal.set(
ASYNC_OK({
cursor,
hasFetchedAll: cursor === null,
isFetchingMore: false,
fetchMoreError: void 0,
fetchMore: this.fetchMore
})
);
},
(err) => {
this.#signal.set(ASYNC_ERR(err));
setTimeout(() => {
this.#cachedPromise = null;
this.#signal.set(ASYNC_LOADING);
}, 5e3);
}
);
this.#cachedPromise = promise;
return this.#cachedPromise;
}
};
var SinglePageResource = class {
#signal;
signal;
#fetchPage;
#autoRetry = true;
constructor(fetchPage, autoRetry2 = true) {
this.#signal = new Signal(ASYNC_LOADING);
this.signal = this.#signal.asReadonly();
this.#fetchPage = fetchPage;
this.#autoRetry = autoRetry2;
autobind(this);
}
get() {
return this.#signal.get();
}
#cachedPromise = null;
waitUntilLoaded() {
if (this.#cachedPromise) {
return this.#cachedPromise;
}
const initialFetcher$ = this.#autoRetry ? autoRetry(() => this.#fetchPage(), 5, [5e3, 5e3, 1e4, 15e3]) : this.#fetchPage();
const promise = usify(initialFetcher$);
promise.then(
() => {
this.#signal.set(ASYNC_OK(void 0));
},
(err) => {
this.#signal.set(ASYNC_ERR(err));
if (this.#autoRetry) {
setTimeout(() => {
this.#cachedPromise = null;
this.#signal.set(ASYNC_LOADING);
}, 5e3);
}
}
);
this.#cachedPromise = promise;
return promise;
}
};
function createStore_forNotifications() {
const signal = new MutableSignal3(/* @__PURE__ */ new Map());
function markRead(notificationId, readAt) {
signal.mutate((lut) => {
const existing = lut.get(notificationId);
if (!existing) {
return false;
}
lut.set(notificationId, { ...existing, readAt });
return true;
});
}
function markAllRead(readAt) {
signal.mutate((lut) => {
for (const n of lut.values()) {
n.readAt = readAt;
}
});
}
function deleteOne(inboxNotificationId) {
signal.mutate((lut) => lut.delete(inboxNotificationId));
}
function clear() {
signal.mutate((lut) => lut.clear());
}
function applyDelta(newNotifications, deletedNotifications) {
signal.mutate((lut) => {
let mutated = false;
for (const n of newNotifications) {
const existing = lut.get(n.id);
if (existing) {
const result = compareInboxNotifications(existing, n);
if (result === 1) continue;
}
lut.set(n.id, n);
mutated = true;
}
for (const n of deletedNotifications) {
lut.delete(n.id);
mutated = true;
}
return mutated;
});
}
function updateAssociatedNotification(newComment) {
signal.mutate((lut) => {
const existing = find(
lut.values(),
(notification) => notification.kind === "thread" && notification.threadId === newComment.threadId
);
if (!existing) return false;
lut.set(existing.id, {
...existing,
notifiedAt: newComment.createdAt,
readAt: newComment.createdAt
});
return true;
});
}
function upsert(notification) {
signal.mutate((lut) => {
lut.set(notification.id, notification);
});
}
return {
signal: signal.asReadonly(),
// Mutations
markAllRead,
markRead,
delete: deleteOne,
applyDelta,
clear,
updateAssociatedNotification,
upsert
};
}
function createStore_forUnreadNotificationsCount() {
const baseSignal = new MutableSignal3(
/* @__PURE__ */ new Map()
);
function update(queryKey, count) {
baseSignal.mutate((lut) => {
lut.set(queryKey, count);
});
}
return {
signal: DerivedSignal.from(baseSignal, (c) => Object.fromEntries(c)),
// Mutations
update
};
}
function createStore_forSubscriptions(updates, threads) {
const baseSignal = new MutableSignal3(/* @__PURE__ */ new Map());
function applyDelta(newSubscriptions, deletedSubscriptions) {
baseSignal.mutate((lut) => {
let mutated = false;
for (const s of newSubscriptions) {
lut.set(getSubscriptionKey2(s), s);
mutated = true;
}
for (const s of deletedSubscriptions) {
lut.delete(getSubscriptionKey2(s));
mutated = true;
}
return mutated;
});
}
function create(subscription) {
baseSignal.mutate((lut) => {
lut.set(getSubscriptionKey2(subscription), subscription);
});
}
function deleteOne(subscriptionKey) {
baseSignal.mutate((lut) => {
lut.delete(subscriptionKey);
});
}
return {
signal: DerivedSignal.from(
baseSignal,
updates,
(base, updates2) => applyOptimisticUpdates_forSubscriptions(base, threads, updates2)
),
// Mutations
applyDelta,
create,
delete: deleteOne
};
}
function createStore_forRoomSubscriptionSettings(updates) {
const baseSignal = new MutableSignal3(/* @__PURE__ */ new Map());
function update(roomId, settings) {
baseSignal.mutate((lut) => {
lut.set(roomId, settings);
});
}
return {
signal: DerivedSignal.from(
baseSignal,
updates,
(base, updates2) => applyOptimisticUpdates_forRoomSubscriptionSettings(base, updates2)
),
// Mutations
update
};
}
function createStore_forHistoryVersions() {
const baseSignal = new MutableSignal3(
new DefaultMap(() => /* @__PURE__ */ new Map())
);
function update(roomId, versions) {
baseSignal.mutate((lut) => {
const versionsById = lut.getOrCreate(roomId);
for (const version of versions) {
versionsById.set(version.id, version);
}
});
}
return {
signal: DerivedSignal.from(
baseSignal,
(hv) => Object.fromEntries(
[...hv].map(([roomId, versions]) => [
roomId,
Object.fromEntries(versions)
])
)
),
// Mutations
update
};
}
function createStore_forUrlsMetadata() {
const baseSignal = new MutableSignal3(/* @__PURE__ */ new Map());
function update(url, metadata) {
baseSignal.mutate((lut) => {
lut.set(url, metadata);
});
}
return {
signal: DerivedSignal.from(baseSignal, (m) => Object.fromEntries(m)),
// Mutations
update
};
}
function createStore_forPermissionHints() {
const permissionsByRoomId = new DefaultMap(
() => new Signal(/* @__PURE__ */ new Set())
);
function update(newHints) {
batch2(() => {
for (const [roomId, permissions] of Object.entries(newHints)) {
const signal = permissionsByRoomId.getOrCreate(roomId);
const existingPermissions = new Set(signal.get());
for (const permission of permissions) {
existingPermissions.add(permission);
}
signal.set(existingPermissions);
}
});
}
function getPermissionForRoom\u03A3(roomId) {
return permissionsByRoomId.getOrCreate(roomId);
}
return {
getPermissionForRoom\u03A3,
// Mutations
update
};
}
function createStore_forNotificationSettings(updates) {
const signal = new Signal(
createNotificationSettings({})
);
function update(settings) {
signal.set(settings);
}
return {
signal: DerivedSignal.from(
signal,
updates,
(base, updates2) => applyOptimisticUpdates_forNotificationSettings(base, updates2)
),
// Mutations
update
};
}
function createStore_forOptimistic(client) {
const signal = new Signal([]);
const syncSource = client[kInternal2].createSyncSource();
signal.subscribe(
() => syncSource.setSyncStatus(
signal.get().length > 0 ? "synchronizing" : "synchronized"
)
);
function add(optimisticUpdate) {
const id = nanoid2();
const newUpdate = { ...optimisticUpdate, id };
signal.set((state) => [...state, newUpdate]);
return id;
}
function remove(optimisticId) {
signal.set((state) => state.filter((ou) => ou.id !== optimisticId));
}
return {
signal: signal.asReadonly(),
// Mutations
add,
remove
};
}
var UmbrellaStore = class {
#client;
//
// Internally, the UmbrellaStore keeps track of a few source signals that can
// be set and mutated individually. When any of those are mutated then the
// clean "external state" is recomputed.
//
// Mutate inputs... ...observe clean/consistent output!
//
// .-> Base ThreadDB ---------+ +-------> Clean threads by ID (Part 1)
// / | |
// mutate ----> Base Notifications --+ | | +-----> Clean notifications (Part 1)
// \ | | | | & notifications by ID
// | \ | | Apply | |
// | `-> OptimisticUpdates --+--+--> Optimistic -+-+-+-+-> Subscriptions (Part 2)
// \ | Updates | | |
// `------- etc etc ---------+ | | +-> History Versions (Part 3)
// ^ | |
// | | +---> Room Subscription Settings (Part 4)
// | |
// | +-------> Notification Settings (Part 5)
// |
// |
// | ^ ^
// Signal | |
// or DerivedSignal DerivedSignals
// MutableSignal
//
//
// Input signals.
// (Can be mutated directly.)
//
// XXX_vincent Now that we have createStore_forX, we should probably also change
// `threads` to this pattern, ie create a createStore_forThreads helper as
// well. It almost works like that already anyway!
threads;
// Exposes its signal under `.signal` prop
notifications;
subscriptions;
roomSubscriptionSettings;
// prettier-ignore
historyVersions;
unreadNotificationsCount;
urlsMetadata;
permissionHints;
notificationSettings;
optimisticUpdates;
//
// Output signals.
// (Readonly, clean, consistent. With optimistic updates applied.)
//
// Note that the output of threadifications signal is the same as the ones for
// threads and notifications separately, but the threadifications signal will
// be updated whenever either of them change.
//
outputs;
// Notifications
#notificationsLastRequestedAt = null;
// Keeps track of when we successfully requested an inbox notifications update for the last time. Will be `null` as long as the first successful fetch hasn't happened yet.
// Room Threads
#roomThreadsLastRequestedAtByRoom = /* @__PURE__ */ new Map();
// User Threads
#userThreadsLastRequestedAt = null;
// Room versions
#roomVersionsLastRequestedAtByRoom = /* @__PURE__ */ new Map();
// Notification Settings
#notificationSettings;
constructor(client) {
this.#client = client[kInternal2].as();
this.optimisticUpdates = createStore_forOptimistic(this.#client);
this.permissionHints = createStore_forPermissionHints();
const notificationSettingsFetcher = async () => {
const result = await this.#client.getNotificationSettings();
this.notificationSettings.update(result);
};
this.notificationSettings = createStore_forNotificationSettings(
this.optimisticUpdates.signal
);
this.#notificationSettings = new SinglePageResource(
notificationSettingsFetcher
);
this.threads = new ThreadDB();
this.subscriptions = createStore_forSubscriptions(
this.optimisticUpdates.signal,
this.threads
);
this.notifications = createStore_forNotifications();
this.roomSubscriptionSettings = createStore_forRoomSubscriptionSettings(
this.optimisticUpdates.signal
);
this.historyVersions = createStore_forHistoryVersions();
this.unreadNotificationsCount = createStore_forUnreadNotificationsCount();
this.urlsMetadata = createStore_forUrlsMetadata();
const threadifications = DerivedSignal.from(
this.threads.signal,
this.notifications.signal,
this.optimisticUpdates.signal,
(ts, ns, updates) => applyOptimisticUpdates_forThreadifications(ts, ns, updates)
);
const threads = DerivedSignal.from(threadifications, (s) => s.threadsDB);
const notifications = DerivedSignal.from(
threadifications,
(s) => ({
sortedNotifications: s.sortedNotifications,
notificationsById: s.notificationsById
}),
shallow
);
const threadSubscriptions = DerivedSignal.from(
notifications,
this.subscriptions.signal,
(n, s) => ({
subscriptions: s,
notifications: n.sortedNotifications
})
);
const loadingUserThreads = new DefaultMap(
(queryKey) => {
const query = JSON.parse(queryKey);
const resource = new PaginatedResource(async (cursor) => {
const result = await this.#client[kInternal2].httpClient.getUserThreads_experimental({
cursor,
query
});
this.updateThreadifications(
result.threads,
result.inboxNotifications,
result.subscriptions
);
this.permissionHints.update(result.permissionHints);
if (this.#userThreadsLastRequestedAt === null) {
this.#userThreadsLastRequestedAt = result.requestedAt;
}
return result.nextCursor;
});
const signal = DerivedSignal.from(() => {
const result = resource.get();
if (result.isLoading || result.error) {
return result;
}
const subscriptions = threadSubscriptions.get().subscriptions;
const threads2 = this.outputs.threads.get().findMany(
void 0,
// Do _not_ filter by roomId
query ?? {},
"desc",
subscriptions
);
const page = result.data;
return {
isLoading: false,
threads: threads2,
hasFetchedAll: page.hasFetchedAll,
isFetchingMore: page.isFetchingMore,
fetchMoreError: page.fetchMoreError,
fetchMore: page.fetchMore
};
}, shallow2);
return { signal, waitUntilLoaded: resource.waitUntilLoaded };
}
);
const loadingRoomThreads = new DefaultMap(
(queryKey) => {
const [roomId, query] = JSON.parse(queryKey);
const resource = new PaginatedResource(async (cursor) => {
const result = await this.#client[kInternal2].httpClient.getThreads({
roomId,
cursor,
query
});
this.updateThreadifications(
result.threads,
result.inboxNotifications,
result.subscriptions
);
this.permissionHints.update(result.permissionHints);
const lastRequestedAt = this.#roomThreadsLastRequestedAtByRoom.get(roomId);
if (lastRequestedAt === void 0 || lastRequestedAt > result.requestedAt) {
this.#roomThreadsLastRequestedAtByRoom.set(
roomId,
result.requestedAt
);
}
return result.nextCursor;
});
const signal = DerivedSignal.from(() => {
const result = resource.get();
if (result.isLoading || result.error) {
return result;
}
const subscriptions = threadSubscriptions.get().subscriptions;
const threads2 = this.outputs.threads.get().findMany(roomId, query ?? {}, "asc", subscriptions);
const page = result.data;
return {
isLoading: false,
threads: threads2,
hasFetchedAll: page.hasFetchedAll,
isFetchingMore: page.isFetchingMore,
fetchMoreError: page.fetchMoreError,
fetchMore: page.fetchMore
};
}, shallow2);
return { signal, waitUntilLoaded: resource.waitUntilLoaded };
}
);
const loadingNotifications = new DefaultMap(
(queryKey) => {
const query = JSON.parse(queryKey);
const resource = new PaginatedResource(async (cursor) => {
const result = await this.#client.getInboxNotifications({
cursor,
query
});
this.updateThreadifications(
result.threads,
result.inboxNotifications,
result.subscriptions
);
if (this.#notificationsLastRequestedAt === null) {
this.#notificationsLastRequestedAt = result.requestedAt;
}
const nextCursor = result.nextCursor;
return nextCursor;
});
const signal = DerivedSignal.from(() => {
const result = resource.get();
if (result.isLoading || result.error) {
return result;
}
const crit = [];
if (query !== void 0) {
crit.push(makeInboxNotificationsFilter(query));
}
const inboxNotifications = this.outputs.notifications.get().sortedNotifications.filter(
(inboxNotification) => crit.every((pred) => pred(inboxNotification))
);
const page = result.data;
return {
isLoading: false,
inboxNotifications,
hasFetchedAll: page.hasFetchedAll,
isFetchingMore: page.isFetchingMore,
fetchMoreError: page.fetchMoreError,
fetchMore: page.fetchMore
};
}, shallow2);
return {
signal,
waitUntilLoaded: resource.waitUntilLoaded
};
}
);
const unreadNotificationsCount = new DefaultMap(
(queryKey) => {
const query = JSON.parse(queryKey);
const resource = new SinglePageResource(async () => {
const result = await this.#client.getUnreadInboxNotificationsCount({
query
});
this.unreadNotificationsCount.update(queryKey, result);
});
const signal = DerivedSignal.from(
() => {
const result = resource.get();
if (result.isLoading || result.error) {
return result;
} else {
return ASYNC_OK(
"count",
nn(this.unreadNotificationsCount.signal.get()[queryKey])
);
}
},
shallow
);
return {
signal,
waitUntilLoaded: resource.waitUntilLoaded
};
}
);
const roomSubscriptionSettingsByRoomId = new DefaultMap(
(roomId) => {
const resource = new SinglePageResource(async () => {
const room = this.#client.getRoom(roomId);
if (room === null) {
throw new Error(`Room '${roomId}' is not available on client`);
}
const result = await room.getSubscriptionSettings();
this.roomSubscriptionSettings.update(roomId, result);
});
const signal = DerivedSignal.from(() => {
const result = resource.get();
if (result.isLoading || result.error) {
return result;
} else {
return ASYNC_OK(
"settings",
nn(this.roomSubscriptionSettings.signal.get()[roomId])
);
}
}, shallow);
return { signal, waitUntilLoaded: resource.waitUntilLoaded };
}
);
const versionsByRoomId = new DefaultMap(
(roomId) => {
const resource = new SinglePageResource(async () => {
const room = this.#client.getRoom(roomId);
if (room === null) {
throw new Error(`Room '${roomId}' is not available on client`);
}
const result = await room[kInternal2].listTextVersions();
this.historyVersions.update(roomId, result.versions);
const lastRequestedAt = this.#roomVersionsLastRequestedAtByRoom.get(roomId);
if (lastRequestedAt === void 0 || lastRequestedAt > result.requestedAt) {
this.#roomVersionsLastRequestedAtByRoom.set(
roomId,
result.requestedAt
);
}
});
const signal = DerivedSignal.from(() => {
const result = resource.get();
if (result.isLoading || result.error) {
return result;
} else {
return ASYNC_OK(
"versions",
Object.values(this.historyVersions.signal.get()[roomId] ?? {})
);
}
}, shallow);
return { signal, waitUntilLoaded: resource.waitUntilLoaded };
}
);
const notificationSettings = {
signal: DerivedSignal.from(() => {
const result = this.#notificationSettings.get();
if (result.isLoading || result.error) {
return result;
}
return ASYNC_OK(
"settings",
nn(this.notificationSettings.signal.get())
);
}, shallow),
waitUntilLoaded: this.#notificationSettings.waitUntilLoaded
};
const aiChats = new DefaultMap(
(queryKey) => {
const query = JSON.parse(queryKey);
const resource = new PaginatedResource(async (cursor) => {
const result = await this.#client[kInternal2].ai.getChats({
cursor,
query
});
return result.nextCursor;
});
const signal = DerivedSignal.from(() => {
const result = resource.get();
if (result.isLoading || result.error) {
return result;
}
const chats = this.#client[kInternal2].ai.queryChats(query);
return {
isLoading: false,
chats,
hasFetchedAll: result.data.hasFetchedAll,
isFetchingMore: result.data.isFetchingMore,
fetchMore: result.data.fetchMore,
fetchMoreError: result.data.fetchMoreError
};
}, shallow);
return { signal, waitUntilLoaded: resource.waitUntilLoaded };
}
);
const messagesByChatId = new DefaultMap((chatId) => {
const resource\u03A3 = new SinglePageResource(async () => {
await this.#client[kInternal2].ai.getMessageTree(chatId);
});
return new DefaultMap(
(branch) => {
const signal = DerivedSignal.from(() => {
const result = resource\u03A3.get();
if (result.isLoading || result.error) {
return result;
}
return ASYNC_OK(
"messages",
this.#client[kInternal2].ai.signals.getChatMessagesForBranch\u03A3(chatId, branch ?? void 0).get()
);
});
return { signal, waitUntilLoaded: resource\u03A3.waitUntilLoaded };
}
);
});
const aiChatById = new DefaultMap((chatId) => {
const resource = new SinglePageResource(async () => {
await this.#client[kInternal2].ai.getOrCreateChat(chatId);
});
const signal = DerivedSignal.from(() => {
const chat = this.#client[kInternal2].ai.getChatById(chatId);
if (chat === void 0) {
const result = resource.get();
if (result.isLoading || result.error) {
return result;
} else {
return ASYNC_OK(
"chat",
nn(this.#client[kInternal2].ai.getChatById(chatId))
);
}
} else {
return ASYNC_OK(
"chat",
nn(this.#client[kInternal2].ai.getChatById(chatId))
);
}
}, shallow);
return { signal, waitUntilLoaded: resource.waitUntilLoaded };
});
const urlMetadataByUrl = new DefaultMap(
(url) => {
const resource = new SinglePageResource(async () => {
const metadata = await this.#client[kInternal2].httpClient.getUrlMetadata(url);
this.urlsMetadata.update(url, metadata);
}, false);
const signal = DerivedSignal.from(() => {
const result = resource.get();
if (result.isLoading || result.error) {
return result;
}
return ASYNC_OK("metadata", nn(this.urlsMetadata.signal.get()[url]));
}, shallow);
return { signal, waitUntilLoaded: resource.waitUntilLoaded };
}
);
this.outputs = {
threadifications,
threads,
loadingRoomThreads,
loadingUserThreads,
notifications,
loadingNotifications,
unreadNotificationsCount,
roomSubscriptionSettingsByRoomId,
versionsByRoomId,
notificationSettings,
threadSubscriptions,
aiChats,
messagesByChatId,
aiChatById,
urlMetadataByUrl
};
autobind(this);
}
/**
* Updates an existing inbox notification with a new value, replacing the
* corresponding optimistic update.
*
* This will not update anything if the inbox notification ID isn't found.
*/
markInboxNotificationRead(inboxNotificationId, readAt, optimisticId) {
batch2(() => {
this.optimisticUpdates.remove(optimisticId);
this.notifications.markRead(inboxNotificationId, readAt);
});
}
markAllInboxNotificationsRead(optimisticId, readAt) {
batch2(() => {
this.optimisticUpdates.remove(optimisticId);
this.notifications.markAllRead(readAt);
});
}
/**
* Deletes an existing inbox notification, replacing the corresponding
* optimistic update.
*/
deleteInboxNotification(inboxNotificationId, optimisticId) {
batch2(() => {
this.optimisticUpdates.remove(optimisticId);
this.notifications.delete(inboxNotificationId);
});
}
/**
* Deletes *all* inbox notifications, replacing the corresponding optimistic
* update.
*/
deleteAllInboxNotifications(optimisticId) {
batch2(() => {
this.optimisticUpdates.remove(optimisticId);
this.notifications.clear();
});
}
/**
* Creates an existing subscription, replacing the corresponding
* optimistic update.
*/
createSubscription(subscription, optimisticId) {
batch2(() => {
this.optimisticUpdates.remove(optimisticId);
this.subscriptions.create(subscription);
});
}
/**
* Deletes an existing subscription, replacing the corresponding
* optimistic update.
*/
deleteSubscription(subscriptionKey, optimisticId) {
batch2(() => {
this.optimisticUpdates.remove(optimisticId);
this.subscriptions.delete(subscriptionKey);
});
}
/**
* Creates an new thread, replacing the corresponding optimistic update.
*/
createThread(optimisticId, thread) {
batch2(() => {
this.optimisticUpdates.remove(optimisticId);
this.threads.upsert(thread);
});
}
/**
* Updates an existing thread with a new value, replacing the corresponding
* optimistic update.
*
* This will not update anything if:
* - The thread ID isn't found; or
* - The thread ID was already deleted; or
* - The thread ID was updated more recently than the optimistic update's
* timestamp (if given)
*/
#updateThread(threadId, optimisticId, callback, updatedAt) {
batch2(() => {
if (optimisticId !== null) {
this.optimisticUpdates.remove(optimisticId);
}
const db = this.threads;
const existing = db.get(threadId);
if (!existing) return;
if (!!updatedAt && existing.updatedAt > updatedAt) return;
db.upsert(callback(existing));
});
}
patchThread(threadId, optimisticId, patch, updatedAt) {
return this.#updateThread(
threadId,
optimisticId,
(thread) => ({ ...thread, ...compactObject(patch) }),
updatedAt
);
}
addReaction(threadId, optimisticId, commentId, reaction, createdAt) {
this.#updateThread(
threadId,
optimisticId,
(thread) => applyAddReaction(thread, commentId, reaction),
createdAt
);
}
removeReaction(threadId, optimisticId, commentId, emoji, userId, removedAt) {
this.#updateThread(
threadId,
optimisticId,
(thread) => applyRemoveReaction(thread, commentId, emoji, userId, removedAt),
removedAt
);
}
/**
* Soft-deletes an existing thread by setting its `deletedAt` value,
* replacing the corresponding optimistic update.
*
* This will not update anything if:
* - The thread ID isn't found; or
* - The thread ID was already deleted
*/
deleteThread(threadId, optimisticId) {
return this.#updateThread(
threadId,
optimisticId,
// A deletion is actually an update of the deletedAt property internally
(thread) => ({ ...thread, updatedAt: /* @__PURE__ */ new Date(), deletedAt: /* @__PURE__ */ new Date() })
);
}
/**
* Creates an existing comment and ensures the associated notification is
* updated correctly, replacing the corresponding optimistic update.
*/
createComment(newComment, optimisticId) {
batch2(() => {
this.optimisticUpdates.remove(optimisticId);
const existingThread = this.threads.get(newComment.threadId);
if (!existingThread) {
return;
}
this.threads.upsert(applyUpsertComment(existingThread, newComment));
this.notifications.updateAssociatedNotification(newComment);
});
}
editComment(threadId, optimisticId, editedComment) {
return this.#updateThread(
threadId,
optimisticId,
(thread) => applyUpsertComment(thread, editedComment)
);
}
deleteComment(threadId, optimisticId, commentId, deletedAt) {
return this.#updateThread(
threadId,
optimisticId,
(thread) => applyDeleteComment(thread, commentId, deletedAt),
deletedAt
);
}
updateThreadifications(threads, notifications, subscriptions, deletedThreads = [], deletedNotifications = [], deletedSubscriptions = []) {
batch2(() => {
this.threads.applyDelta(threads, deletedThreads);
this.notifications.applyDelta(notifications, deletedNotifications);
this.subscriptions.applyDelta(subscriptions, deletedSubscriptions);
});
}
/**
* Updates existing subscription settings for a room with a new value,
* replacing the corresponding optimistic update.
*/
updateRoomSubscriptionSettings(roomId, optimisticId, settings) {
batch2(() => {
this.optimisticUpdates.remove(optimisticId);
this.roomSubscriptionSettings.update(roomId, settings);
});
}
async fetchNotificationsDeltaUpdate(signal) {
const lastRequestedAt = this.#notificationsLastRequestedAt;
if (lastRequestedAt === null) {
return;
}
const result = await this.#client.getInboxNotificationsSince({
since: lastRequestedAt,
signal
});
if (lastRequestedAt < result.requestedAt) {
this.#notificationsLastRequestedAt = result.requestedAt;
}
this.updateThreadifications(
result.threads.updated,
result.inboxNotifications.updated,
result.subscriptions.updated,
result.threads.deleted,
result.inboxNotifications.deleted,
result.subscriptions.deleted
);
}
async fetchUnreadNotificationsCount(queryKey, signal) {
const query = JSON.parse(queryKey);
const result = await this.#client.getUnreadInboxNotificationsCount({
query,
signal
});
this.unreadNotificationsCount.update(queryKey, result);
}
async fetchRoomThreadsDeltaUpdate(roomId, signal) {
const lastRequestedAt = this.#roomThreadsLastRequestedAtByRoom.get(roomId);
if (lastRequestedAt === void 0) {
return;
}
const updates = await this.#client[kInternal2].httpClient.getThreadsSince({
roomId,
since: lastRequestedAt,
signal
});
this.updateThreadifications(
updates.threads.updated,
updates.inboxNotifications.updated,
updates.subscriptions.updated,
updates.threads.deleted,
updates.inboxNotifications.deleted,
updates.subscriptio