Skip to main content
Glama
presence.store.ts9.46 kB
import { defineStore } from "pinia"; import * as _ from "lodash-es"; import { addStoreHooks } from "@si/vue-lib/pinia"; import { watch } from "vue"; import { useChangeSetsStore } from "@/store/change_sets.store"; import { useWorkspacesStore } from "@/store/workspaces.store"; import { UserId, useAuthStore } from "@/store/auth.store"; import { useRealtimeStore } from "@/store/realtime/realtime.store"; import { useViewsStore } from "@/store/views.store"; import handleStoreError from "./errors"; const MOUSE_REFRESH_RATE = 5; export const ONLINE_PING_RATE = 5000; // 5 seconds export const ONLINE_EXPIRATION = ONLINE_PING_RATE + 1000; // 6 seconds export const IDLE_EXPIRATION = 120000; // 2 minutes // TODO(Wendy) - come up with user colors list, maybe talk to Mark const COLORS = ["ffff00", "00ffff", "ff00ff", "00ff00", "0000ff", "ff0000"]; export type CursorContainerKind = "diagram" | "code-editor" | null; export interface RawDiagramCursor { x: number | null; y: number | null; timestamp: Date; changeSetId: string | null; viewId: string | null; } export type DiagramCursorDef = RawDiagramCursor & { userId: UserId; name: string; color: string | undefined; }; export interface OnlineUser { pk: string; name: string; pictureUrl: string | null; changeSetId?: string; viewId?: string; color?: string | null; idle: boolean; } export const usePresenceStore = () => { const workspacesStore = useWorkspacesStore(); const workspaceId = workspacesStore.selectedWorkspacePk; const authStore = useAuthStore(); const realtimeStore = useRealtimeStore(); const changeSetsStore = useChangeSetsStore(); let viewStore = useViewsStore(changeSetsStore.selectedChangeSetId); return addStoreHooks( workspaceId, undefined, defineStore(`ws${workspaceId}/presence`, { state: () => ({ x: null as number | null, y: null as number | null, diagramCursorsByUserId: {} as Record<UserId, RawDiagramCursor>, usersById: {} as Record< UserId, OnlineUser & { lastOnlineAt: Date; lastActiveAt: Date } >, now: new Date(), lastSeenAt: new Date(), leftDrawerOpen: false, leftResizePanelWidth: 0, rightResizePanelWidth: 0, }), getters: { users(): OnlineUser[] { return _.values(this.usersById); }, usersInChangeSet(): OnlineUser[] { return _.filter( this.users, (u) => u.changeSetId === changeSetsStore.selectedChangeSetId, ); }, diagramCursors: (state): DiagramCursorDef[] => { return _.filter( _.values( _.mapValues(state.diagramCursorsByUserId, (cursor, userId) => ({ ...cursor, userId, name: state.usersById[userId]?.name || "", color: state.usersById[userId]?.color || undefined, })), ), (cursor) => { return ( cursor.x !== null && cursor.y !== null && state.usersById[cursor.userId]?.changeSetId === changeSetsStore.selectedChangeSetId && state.usersById[cursor.userId]?.viewId === viewStore.selectedViewId ); }, ); }, isIdle: (state) => state.now.getTime() - state.lastSeenAt.getTime() > IDLE_EXPIRATION, }, actions: { getUserColor() { return `#${COLORS[(this.users.length - 1) % COLORS.length]}`; }, updateLastSeen() { this.lastSeenAt = new Date(); }, updateCursor(pos: { x: number; y: number } | null) { this.x = pos?.x || null; this.y = pos?.y || null; this.sendCursor(); }, clearCursor() { this.x = null; this.y = null; this.sendCursor(); }, sendOnline() { if (!authStore.user) return; realtimeStore.sendMessage({ kind: "Online", data: { userPk: authStore.user.pk, name: authStore.user.name, pictureUrl: authStore.user.picture_url ?? null, idle: this.isIdle, changeSetId: changeSetsStore.selectedChangeSetId ?? null, viewId: viewStore.selectedViewId ?? null, }, }); }, websocketSendCursor: _.debounce( (x: number | null, y: number | null) => { if (!authStore.user) return; realtimeStore.sendMessage({ kind: "Cursor", data: { userName: authStore.user.name, userPk: authStore.user.pk, changeSetId: changeSetsStore.selectedChangeSetId ?? null, viewId: viewStore.selectedViewId ?? null, container: null, containerKey: null, x: x !== null ? x.toString() : null, y: y !== null ? y.toString() : null, }, }); }, MOUSE_REFRESH_RATE, ), sendCursor() { this.websocketSendCursor(this.x, this.y); }, }, onActivated() { const realtimeStore = useRealtimeStore(); this.sendCursor(); this.sendOnline(); const interval = setInterval(() => { this.sendOnline(); // Remove users whose Online ping is too old this.usersById = _.pickBy( this.usersById, (user) => new Date().getTime() - user.lastOnlineAt.getTime() < ONLINE_EXPIRATION, ); }, ONLINE_PING_RATE); watch( [() => changeSetsStore.selectedChangeSetId, () => this.isIdle], () => { viewStore = useViewsStore(changeSetsStore.selectedChangeSetId); this.sendOnline(); }, ); watch( [() => viewStore.selectedViewId, () => this.isIdle], this.sendOnline, ); const timeUpdate = setInterval(() => { this.now = new Date(); }, 1000); // This subscribes to events based on your current change set for Presence data that is change set specific watch( () => changeSetsStore.selectedChangeSetId, (newChangeSetId) => { realtimeStore.unsubscribe(`${this.$id}-changeset`); realtimeStore.subscribe( `${this.$id}-changeset`, `changeset/${newChangeSetId}`, [ { eventType: "Cursor", callback: (payload) => { if (payload.userPk === authStore.user?.pk) return; /* eslint-disable no-empty */ try { this.diagramCursorsByUserId[payload.userPk] = { changeSetId: payload.changeSetId, viewId: payload.viewId, x: payload.x !== null ? parseInt(payload.x) : null, y: payload.y !== null ? parseInt(payload.y) : null, timestamp: new Date(), }; // Triggers watchers of cursors this.diagramCursorsByUserId = { ...this.diagramCursorsByUserId, }; } catch {} }, }, ], ); }, ); // This subscribes to events which are for your whole workspace realtimeStore.subscribe( `${this.$id}-workspace`, `workspace/${workspaceId}`, [ { eventType: "Online", callback: (payload) => { if (payload.userPk === authStore.user?.pk) return; const needsColor = !this.usersById[payload.userPk]; // eslint-disable-next-line @typescript-eslint/no-explicit-any this.usersById[payload.userPk] ||= {} as any; _.assign(this.usersById[payload.userPk], { pk: payload.userPk, ..._.pick(payload, "name", "idle", "pictureUrl"), viewId: payload.viewId, changeSetId: payload.changeSetId, lastOnlineAt: new Date(), ...(!payload.idle && { lastActiveAt: new Date() }), ...(needsColor && { color: this.getUserColor(), }), }); }, }, ], ); window.addEventListener("mousedown", this.updateLastSeen); window.addEventListener("mousemove", this.updateLastSeen); window.addEventListener("keydown", this.updateLastSeen); const actionUnsub = this.$onAction(handleStoreError); return () => { actionUnsub(); realtimeStore.unsubscribe(`${this.$id}-changeset`); realtimeStore.unsubscribe(`${this.$id}-workspace`); clearInterval(interval); clearInterval(timeUpdate); window.removeEventListener("mousedown", this.updateLastSeen); window.removeEventListener("mousemove", this.updateLastSeen); window.removeEventListener("keydown", this.updateLastSeen); }; }, }), )(); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server