UNPKG

@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
// 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