Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a4f8a6727 |
No files matched your search
+1
-1
@@ -17,7 +17,7 @@ Versions merged on 2026-10-31 (machine-readable copy: `packages/ui-api-client/op
|
||||
| health | 1.1.0 | `/health` | 4 | qqq `develop` 6ac64fc66 (qqq-middleware-javalin) | 1.1.0 (same) |
|
||||
| alerts | 1.2.0 | `/alerts` | 6 | qbit-alerts `develop` a3fc6ac | |
|
||||
| assistant | 1.6.0 | `/assistant` | 16 | qbit-assistant `develop` bcf173f | |
|
||||
| collaboration | 1.1.0 | `/qbit/collaboration` | 45 | qbit-collaboration `develop` 03407ae | 1.1.0 (presence schemas, error contract) |
|
||||
| collaboration | 1.2.0 | `/qbit/collaboration` | 45 | qbit-collaboration `feat/per-session-presence` 0ae1e62 (local worktree `.worktrees/qbit-collab-sessions`, not yet on `develop`) | 1.1.0 (presence schemas, error contract); 1.2.0 adds per-connection presence (`connections`, `leaseCount`, `perConnection`), used when present |
|
||||
| data-analysis | 1.5.0 | `/qbit/data-analysis` | 21 | qbit-data-analysis `develop` 368d2c0 | |
|
||||
| db-performance | 1.0.0 | `/qbit/db-performance` | 4 | qbit-db-performance `develop` 4fd2c30 | |
|
||||
| edi | 1.1.0 | `/qbit/edi` | 4 | qbit-edi `develop` 75a4e6b | |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { COLLABORATION, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
|
||||
import { COLLABORATION, connectionsSuffix, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
|
||||
import type { Context } from '../context.js'
|
||||
import { UsageError } from '../context.js'
|
||||
import { COLUMNS_OPTION, chooseColumns, countFooter, instantText, positiveInt, type Json } from './shared.js'
|
||||
@@ -27,14 +27,15 @@ function hhmmss(date: Date): string {
|
||||
return date.toTimeString().slice(0, 8)
|
||||
}
|
||||
|
||||
/** `2 here: Alice Morgan (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
|
||||
/** `2 on person 1: Alice Morgan ×2 (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
|
||||
export function presenceLine(state: PresenceState): string {
|
||||
if (!state.available) return 'presence: the collaboration extension is not installed'
|
||||
if (state.error) return `presence: ${state.error}`
|
||||
const where = state.scope.pk !== undefined ? `${state.scope.table} ${state.scope.pk}` : state.scope.table
|
||||
if (!state.count) return `nobody on ${where}`
|
||||
const people = state.people.map((p) => `${p.name} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
|
||||
return `${state.count} on ${where}: ${people.join(', ')}`
|
||||
const people = state.people.map((p) => `${p.name}${connectionsSuffix(p)} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
|
||||
const connections = state.connections ?? state.count
|
||||
return `${state.count} on ${where}${connections > state.count ? ` (${connections} connections)` : ''}: ${people.join(', ')}`
|
||||
}
|
||||
|
||||
function favoriteRows(items: FavoriteEntry[]): Json[] {
|
||||
@@ -71,7 +72,8 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
|
||||
.option('--watch', 'join and follow changes (heartbeat every 20 s, leaves on exit)')
|
||||
.option('--observe', 'follow changes without joining')
|
||||
.option('--editing [field]', 'with --watch: join as EDITING, optionally on a field')
|
||||
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean }) => {
|
||||
.option('--per-connection', 'one row per connection (tab, terminal) with its mode, field and start (collaboration 1.2.0)')
|
||||
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean; perConnection?: boolean }) => {
|
||||
const c = ctx()
|
||||
const collaboration = await collab(c)
|
||||
const scope = { table, pk }
|
||||
@@ -81,18 +83,30 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
|
||||
else process.stdout.write(`${hhmmss(new Date())} ${presenceLine(state)}${state.live ? '' : ' (polling)'}\n`)
|
||||
}
|
||||
if (!options.watch && !options.observe) {
|
||||
const state = await collaboration.presenceSnapshot(scope)
|
||||
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '' }))
|
||||
c.print({ data: { table, pk: pk ?? null, count: state.count, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field'], footer: countFooter(state.count, 'person', [`on ${pk !== undefined ? `${table} ${pk}` : table}`, '--watch to join and follow'], 'people') })
|
||||
const state = await collaboration.presenceSnapshot(scope, { perConnection: options.perConnection })
|
||||
const connections = state.connections ?? state.count
|
||||
const where = `on ${pk !== undefined ? `${table} ${pk}` : table}`
|
||||
const notes = [where, ...(connections > state.count ? [`${connections} connections`] : []), '--watch to join and follow']
|
||||
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', connections: p.connections ?? 1 }))
|
||||
if (options.perConnection) {
|
||||
// older servers send no per-connection detail: one row per person, connection unknown
|
||||
const leaseRows = state.people.flatMap((p) => p.leases?.length
|
||||
? p.leases.map((l) => ({ name: p.name, user: p.userId, mode: l.mode, field: l.fieldName ?? '', since: l.since ? hhmmss(l.since) : '', connection: l.connectionId }))
|
||||
: [{ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', since: '', connection: '' }])
|
||||
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows, leases: leaseRows }, rows: leaseRows, columns: ['name', 'user', 'mode', 'field', 'since', 'connection'], footer: countFooter(state.count, 'person', notes, 'people') })
|
||||
return
|
||||
}
|
||||
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field', 'connections'], footer: countFooter(state.count, 'person', notes, 'people') })
|
||||
return
|
||||
}
|
||||
let last = ''
|
||||
const handle = await collaboration.joinPresence(scope, {
|
||||
announce: Boolean(options.watch),
|
||||
perConnection: options.perConnection,
|
||||
mode: options.editing ? 'EDITING' : 'VIEWING',
|
||||
fieldName: typeof options.editing === 'string' ? options.editing : undefined,
|
||||
onChange: (state) => {
|
||||
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName]), state.error, state.live])
|
||||
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName, p.connections, p.leases?.map((l) => [l.connectionId, l.mode, l.fieldName])]), state.error, state.live])
|
||||
if (key !== last) {
|
||||
last = key
|
||||
emit(state)
|
||||
|
||||
@@ -41,4 +41,5 @@ test('presence lines say who is there and since when', () => {
|
||||
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 0, people: [], live: true }), 'nobody on person 1')
|
||||
assert.equal(presenceLine({ available: true, scope: { table: 'person' }, count: 2, people: [{ userId: 'a', name: 'Alice', mode: 'VIEWING', since }, { userId: 'b', name: 'Bob', mode: 'EDITING', fieldName: 'email', since }], live: true }), '2 on person: Alice (viewing since 17:50:01), Bob (editing email since 17:50:01)')
|
||||
assert.match(presenceLine({ available: false, scope: { table: 'person' }, count: 0, people: [], live: false }), /not installed/)
|
||||
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 1, connections: 2, people: [{ userId: 'admin', name: 'Admin', mode: 'VIEWING', since, connections: 2 }], live: true }), '1 on person 1 (2 connections): Admin ×2 (viewing since 17:50:01)')
|
||||
})
|
||||
@@ -4,7 +4,7 @@ esb qqq esb 1.1.0 ok 1.1.0 1.1.0 5
|
||||
health qqq health not installed 1.1.0 1.1.0 4
|
||||
alerts qqq alerts not installed 1.2.0 6
|
||||
assistant qqq assistant not installed 1.6.0 16
|
||||
collaboration qqq collab not installed 1.1.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
|
||||
collaboration qqq collab not installed 1.2.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
|
||||
data-analysis qqq data-analysis not installed 1.5.0 21
|
||||
db-performance qqq db-performance not installed 1.0.0 4
|
||||
edi qqq edi not installed 1.1.0 4
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* `qqq collab presence` against a stub: the connections column for two terminals of one user
|
||||
* (collaboration 1.2.0), `--per-connection` rows, and a 1.1.0 server that sends no connections.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { after, before, describe, test } from 'node:test'
|
||||
import { runCli, sandbox } from './harness.js'
|
||||
import { startStub, type Stub } from './stub.js'
|
||||
|
||||
describe('qqq collab presence against a stub', () => {
|
||||
let stub: Stub
|
||||
let box: ReturnType<typeof sandbox>
|
||||
let version = '1.2.0'
|
||||
const qqq = (args: string[]) => runCli(['--server', stub.url, ...args], { QQQ_TOKEN: 't', ...box.env })
|
||||
const admin = { userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }
|
||||
before(async () => {
|
||||
box = sandbox()
|
||||
stub = await startStub([
|
||||
[/^GET \/version$/, () => ({ apiVersion: '1.8.0', supportedMajorVersions: [1], features: [], extensions: [{ name: 'collaboration', version }] })],
|
||||
[/^GET \/qbit\/collaboration\/presence$/, (r) => {
|
||||
if (version === '1.1.0') return { table: 'person', viewers: [admin, { userId: 'bob', displayName: 'Bob', mode: 'EDITING' }] }
|
||||
const leases = [
|
||||
{ ...admin, connections: 1, connectionId: 'c0000000000000001', sessionId: 's1', since: '2026-11-01T12:00:00Z' },
|
||||
{ ...admin, mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'c0000000000000002', sessionId: 's2', since: '2026-11-01T12:00:05Z' },
|
||||
]
|
||||
return { table: 'person', viewers: [{ ...admin, mode: 'EDITING', connections: 2 }], leaseCount: 2, ...(r.query.get('perConnection') === 'true' ? { leases } : {}) }
|
||||
}],
|
||||
])
|
||||
})
|
||||
after(async () => {
|
||||
await stub.close()
|
||||
box.dispose()
|
||||
})
|
||||
|
||||
test('two terminals of one user: one person with 2 connections', async () => {
|
||||
version = '1.2.0'
|
||||
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
|
||||
assert.equal(r.code, 0, r.stderr)
|
||||
assert.match(r.stdout, /^name,user,mode,field,connections\r?\nAdmin,admin,EDITING,,2/m)
|
||||
const json = JSON.parse((await qqq(['collab', 'presence', 'person', '-O', 'json'])).stdout)
|
||||
assert.deepEqual([json.count, json.connections, json.viewers[0].connections], [1, 2, 2])
|
||||
assert.equal(stub.requests.filter((q) => q.path === '/qbit/collaboration/presence').at(-1)!.query.get('perConnection'), null)
|
||||
})
|
||||
|
||||
test('--per-connection lists each terminal with its mode, field and start', async () => {
|
||||
version = '1.2.0'
|
||||
const r = await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])
|
||||
assert.equal(r.code, 0, r.stderr)
|
||||
const json = JSON.parse(r.stdout)
|
||||
assert.deepEqual(json.leases.map((l: { mode: string; field: string; connection: string }) => [l.mode, l.field, l.connection]), [['VIEWING', '', 'c0000000000000001'], ['EDITING', 'name', 'c0000000000000002']])
|
||||
assert.equal(stub.requests.at(-1)!.query.get('perConnection'), 'true')
|
||||
})
|
||||
|
||||
test('a collaboration 1.1.0 server without connections: each person counts once', async () => {
|
||||
version = '1.1.0'
|
||||
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
|
||||
assert.equal(r.code, 0, r.stderr)
|
||||
assert.match(r.stdout, /Admin,admin,VIEWING,,1\r?\nBob,bob,EDITING,,1/)
|
||||
const per = JSON.parse((await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])).stdout)
|
||||
assert.deepEqual([per.count, per.connections, per.leases.length], [2, 2, 2])
|
||||
})
|
||||
})
|
||||
@@ -34,7 +34,7 @@ export const DOCUMENT_EXTENSIONS: DocumentExtension[] = [
|
||||
{"name":"health","version":"1.1.0","pathPrefix":"/health","description":"Central health summary, components, history and on-demand checks."},
|
||||
{"name":"alerts","version":"1.2.0","pathPrefix":"/alerts","description":"Alerts inbox and record panel"},
|
||||
{"name":"assistant","version":"1.6.0","pathPrefix":"/assistant","description":"QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations"},
|
||||
{"name":"collaboration","version":"1.1.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
|
||||
{"name":"collaboration","version":"1.2.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
|
||||
{"name":"data-analysis","version":"1.5.0","pathPrefix":"/qbit/data-analysis","description":"Profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs and anomaly watch previews as the signed-in user."},
|
||||
{"name":"db-performance","version":"1.0.0","pathPrefix":"/qbit/db-performance","description":"Database performance, live activity and audited query plans"},
|
||||
{"name":"edi","version":"1.1.0","pathPrefix":"/qbit/edi","description":"EDI sandbox and mapper, using the signed-in user's permissions."},
|
||||
@@ -111,7 +111,7 @@ export const OPERATIONS: OperationInfo[] = [
|
||||
{"id":"getQbitCollaborationMyWork","method":"GET","path":"/qqq/v1/qbit/collaboration/my-work","summary":"Get my work summary","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationPeople","method":"GET","path":"/qqq/v1/qbit/collaboration/people","summary":"Search the active people directory","tag":"collaboration","pathParams":[],"queryParams":["q","limit"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationPeopleByUserId","method":"GET","path":"/qqq/v1/qbit/collaboration/people/{userId}","summary":"Get an active directory person","tag":"collaboration","pathParams":["userId"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk","perConnection"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationRecents","method":"GET","path":"/qqq/v1/qbit/collaboration/recents","summary":"List recent targets","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationRecordsByTableByPkAttachments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments","summary":"List record attachments","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationRecordsByTableByPkComments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/comments","summary":"Page record comment threads","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
|
||||
|
||||
@@ -43,6 +43,7 @@ const presence = await collab.joinPresence({ table: 'person', pk: '1' }, {
|
||||
announce: true, // false: watch without appearing
|
||||
})
|
||||
presence.count; presence.people; presence.state.live; presence.state.error
|
||||
presence.state.connections // two terminals of one user count twice (collaboration 1.2.0)
|
||||
await presence.stop() // DELETE /presence, stop the heartbeat, close the stream
|
||||
```
|
||||
|
||||
@@ -55,6 +56,12 @@ await presence.stop() // DELETE /presence, stop the heartbeat, close the s
|
||||
- `since` is when this client first saw the person; the qbit does not send join times.
|
||||
- The list includes the signed-in user when they hold a lease (tabs and clients merge per user;
|
||||
EDITING wins).
|
||||
- Collaboration 1.2.0: each person has `connections` (their live tabs and terminals) and
|
||||
`state.connections` totals them. `perConnection: true` (option of `joinPresence` and
|
||||
`presenceSnapshot`) also fills `person.leases`: each connection's mode, field, start and opaque
|
||||
ids, this client's own marked `self` (the server's id is `c` + 16 hex digits of SHA-256 of
|
||||
user id, newline, connection id; see `opaqueConnectionId`). Older servers send neither: each
|
||||
person is then one connection and `leases` stays unset.
|
||||
- One stream per joinPresence. A TUI that already holds a stream for other channels can pass an
|
||||
`openStream` that fans out its own connection's events instead of opening another.
|
||||
- `state.error` holds a refused heartbeat (404 when presence is off for the table or the record is
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* there) and the other calls throw ExtensionNotInstalledError.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
|
||||
import type { CallInput, CallResult } from '../api/call.js'
|
||||
import type { ExtensionRegistry } from './registry.js'
|
||||
@@ -104,6 +105,20 @@ export interface PresenceScope {
|
||||
pk?: string
|
||||
}
|
||||
|
||||
/** One live connection (a tab or terminal) of a person, from collaboration 1.2.0's per-connection presence. */
|
||||
export interface PresenceConnection {
|
||||
/** Opaque per-connection id from the server (never the client's own connection id). */
|
||||
connectionId: string
|
||||
mode: 'VIEWING' | 'EDITING' | string
|
||||
fieldName?: string | null
|
||||
/** When the lease started, as the server reports it. */
|
||||
since?: Date
|
||||
/** Opaque id of the signed-in session behind it (never the session itself). */
|
||||
sessionId?: string
|
||||
/** True for this client's own connection. */
|
||||
self?: boolean
|
||||
}
|
||||
|
||||
/** One person on the scope, merged across their tabs. */
|
||||
export interface PresencePerson {
|
||||
userId: string
|
||||
@@ -112,6 +127,10 @@ export interface PresencePerson {
|
||||
fieldName?: string | null
|
||||
/** When this client first saw them there (the qbit does not send join times). */
|
||||
since: Date
|
||||
/** Live connections (tabs, terminals) merged into this person; 1 from servers before collaboration 1.2.0 (always set by this module). */
|
||||
connections?: number
|
||||
/** Each connection, when the server sent per-connection presence (perConnection, or any 1.2.0 event). */
|
||||
leases?: PresenceConnection[]
|
||||
}
|
||||
|
||||
export interface PresenceState {
|
||||
@@ -120,6 +139,8 @@ export interface PresenceState {
|
||||
scope: PresenceScope
|
||||
count: number
|
||||
people: PresencePerson[]
|
||||
/** Live connections behind `people` (two terminals of one user count twice); equals `count` on older servers (always set by this module). */
|
||||
connections?: number
|
||||
/** True while the realtime stream is connected and subscribed (else presence is polled). */
|
||||
live: boolean
|
||||
/** The last error (presence not enabled on the table, record not visible, ...), cleared on success. */
|
||||
@@ -140,6 +161,8 @@ export interface PresenceOptions {
|
||||
announce?: boolean
|
||||
/** Delay before reconnecting a dropped stream; default 2000 ms, doubling to 30 s. */
|
||||
reconnectMs?: number
|
||||
/** Ask for each connection's mode, field and start (collaboration 1.2.0; ignored by older servers). */
|
||||
perConnection?: boolean
|
||||
}
|
||||
|
||||
export interface PresenceHandle {
|
||||
@@ -159,12 +182,64 @@ interface Viewer {
|
||||
displayName?: string
|
||||
mode?: string
|
||||
fieldName?: string | null
|
||||
connections?: number
|
||||
connectionId?: string
|
||||
sessionId?: string
|
||||
since?: string
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
table?: string
|
||||
pk?: string | null
|
||||
viewers?: Viewer[]
|
||||
leaseCount?: number
|
||||
leases?: Viewer[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The opaque id the server gives a connection (collaboration 1.2.0): `c` and the first 16 hex
|
||||
* digits of SHA-256 of the user id, a newline and the client's connection id.
|
||||
*/
|
||||
export function opaqueConnectionId(userId: string, connectionId: string): string {
|
||||
return `c${createHash('sha256').update(`${userId}\n${connectionId}`).digest('hex').slice(0, 16)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* People and connection totals from a snapshot or event. Servers before collaboration 1.2.0 send
|
||||
* neither `connections` nor `leases`: each person then counts as one connection.
|
||||
*/
|
||||
export function presencePeople(snapshot: Snapshot, firstSeen: (userId: string) => Date, ownConnectionId?: string): { people: PresencePerson[]; connections: number } {
|
||||
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
|
||||
const leases = new Map<string, PresenceConnection[]>()
|
||||
for (const l of snapshot.leases ?? []) {
|
||||
if (l.userId === undefined || !l.connectionId) continue
|
||||
const userId = String(l.userId)
|
||||
const since = l.since ? new Date(l.since) : undefined
|
||||
const entry: PresenceConnection = {
|
||||
connectionId: l.connectionId, mode: l.mode ?? 'VIEWING', fieldName: l.fieldName ?? null,
|
||||
...(since && !Number.isNaN(since.getTime()) ? { since } : {}), ...(l.sessionId ? { sessionId: l.sessionId } : {}),
|
||||
...(ownConnectionId && l.connectionId === opaqueConnectionId(userId, ownConnectionId) ? { self: true } : {}),
|
||||
}
|
||||
leases.set(userId, [...(leases.get(userId) ?? []), entry])
|
||||
}
|
||||
const people = viewers.map((v) => {
|
||||
const userId = String(v.userId)
|
||||
const own = leases.get(userId)
|
||||
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : own?.length || 1
|
||||
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen(userId), connections, ...(own ? { leases: own } : {}) }
|
||||
})
|
||||
const sum = people.reduce((n, p) => n + p.connections, 0)
|
||||
return { people, connections: typeof snapshot.leaseCount === 'number' ? Math.max(snapshot.leaseCount, people.length) : sum }
|
||||
}
|
||||
|
||||
/** ` ×2` after a person with more than one connection, else nothing. */
|
||||
export function connectionsSuffix(p: { connections?: number }): string {
|
||||
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
|
||||
}
|
||||
|
||||
/** GET /presence query for a scope; `perConnection` only when asked (older servers ignore it). */
|
||||
function presenceQuery(scope: PresenceScope, perConnection?: boolean): Record<string, string | undefined> {
|
||||
return { table: scope.table, pk: scope.pk, ...(perConnection ? { perConnection: 'true' } : {}) }
|
||||
}
|
||||
|
||||
/** The channel presence events for a scope arrive on. */
|
||||
@@ -281,13 +356,15 @@ export class Collaboration {
|
||||
}
|
||||
|
||||
/** Who is on a table or record right now, without joining. Empty when the extension is absent. */
|
||||
async presenceSnapshot(scope: PresenceScope): Promise<PresenceState> {
|
||||
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
|
||||
async presenceSnapshot(scope: PresenceScope, options: { perConnection?: boolean } = {}): Promise<PresenceState> {
|
||||
const state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, live: false }
|
||||
if (!state.available) return state
|
||||
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot
|
||||
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).data as Snapshot
|
||||
const now = new Date()
|
||||
state.people = (snapshot.viewers ?? []).map((v) => ({ userId: String(v.userId ?? ''), name: String(v.displayName ?? v.userId ?? ''), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: now }))
|
||||
state.count = state.people.length
|
||||
const { people, connections } = presencePeople(snapshot, () => now)
|
||||
state.people = people
|
||||
state.count = people.length
|
||||
state.connections = connections
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -297,7 +374,7 @@ export class Collaboration {
|
||||
* at once with a no-op handle when the extension is absent.
|
||||
*/
|
||||
async joinPresence(scope: PresenceScope, options: PresenceOptions = {}): Promise<PresenceHandle> {
|
||||
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
|
||||
const state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, live: false }
|
||||
const notify = () => options.onChange?.({ ...state, people: [...state.people] })
|
||||
if (!state.available) {
|
||||
return { state, get count() { return 0 }, get people() { return [] }, refresh: async () => {}, setMode: async () => {}, stop: async () => {} }
|
||||
@@ -313,22 +390,22 @@ export class Collaboration {
|
||||
|
||||
const apply = (snapshot: Snapshot) => {
|
||||
const now = new Date()
|
||||
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
|
||||
const ids = new Set(viewers.map((v) => String(v.userId)))
|
||||
const ids = new Set((snapshot.viewers ?? []).filter((v) => v.userId !== undefined).map((v) => String(v.userId)))
|
||||
for (const id of [...firstSeen.keys()]) if (!ids.has(id)) firstSeen.delete(id)
|
||||
state.people = viewers.map((v) => {
|
||||
const userId = String(v.userId)
|
||||
const { people, connections } = presencePeople(snapshot, (userId) => {
|
||||
if (!firstSeen.has(userId)) firstSeen.set(userId, now)
|
||||
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen.get(userId)! }
|
||||
})
|
||||
state.count = state.people.length
|
||||
return firstSeen.get(userId)!
|
||||
}, announce ? connectionId : undefined)
|
||||
state.people = people
|
||||
state.count = people.length
|
||||
state.connections = connections
|
||||
state.error = undefined
|
||||
notify()
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
apply((await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot)
|
||||
apply((await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).data as Snapshot)
|
||||
} catch (error) {
|
||||
state.error = messageOf(error)
|
||||
notify()
|
||||
@@ -341,8 +418,9 @@ export class Collaboration {
|
||||
try {
|
||||
const result = await this.transport.call('postQbitCollaborationPresence', { json: { connectionId, table: scope.table, pk: scope.pk, mode, fieldName } })
|
||||
const data = result.data as Snapshot | undefined
|
||||
// The qbit answers the heartbeat with the snapshot; older builds answer {success}.
|
||||
if (data && typeof data.table === 'string') {
|
||||
// The qbit answers the heartbeat with the merged snapshot; older builds answer {success}.
|
||||
// Per-connection detail only comes with GET, so re-read rather than drop it.
|
||||
if (data && typeof data.table === 'string' && !options.perConnection) {
|
||||
apply(data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
|
||||
import type { CallInput, CallResult } from '../api/call.js'
|
||||
import { Collaboration, presenceChannel, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
|
||||
import { Collaboration, connectionsSuffix, opaqueConnectionId, presenceChannel, presencePeople, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
|
||||
import { ExtensionNotInstalledError, ExtensionRegistry } from '../extensions/registry.js'
|
||||
|
||||
const withCollab = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.1.0' }] })
|
||||
@@ -164,3 +164,65 @@ test('tags: vocabulary search, create, record tags and apply call the qbit opera
|
||||
assert.deepEqual(calls[1]!.input.json, { table: 'person', label: 'New one', color: 'teal' })
|
||||
assert.deepEqual(calls[3]!.input, { path: { table: 'person', pk: '3' }, json: { add: [9] } })
|
||||
})
|
||||
|
||||
test('two connections of one user: one person with connections 2, and per connection each mode, field and start', async () => {
|
||||
const leases = [
|
||||
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-mine'), sessionId: 's1', since: '2026-11-01T12:00:00Z' },
|
||||
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-other'), sessionId: 's2', since: '2026-11-01T12:00:05Z' },
|
||||
]
|
||||
const { calls, transport } = fake((id, input) => {
|
||||
if (id === 'deleteQbitCollaborationPresence') return { success: true }
|
||||
const per = (input.query as Record<string, string | undefined> | undefined)?.perConnection === 'true'
|
||||
return { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 2 }], leaseCount: 2, ...(per ? { leases } : {}) }
|
||||
})
|
||||
const collab = new Collaboration(withCollab, transport)
|
||||
const merged = await collab.presenceSnapshot({ table: 'person', pk: '1' })
|
||||
assert.deepEqual([merged.count, merged.connections, merged.people[0]!.connections, merged.people[0]!.leases], [1, 2, 2, undefined])
|
||||
assert.equal(connectionsSuffix(merged.people[0]!), ' ×2')
|
||||
assert.equal((calls.at(-1)!.input.query as Record<string, unknown>).perConnection, undefined, 'merged by default')
|
||||
|
||||
const handle = await collab.joinPresence({ table: 'person', pk: '1' }, { perConnection: true, connectionId: 'cli-mine', heartbeatMs: 10000 })
|
||||
try {
|
||||
const admin = handle.people[0]!
|
||||
assert.equal(admin.connections, 2)
|
||||
assert.deepEqual(admin.leases!.map((l) => [l.mode, l.fieldName, l.since?.toISOString(), l.self ?? false]), [
|
||||
['VIEWING', null, '2026-11-01T12:00:00.000Z', true],
|
||||
['EDITING', 'name', '2026-11-01T12:00:05.000Z', false],
|
||||
])
|
||||
assert.deepEqual(calls.filter((c) => c.id === 'getQbitCollaborationPresence').at(-1)!.input.query, { table: 'person', pk: '1', perConnection: 'true' })
|
||||
} finally {
|
||||
await handle.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('servers before collaboration 1.2.0 send no connections: each person is one connection', async () => {
|
||||
const { transport } = fake(() => ({ table: 'person', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'bob', displayName: 'Bob' }] }))
|
||||
const state = await new Collaboration(withCollab, transport).presenceSnapshot({ table: 'person' }, { perConnection: true })
|
||||
assert.deepEqual([state.count, state.connections], [2, 2])
|
||||
assert.deepEqual(state.people.map((p) => [p.connections, p.leases, connectionsSuffix(p)]), [[1, undefined, ''], [1, undefined, '']])
|
||||
const fromEvent = presencePeople({ table: 'person', viewers: [{ userId: 'x', displayName: 'X', connections: 3 }], leaseCount: 3 }, () => new Date(0))
|
||||
assert.deepEqual([fromEvent.connections, fromEvent.people[0]!.connections], [3, 3])
|
||||
})
|
||||
|
||||
test('presence.changed with leases updates connections live', async () => {
|
||||
const events: RealtimeMessage[] = [
|
||||
{ event: 'hello', data: { connectionId: 'sse-1' } },
|
||||
{ event: 'presence.changed', data: { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }], leaseCount: 2,
|
||||
leases: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c1', since: '2026-11-01T12:00:00Z' }, { userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c2', since: '2026-11-01T12:00:01Z' }] } },
|
||||
]
|
||||
async function* stream(signal: AbortSignal): AsyncIterable<RealtimeMessage> {
|
||||
let next = 0
|
||||
while (!signal.aborted) {
|
||||
await tick(2)
|
||||
if (next < events.length) yield events[next++]!
|
||||
}
|
||||
}
|
||||
const { transport } = fake((id, input) => (id === 'updateRealtimeSubscriptions' ? { active: (input.json as { add: string[] }).add } : { table: 'person', pk: '1', viewers: [] }), stream)
|
||||
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'person', pk: '1' }, { announce: false, heartbeatMs: 10000 })
|
||||
try {
|
||||
await until('the event with two connections', () => handle.state.connections === 2)
|
||||
assert.equal(handle.people[0]!.leases?.length, 2)
|
||||
} finally {
|
||||
await handle.stop()
|
||||
}
|
||||
})
|
||||
@@ -210,10 +210,10 @@ export class RecordCollaboration {
|
||||
}
|
||||
|
||||
/** Who is on the record (the actions entry): everyone the server reports, this user marked "(you)". */
|
||||
export function viewingText(everyone: Array<{ name: string; self?: boolean }> | undefined): string {
|
||||
export function viewingText(everyone: Array<{ name: string; self?: boolean; connections?: number }> | undefined): string {
|
||||
if (!everyone) return 'Viewing now: …'
|
||||
if (!everyone.length) return 'Viewing now: no one else'
|
||||
const names = everyone.map((p) => (p.self ? `${p.name} (you)` : p.name))
|
||||
const names = everyone.map((p) => `${p.name}${p.connections && p.connections > 1 ? ` ×${p.connections}` : ''}${p.self ? ' (you)' : ''}`)
|
||||
const others = everyone.filter((p) => !p.self).length
|
||||
return `Viewing now: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}${others ? ` · ${others} other${others === 1 ? '' : 's'}` : ''}`
|
||||
}
|
||||
|
||||
@@ -20,6 +20,24 @@ export interface PresencePerson {
|
||||
/** VIEWING or EDITING (the server's mode), and the field being edited when it says. */
|
||||
mode?: string
|
||||
fieldName?: string | null
|
||||
/** Live connections (tabs, terminals) of this person; absent or 1 before collaboration 1.2.0. */
|
||||
connections?: number
|
||||
/** Each connection's mode, field and start when the server sends them (collaboration 1.2.0). */
|
||||
terminals?: PresenceTerminal[]
|
||||
}
|
||||
|
||||
/** One connection of a person: what that tab or terminal does. */
|
||||
export interface PresenceTerminal {
|
||||
mode?: string
|
||||
fieldName?: string | null
|
||||
since?: string
|
||||
/** This terminal. */
|
||||
self?: boolean
|
||||
}
|
||||
|
||||
/** ` ×2` after a person with more than one connection. */
|
||||
export function timesText(p: { connections?: number }): string {
|
||||
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
|
||||
}
|
||||
|
||||
export interface Presence {
|
||||
@@ -64,7 +82,7 @@ export function usePresence(scope: string | undefined): Presence {
|
||||
/** Title-bar text for presence, empty when nobody else is there. */
|
||||
export function presenceText(p: Presence): string {
|
||||
if (!p.count) return ''
|
||||
const names = p.people.slice(0, 3).map((x) => x.name).join(', ')
|
||||
const names = p.people.slice(0, 3).map((x) => `${x.name}${timesText(x)}`).join(', ')
|
||||
return `${p.count} other${p.count === 1 ? '' : 's'} viewing${names ? `: ${names}${p.count > 3 ? ', …' : ''}` : ''}`
|
||||
}
|
||||
|
||||
@@ -94,8 +112,13 @@ export function collaborationPresence(registry: ExtensionRegistry, client: TuiCl
|
||||
const entry: { scope: string; handle?: PresenceHandle; value: Presence } = { scope, value: NONE }
|
||||
current = entry
|
||||
collab.joinPresence(kind === 'record' ? { table, pk: pk.join(':') } : { table }, {
|
||||
perConnection: true,
|
||||
onChange: (state) => {
|
||||
const everyone = state.people.map((p) => ({ name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName }))
|
||||
const everyone = state.people.map((p) => ({
|
||||
name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName,
|
||||
...(p.connections && p.connections > 1 ? { connections: p.connections } : {}),
|
||||
...(p.leases?.length ? { terminals: p.leases.map((l) => ({ mode: l.mode, fieldName: l.fieldName, since: l.since?.toISOString(), ...(l.self ? { self: true } : {}) })) } : {}),
|
||||
}))
|
||||
const people = everyone.filter((p) => !p.self).map(({ self: _self, ...person }) => person)
|
||||
entry.value = { count: people.length, people, everyone }
|
||||
notify()
|
||||
|
||||
@@ -51,6 +51,8 @@ interface Viewer {
|
||||
userId: string
|
||||
displayName: string
|
||||
mode?: string
|
||||
/** Live connections (tabs, terminals) of this user on the table; absent before collaboration 1.2.0. */
|
||||
connections?: number
|
||||
}
|
||||
|
||||
/** Sessions that count as online: active in the last 10 minutes. */
|
||||
@@ -64,11 +66,13 @@ function userLabel(userId: string): string {
|
||||
* Who is online: signed-in sessions from the session store's table when the server has one and it
|
||||
* holds sessions active in the last 10 minutes (own sessions count: "2 sessions · 1 user (you ×2)"),
|
||||
* else this terminal plus everyone seen in collaboration presence across a few tables per refresh
|
||||
* (the saver keeps its lease, so the signed-in viewer is always one of them). Never "nobody around"
|
||||
* while the viewer is signed in.
|
||||
* (the saver keeps its lease, so the signed-in viewer is always one of them). Collaboration 1.2.0
|
||||
* counts connections, so two terminals of one user are "2 online · you ×2"; older servers count each
|
||||
* person once. Never "nobody around" while the viewer is signed in.
|
||||
*/
|
||||
export function presenceSource(ctx: SaverContext): TileSource {
|
||||
const seen = new Map<string, { name: string; table: string; mode?: string; at: number }>()
|
||||
// keyed by user and table: a user's terminals on different tables add up
|
||||
const seen = new Map<string, { userId: string; name: string; table: string; mode?: string; connections?: number; self: boolean; at: number }>()
|
||||
const skip = new Set<string>()
|
||||
let cursor = 0
|
||||
let sessionsGone = false
|
||||
@@ -130,10 +134,9 @@ export function presenceSource(ctx: SaverContext): TileSource {
|
||||
for (const [id, v] of seen) if (v.table === table) seen.delete(id)
|
||||
for (const v of snap.viewers ?? []) {
|
||||
const you = v.userId === self.userId || (Boolean(self.user) && v.displayName === self.user)
|
||||
// this terminal is you (it keeps its lease while the saver shows): not a second person
|
||||
if (you) continue
|
||||
names.set(v.userId, v.displayName || v.userId)
|
||||
seen.set(v.userId, { name: v.displayName || v.userId, table, mode: v.mode, at: t })
|
||||
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : undefined
|
||||
if (!you) names.set(v.userId, v.displayName || v.userId)
|
||||
seen.set(`${v.userId}\u0000${table}`, { userId: v.userId, name: v.displayName || v.userId, table, mode: v.mode, connections, self: you, at: t })
|
||||
}
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status
|
||||
@@ -146,9 +149,21 @@ export function presenceSource(ctx: SaverContext): TileSource {
|
||||
const ttl = Math.ceil(Math.max(1, all.length) / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
|
||||
const t = now()
|
||||
for (const [id, v] of seen) if (t - v.at > ttl) seen.delete(id)
|
||||
const others = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
const total = others.length + (signedIn ? 1 : 0)
|
||||
const parts = [...(signedIn ? ['you'] : []), ...others.map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
|
||||
// one entry per person, their connections summed over the tables they are on; a server
|
||||
// before collaboration 1.2.0 sends no connections, and then each person counts once
|
||||
const people = new Map<string, { name: string; table: string; mode?: string; connections: number; counted: boolean; self: boolean }>()
|
||||
for (const v of seen.values()) {
|
||||
const p = people.get(v.userId)
|
||||
if (!p) people.set(v.userId, { name: v.name, table: v.table, mode: v.mode, connections: v.connections ?? 1, counted: v.connections !== undefined, self: v.self })
|
||||
else if (p.counted && v.connections !== undefined) p.connections += v.connections
|
||||
else p.connections = 1, p.counted = false
|
||||
}
|
||||
const others = [...people.values()].filter((p) => !p.self).sort((a, b) => a.name.localeCompare(b.name))
|
||||
// this terminal is you (it keeps its lease while the saver shows); your other terminals count too
|
||||
const yours = signedIn ? Math.max(1, [...people.values()].filter((p) => p.self).reduce((n, p) => n + p.connections, 0)) : 0
|
||||
const times = (n: number) => (n > 1 ? ` ×${n}` : '')
|
||||
const total = others.reduce((n, p) => n + p.connections, 0) + yours
|
||||
const parts = [...(signedIn ? [`you${times(yours)}`] : []), ...others.map((p) => `${p.name}${times(p.connections)}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
|
||||
const detail = parts.length ? `${total} online · ${parts.join(', ')}` : 'nobody around'
|
||||
return { value: String(total), detail, big: String(total), interest: total ? 50 + total : 5 }
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import type { TagEntry } from '@qrun-io/qqq-core'
|
||||
import type { CollabSummary } from '../collab'
|
||||
import type { PresencePerson } from '../presence'
|
||||
import { timesText, type PresencePerson } from '../presence'
|
||||
import { ago } from '../servers'
|
||||
import { KEY_SEP, cellWidth, pad, trunc } from '../util/text'
|
||||
|
||||
@@ -77,7 +77,7 @@ export function tagsText(tags: TagEntry[] | undefined): string {
|
||||
}
|
||||
|
||||
/** What a person is doing on the record: "editing email", "editing", "viewing". */
|
||||
export function personState(p: PresencePerson): string {
|
||||
export function personState(p: Pick<PresencePerson, 'mode' | 'fieldName'>): string {
|
||||
return /edit/i.test(p.mode ?? '') ? `editing${p.fieldName ? ` ${p.fieldName}` : ''}` : 'viewing'
|
||||
}
|
||||
|
||||
@@ -92,11 +92,11 @@ function viewingParts(state: CollabState): StripPart[] {
|
||||
const others = othersOf(state)
|
||||
// others (with what they do) take the place of "(you)": the strip is about who else is here
|
||||
if (others.length) {
|
||||
const named = others.slice(0, 2).map((p) => `${p.name} (${personState(p)})`).join(', ')
|
||||
const named = others.slice(0, 2).map((p) => `${p.name}${timesText(p)} (${personState(p)})`).join(', ')
|
||||
return [{ text: 'viewing: ' }, { text: named + (others.length > 2 ? ` +${others.length - 2}` : ''), em: true }]
|
||||
}
|
||||
const self = everyone.find((p) => p.self)
|
||||
return [{ text: 'viewing: ' }, { text: self ? self.name : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
|
||||
return [{ text: 'viewing: ' }, { text: self ? `${self.name}${timesText(self)}` : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
|
||||
}
|
||||
|
||||
const count = (n: number | undefined, one: string, many = `${one}s`): StripPart[] => [{ text: n === undefined ? '…' : String(n), em: true }, { text: ` ${n === 1 ? one : many}` }]
|
||||
@@ -193,10 +193,21 @@ export function panelRows(state: CollabState, width: number, height: number, now
|
||||
else if (!everyone.length) add(' no one else', 'dim', 'viewing')
|
||||
// others first, with what they are doing; this user last
|
||||
const people: Array<PresencePerson & { self?: boolean }> = [...othersOf(state), ...(everyone ?? []).filter((x) => x.self)]
|
||||
for (const p of people) add(` ${p.name}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
|
||||
for (const p of people) {
|
||||
add(` ${p.name}${timesText(p)}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
|
||||
// with more than one terminal, what each does (this one first)
|
||||
const terminals = p.terminals && p.terminals.length > 1 ? [...p.terminals].sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self))) : []
|
||||
terminals.forEach((x, i) => add(` ${x.self ? 'this terminal' : `#${i + 1}`} · ${personState(x)}${x.since ? ` · ${hhmm(x.since)}` : ''}`, 'dim', 'viewing'))
|
||||
}
|
||||
return rows.slice(0, Math.max(0, height))
|
||||
}
|
||||
|
||||
/** A lease start as local "17:50". */
|
||||
function hhmm(iso: string): string {
|
||||
const at = new Date(iso)
|
||||
return Number.isNaN(at.getTime()) ? iso : at.toTimeString().slice(0, 5)
|
||||
}
|
||||
|
||||
/** A due date as "3 Nov" (and the year when it is not this one). */
|
||||
function dueText(iso: string): string {
|
||||
const at = new Date(iso)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { actionsOverlay } from '../overlays/actions'
|
||||
import { TagOverlay, collaborationOf } from '../overlays/tags'
|
||||
import { RecordCollaboration, uploadBlocked, viewingText, type CollabSummary } from '../collab'
|
||||
import { CollabScreen, sharePreview, toggleFollow, uploadAttachment, type CollabView } from './collab'
|
||||
import { presenceSource } from '../presence'
|
||||
import { presenceSource, timesText } from '../presence'
|
||||
import { canPrintTable, printScreen } from '../printing'
|
||||
import { AuditScreen } from './audits'
|
||||
import { DeveloperScreen } from './developer'
|
||||
@@ -303,7 +303,9 @@ export class RecordScreen implements Screen {
|
||||
/** Who else is on the record, in a dialog. */
|
||||
showViewers(): void {
|
||||
const viewers = this.viewers()
|
||||
this.store.message(viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`, 'info', 'Presence')
|
||||
const self = presenceSource().get(this.presenceScope()).everyone?.find((p) => p.self)
|
||||
const yours = self?.connections && self.connections > 1 ? `\n\nYou have it open in ${self.connections} terminals.` : ''
|
||||
this.store.message((viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`) + yours, 'info', 'Presence')
|
||||
}
|
||||
|
||||
build(): void {
|
||||
@@ -644,7 +646,7 @@ export class RecordScreen implements Screen {
|
||||
|
||||
/** The names of the others viewing this record (presence; this client is not counted). */
|
||||
viewers(): string[] {
|
||||
return presenceSource().get(this.presenceScope()).people.map((p) => p.name)
|
||||
return presenceSource().get(this.presenceScope()).people.map((p) => `${p.name}${timesText(p)}`)
|
||||
}
|
||||
|
||||
/** Opens comments, attachments, the timeline or tasks. */
|
||||
|
||||
@@ -243,6 +243,7 @@ test('collaboration: who is viewing (this user marked) and comment lines', () =>
|
||||
expect(viewingText([{ name: 'Admin', self: true }])).toBe('Viewing now: Admin (you)')
|
||||
expect(viewingText([{ name: 'Tester', self: true }, { name: 'Pat Smith' }])).toBe('Viewing now: Tester (you), Pat Smith · 1 other')
|
||||
expect(viewingText(undefined)).toBe('Viewing now: …')
|
||||
expect(viewingText([{ name: 'Admin', self: true, connections: 2 }, { name: 'Pat Smith', connections: 1 }])).toBe('Viewing now: Admin ×2 (you), Pat Smith · 1 other')
|
||||
expect(commentLines({ authorDisplayName: 'Pat', body: 'hi @[Lee](user:7)', createdAt: undefined, replies: [{ authorDisplayName: 'Lee', body: 'ok' }] }, () => 'now')).toEqual(['Pat · now: hi @Lee', ' ↳ Lee · now: ok'])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { expect, test } from 'bun:test'
|
||||
import { ExtensionRegistry, opaqueConnectionId } from '@qrun-io/qqq-core'
|
||||
import { collaborationPresence, presenceText } from '../src/presence'
|
||||
|
||||
const registry = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.2.0' }] })
|
||||
|
||||
/** A client stub answering presence calls; records the GET queries. */
|
||||
function client(answer: (query: Record<string, string | undefined>, own: string | undefined) => unknown) {
|
||||
const queries: Array<Record<string, string | undefined>> = []
|
||||
let own: string | undefined
|
||||
return {
|
||||
queries,
|
||||
call: async (id: string, input: { query?: Record<string, string | undefined>; json?: { connectionId?: string } }) => {
|
||||
if (input.json?.connectionId) own = input.json.connectionId
|
||||
if (id === 'getQbitCollaborationPresence') queries.push(input.query ?? {})
|
||||
const data = id === 'getQbitCollaborationPresence' ? answer(input.query ?? {}, own) : id === 'deleteQbitCollaborationPresence' ? { success: true } : { table: 'person', pk: '7', viewers: [] }
|
||||
return { status: 200, headers: new Headers(), data, contentType: 'application/json' }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function until(condition: () => boolean): Promise<void> {
|
||||
for (let i = 0; i < 500 && !condition(); i++) await new Promise((r) => setTimeout(r, 2))
|
||||
}
|
||||
|
||||
test('the record presence asks per connection: "Admin ×2", this terminal marked among the terminals', async () => {
|
||||
const c = client((query, own) => ({
|
||||
table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }, { userId: 'pat', displayName: 'Pat', mode: 'VIEWING', connections: 2 }], leaseCount: 4,
|
||||
...(query.perConnection === 'true' ? { leases: [
|
||||
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', own ?? ''), since: '2026-11-01T12:00:00Z' },
|
||||
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'cffff', since: '2026-11-01T12:00:01Z' },
|
||||
] } : {}),
|
||||
}))
|
||||
const source = collaborationPresence(registry, c as never, 'Admin')!
|
||||
source.enter!('record:person:7')
|
||||
await until(() => Boolean(source.get('record:person:7').everyone?.length))
|
||||
const p = source.get('record:person:7')
|
||||
expect(c.queries[0]!.perConnection).toBe('true')
|
||||
const admin = p.everyone!.find((x) => x.self)!
|
||||
expect(admin.connections).toBe(2)
|
||||
expect(admin.terminals!.map((t) => [t.mode, t.self ?? false])).toEqual([['VIEWING', true], ['EDITING', false]])
|
||||
expect(presenceText(p)).toBe('1 other viewing: Pat ×2')
|
||||
source.enter!(undefined)
|
||||
})
|
||||
|
||||
test('a server without connections (collaboration 1.1.0) still shows everyone, once each', async () => {
|
||||
const c = client(() => ({ table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'pat', displayName: 'Pat', mode: 'EDITING' }] }))
|
||||
const source = collaborationPresence(registry, c as never, 'Admin')!
|
||||
source.enter!('record:person:7')
|
||||
await until(() => Boolean(source.get('record:person:7').everyone?.length))
|
||||
const p = source.get('record:person:7')
|
||||
expect(p.everyone!.map((x) => [x.name, x.connections, x.terminals])).toEqual([['Admin', undefined, undefined], ['Pat', undefined, undefined]])
|
||||
expect(presenceText(p)).toBe('1 other viewing: Pat')
|
||||
source.enter!(undefined)
|
||||
})
|
||||
@@ -93,3 +93,29 @@ test('panel rows: tags, latest two comments, tasks with the next due, files, fol
|
||||
expect(rows.filter((r) => r.key === 'C').length).toBe(5)
|
||||
expect(panelRows(busy, PANEL_WIDTH - 1, 5)).toHaveLength(5)
|
||||
})
|
||||
|
||||
test('two terminals of one user: "×2" on the strip and in the panel, each terminal with its mode and start', () => {
|
||||
const since = '2026-11-01T12:00:05Z'
|
||||
const local = new Date(since).toTimeString().slice(0, 5)
|
||||
const twice: CollabState = {
|
||||
...alone,
|
||||
everyone: [
|
||||
{ name: 'Admin', self: true, mode: 'EDITING', fieldName: 'name', connections: 2, terminals: [{ mode: 'EDITING', fieldName: 'name', since }, { mode: 'VIEWING', self: true }] },
|
||||
{ name: 'Pat Smith', mode: 'VIEWING', connections: 3 },
|
||||
],
|
||||
}
|
||||
expect(text(twice, 200)).toContain('viewing: Pat Smith ×3 (viewing)')
|
||||
expect(text({ ...alone, everyone: [twice.everyone![0]!] }, 200).trimEnd()).toEndWith('viewing: Admin ×2 (you)')
|
||||
const lines = panelRows(twice, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
|
||||
expect(lines.slice(lines.indexOf(' Viewing now'))).toEqual([
|
||||
' Viewing now',
|
||||
' Pat Smith ×3 · viewing',
|
||||
' Admin ×2 (you) · editing name',
|
||||
' this terminal · viewing',
|
||||
` #2 · editing name · ${local}`,
|
||||
])
|
||||
// a server before collaboration 1.2.0 sends no connections: no "×", no terminal lines
|
||||
const old = panelRows(busy, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
|
||||
expect(old.join('\n')).not.toContain('×')
|
||||
expect(old.join('\n')).not.toContain('this terminal')
|
||||
})
|
||||
@@ -134,6 +134,22 @@ describe('sources', () => {
|
||||
expect((await s.load()).value).toBe('1')
|
||||
})
|
||||
|
||||
test('presence counts connections: two terminals of one user are 2 online; older servers count each person once', async () => {
|
||||
const viewers: Record<string, Array<{ userId: string; displayName: string; mode: string; connections?: number }>> = {
|
||||
person: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }, { userId: 'u:bob', displayName: 'Bob', mode: 'VIEWING', connections: 2 }],
|
||||
order: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }],
|
||||
}
|
||||
const s = presenceSource(ctx({ data: (_id, input) => ({ viewers: viewers[(input as { query: { table: string } }).query.table] ?? [] }) }))
|
||||
const v = await s.load()
|
||||
expect(v.value).toBe('4')
|
||||
expect(v.detail).toBe('4 online · you ×2, Bob ×2 · PERSON')
|
||||
// only this terminal's own lease, no "connections" (collaboration 1.1.0): you, once
|
||||
const old = presenceSource(ctx({ data: () => ({ viewers: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING' }] }) }))
|
||||
const o = await old.load()
|
||||
expect(o.value).toBe('1')
|
||||
expect(o.detail).toBe('1 online · you')
|
||||
})
|
||||
|
||||
test('presence on a table that refuses it is skipped; the source hides when nothing is left', async () => {
|
||||
const refuse = () => { throw new UiApiError(404, {}) }
|
||||
// signed out: nothing to show once every table refuses
|
||||
|
||||
@@ -31,10 +31,10 @@
|
||||
},
|
||||
{
|
||||
"name": "collaboration",
|
||||
"version": "1.1.0",
|
||||
"document": "qbit-collaboration/src/main/resources/openapi/qqq-v1/collaboration.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "03407ae"
|
||||
"version": "1.2.0",
|
||||
"document": ".worktrees/qbit-collab-sessions/src/main/resources/openapi/qqq-v1/collaboration.yaml",
|
||||
"branch": "feat/per-session-presence",
|
||||
"commit": "0ae1e62"
|
||||
},
|
||||
{
|
||||
"name": "data-analysis",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
{
|
||||
"name": "collaboration",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence",
|
||||
"pathPrefix": "/qbit/collaboration",
|
||||
"operations": [
|
||||
@@ -13972,6 +13972,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0.",
|
||||
"in": "query",
|
||||
"name": "perConnection",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -41284,13 +41293,28 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CollaborationPresenceViewerV1"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "Live viewers; always present, empty once the last viewer leaves.",
|
||||
"nullable": false
|
||||
},
|
||||
"leaseCount": {
|
||||
"description": "Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0.",
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
},
|
||||
"leases": {
|
||||
"description": "One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CollaborationPresenceViewerV1"
|
||||
},
|
||||
"nullable": true,
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CollaborationPresenceViewerV1": {
|
||||
"description": "One user merged across live tabs; EDITING takes precedence.",
|
||||
"description": "One user merged across live tabs (EDITING takes precedence), or one live connection in leases.",
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"description": "Session display name.",
|
||||
@@ -41315,6 +41339,27 @@
|
||||
"description": "Stable user identity.",
|
||||
"nullable": false,
|
||||
"type": "string"
|
||||
},
|
||||
"connectionId": {
|
||||
"description": "Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0.",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"connections": {
|
||||
"description": "Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0.",
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
},
|
||||
"sessionId": {
|
||||
"description": "Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0.",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"since": {
|
||||
"description": "When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -8936,9 +8936,14 @@ export interface components {
|
||||
pk?: string | null;
|
||||
/** @description Parent table name. */
|
||||
table?: string;
|
||||
/** @description Live viewers; always present, empty once the last viewer leaves. */
|
||||
viewers?: components["schemas"]["CollaborationPresenceViewerV1"][];
|
||||
/** @description Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0. */
|
||||
leaseCount?: number | null;
|
||||
/** @description One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0. */
|
||||
leases?: components["schemas"]["CollaborationPresenceViewerV1"][] | null;
|
||||
};
|
||||
/** @description One user merged across live tabs; EDITING takes precedence. */
|
||||
/** @description One user merged across live tabs (EDITING takes precedence), or one live connection in leases. */
|
||||
CollaborationPresenceViewerV1: {
|
||||
/** @description Session display name. */
|
||||
displayName?: string;
|
||||
@@ -8951,6 +8956,17 @@ export interface components {
|
||||
mode?: "VIEWING" | "EDITING";
|
||||
/** @description Stable user identity. */
|
||||
userId?: string;
|
||||
/** @description Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0. */
|
||||
connectionId?: string | null;
|
||||
/** @description Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0. */
|
||||
connections?: number | null;
|
||||
/** @description Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0. */
|
||||
sessionId?: string | null;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.
|
||||
*/
|
||||
since?: string | null;
|
||||
};
|
||||
/** @description Most recent visible targets, at most fifty. */
|
||||
CollaborationRecentListV1: {
|
||||
@@ -19478,6 +19494,8 @@ export interface operations {
|
||||
table: string;
|
||||
/** @description Parent record primary key. */
|
||||
pk?: string;
|
||||
/** @description Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0. */
|
||||
perConnection?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
Reference in new issue
Block a user