@colixsystems/widget-sdk 0.205.0 → 0.206.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/contract.cjs +66 -5
- package/dist/contract.js +66 -5
- package/dist/index.d.ts +51 -2
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/presence-hooks.js +239 -0
- package/dist/presence.js +18 -0
- package/dist/presence.native.js +16 -0
- package/package.json +1 -1
- package/src/contract.js +66 -5
package/README.md
CHANGED
|
@@ -83,7 +83,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
83
83
|
|
|
84
84
|
## Status
|
|
85
85
|
|
|
86
|
-
`v0.
|
|
86
|
+
`v0.206.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
87
|
+
|
|
88
|
+
### What's new in 0.206.0 (contract 1.165.0)
|
|
89
|
+
|
|
90
|
+
**Who is online — `useOnlineMembers(tableId, recordIds)` and `useOnlineVisibility()`.** For the people who share a record (a conversation, a team, a case): which members of records you are a member of — users holding their own read grant on it, given by a person — have the app open now and show their online status. A table opts in: a template table declares `presence: true` (an upgrade that declares it turns it on), or a builder turns on the table's "Show members' online status" setting in the Studio. `online` is a `Set` of user ids across the records and `byRecord[recordId]` is `{ online_user_ids, online_count }` (ids capped at 100, so count with `online_count`); a record the viewer only reads (through a group or a grant to everyone) or cannot read answers with nobody, and there are never timestamps. It refreshes every minute while the app is in front, on return to it and on pull-to-refresh. `supported` is true only once the workspace answered: it is false while the workspace does not record user activity, when the table has not opted in, when the viewer hides their own status, or in a preview — then show no online state at all, never "offline". Declare `directory.read:users`; the publish checks do not require it for this hook, but it is the scope the hook belongs to. `useOnlineVisibility()` is the viewer's own "Show when I'm online" switch (`{ supported, showOnline, setShowOnline }`, on by default): hiding takes the viewer out of everyone's answers, and they then see nobody online either.
|
|
91
|
+
|
|
92
|
+
### What's new in 0.205.1
|
|
93
|
+
|
|
94
|
+
**An encrypted column never labels a relation.** A RELATION read's `label` now comes from the first non-encrypted STRING/TEXT column on the target table when no display column is set, and an encrypted display column gives no label (`null`): a label is read whatever the reader may read of the target row, so it never carries encrypted text. To show an encrypted value of a related row, read that row through the table it lives in. A documentation correction; `CONTRACT.version` is unchanged.
|
|
87
95
|
|
|
88
96
|
### What's new in 0.205.0 (contract 1.164.0)
|
|
89
97
|
|
|
@@ -2110,7 +2118,7 @@ useEffect(() => {
|
|
|
2110
2118
|
|
|
2111
2119
|
### What's new in 0.42.0
|
|
2112
2120
|
|
|
2113
|
-
**RELATION columns hydrate with a display label (sc-1181).** Record reads now return `{ id, label }` for ONE_TO_ONE / ONE_TO_MANY and `[{ id, label }, ...]` for MANY_TO_MANY (empty array when no links) — `label` is the value of the column pointed at by the new optional `display_column_id` on `DatastoreSchemaColumn`, or, when unset, the first STRING/TEXT column on the target table. Widgets should render `record.<rel>.label` (or `record.<rel>.map(r => r.label).join(", ")` for M:M) directly; `.id` is still there for the foreign-key case. The cell-formatting helpers in the built-in `DataList` and `DataValue` widgets already walk arrays and prefer `label` over `name` / `id` — author widgets that need the same can copy that pattern. `CONTRACT.version` is unchanged.
|
|
2121
|
+
**RELATION columns hydrate with a display label (sc-1181).** Record reads now return `{ id, label }` for ONE_TO_ONE / ONE_TO_MANY and `[{ id, label }, ...]` for MANY_TO_MANY (empty array when no links) — `label` is the value of the column pointed at by the new optional `display_column_id` on `DatastoreSchemaColumn`, or, when unset, the first STRING/TEXT column on the target table (since 0.205.1: the first non-encrypted one; an encrypted display column gives no label). Widgets should render `record.<rel>.label` (or `record.<rel>.map(r => r.label).join(", ")` for M:M) directly; `.id` is still there for the foreign-key case. The cell-formatting helpers in the built-in `DataList` and `DataValue` widgets already walk arrays and prefer `label` over `name` / `id` — author widgets that need the same can copy that pattern. `CONTRACT.version` is unchanged.
|
|
2114
2122
|
|
|
2115
2123
|
### What's new in 0.41.0
|
|
2116
2124
|
|
package/dist/contract.cjs
CHANGED
|
@@ -2064,6 +2064,62 @@ const HOOKS = [
|
|
|
2064
2064
|
requiredContextSlice: ["directory.users"],
|
|
2065
2065
|
scopes: ["users.activity:*"],
|
|
2066
2066
|
},
|
|
2067
|
+
// sc-11025 / REQ-USERMGMT-12 — online status for people who share a record.
|
|
2068
|
+
{
|
|
2069
|
+
name: "useOnlineMembers",
|
|
2070
|
+
signature: "useOnlineMembers(tableId, recordIds)",
|
|
2071
|
+
description:
|
|
2072
|
+
"Who is online among the members of each record the viewer is a " +
|
|
2073
|
+
"member of (a conversation, a team, a case), on a table that shows its " +
|
|
2074
|
+
"members' online status (a template table's `presence: true`, or the " +
|
|
2075
|
+
"table setting in the Studio). Members hold their OWN read grant on the " +
|
|
2076
|
+
"record, given to them by a person (a grant from a template or one " +
|
|
2077
|
+
"inherited from a parent row names no member). `online` is a Set of " +
|
|
2078
|
+
"the user ids online across the records; `byRecord[recordId]` is " +
|
|
2079
|
+
"{ online_user_ids, online_count } — the ids are capped at 100 per " +
|
|
2080
|
+
"record, so count with `online_count`. A record the viewer only reads " +
|
|
2081
|
+
"(through a group or a grant to everyone) or cannot read answers with " +
|
|
2082
|
+
"nobody. Refreshes every minute while the app is in front, on return " +
|
|
2083
|
+
"to it and on pull-to-refresh. `supported` is true only once the " +
|
|
2084
|
+
"workspace answered: it is false while the workspace does not record " +
|
|
2085
|
+
"user activity, when the table has not opted in, when the viewer hides " +
|
|
2086
|
+
"their own status (useOnlineVisibility), and in a preview — then show " +
|
|
2087
|
+
"NO online state at all, never \"offline\". The viewer's own id may be " +
|
|
2088
|
+
"in `online`; leave it out when you mean \"someone else is here\". " +
|
|
2089
|
+
"Never timestamps. Declare `directory.read:users`: the publish checks " +
|
|
2090
|
+
"do not require it for this hook, but it is the scope the hook belongs to.",
|
|
2091
|
+
returnShape: {
|
|
2092
|
+
supported: "boolean // false: render no online dots, no counts, no 'offline' state",
|
|
2093
|
+
online: "Set<string> // user ids online across the records",
|
|
2094
|
+
byRecord: "{ [recordId]: { online_user_ids: string[], online_count: number } }",
|
|
2095
|
+
loading: "boolean",
|
|
2096
|
+
error: "DirectoryError | null // reason ACTIVITY_UNAVAILABLE, ACTIVITY_DISABLED, ONLINE_STATUS_HIDDEN, PRESENCE_NOT_ENABLED or FORBIDDEN (not a live person) when refused",
|
|
2097
|
+
refetch: "() => Promise<void>",
|
|
2098
|
+
},
|
|
2099
|
+
requiredContextSlice: ["directory.presence"],
|
|
2100
|
+
scopes: ["directory.read:users"],
|
|
2101
|
+
},
|
|
2102
|
+
{
|
|
2103
|
+
name: "useOnlineVisibility",
|
|
2104
|
+
signature: "useOnlineVisibility()",
|
|
2105
|
+
description:
|
|
2106
|
+
"The viewer's own \"Show when I'm online\" switch, on by default. " +
|
|
2107
|
+
"`supported` is false where the workspace shows nobody online: offer " +
|
|
2108
|
+
"the switch only when it is true. `setShowOnline(false)` hides the " +
|
|
2109
|
+
"viewer from everyone, and they then see nobody online either; every " +
|
|
2110
|
+
"useOnlineMembers on the page re-reads. No scope: it is the viewer's " +
|
|
2111
|
+
"own setting.",
|
|
2112
|
+
returnShape: {
|
|
2113
|
+
supported: "boolean",
|
|
2114
|
+
showOnline: "boolean | null // null until known",
|
|
2115
|
+
loading: "boolean",
|
|
2116
|
+
saving: "boolean",
|
|
2117
|
+
error: "DirectoryError | null",
|
|
2118
|
+
setShowOnline: "(showOnline: boolean) => Promise<void> // rejects with DirectoryError",
|
|
2119
|
+
},
|
|
2120
|
+
requiredContextSlice: ["directory.presence"],
|
|
2121
|
+
scopes: null,
|
|
2122
|
+
},
|
|
2067
2123
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
|
|
2068
2124
|
// `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
|
|
2069
2125
|
// Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
|
|
@@ -3218,7 +3274,7 @@ const MANIFEST_SCHEMA = {
|
|
|
3218
3274
|
type: "object",
|
|
3219
3275
|
required: false,
|
|
3220
3276
|
description:
|
|
3221
|
-
"Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation).",
|
|
3277
|
+
"Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table.",
|
|
3222
3278
|
},
|
|
3223
3279
|
translations: {
|
|
3224
3280
|
type: "object",
|
|
@@ -3369,10 +3425,11 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3369
3425
|
"{ me(), users: { list(query?) -> Promise<{ data, meta }>, get(id), invite(body), deactivate(id), reactivate(id) }, " +
|
|
3370
3426
|
"groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
|
|
3371
3427
|
"invites: { list(), revoke(id), resend(id) }, " +
|
|
3372
|
-
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }
|
|
3373
|
-
"
|
|
3428
|
+
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
|
|
3429
|
+
"presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
|
|
3430
|
+
"users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
|
|
3374
3431
|
required: true,
|
|
3375
|
-
fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
|
|
3432
|
+
fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
|
|
3376
3433
|
},
|
|
3377
3434
|
assets: {
|
|
3378
3435
|
description:
|
|
@@ -5464,7 +5521,11 @@ const CONTRACT = deepFreeze({
|
|
|
5464
5521
|
// and `useInactiveUsers` read user activity through the new
|
|
5465
5522
|
// `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
|
|
5466
5523
|
// new `users.activity:*` scope.
|
|
5467
|
-
|
|
5524
|
+
// 1.165.0: additive (sc-11025) — `useOnlineMembers(tableId, recordIds)`
|
|
5525
|
+
// and `useOnlineVisibility()` read who of a record's members is online
|
|
5526
|
+
// and the viewer's own switch, through the new `ctx.directory.presence`.
|
|
5527
|
+
// A `datastoreTemplate` table may declare `presence: true`.
|
|
5528
|
+
version: "1.165.0",
|
|
5468
5529
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5469
5530
|
hooks: HOOKS,
|
|
5470
5531
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -2064,6 +2064,62 @@ const HOOKS = [
|
|
|
2064
2064
|
requiredContextSlice: ["directory.users"],
|
|
2065
2065
|
scopes: ["users.activity:*"],
|
|
2066
2066
|
},
|
|
2067
|
+
// sc-11025 / REQ-USERMGMT-12 — online status for people who share a record.
|
|
2068
|
+
{
|
|
2069
|
+
name: "useOnlineMembers",
|
|
2070
|
+
signature: "useOnlineMembers(tableId, recordIds)",
|
|
2071
|
+
description:
|
|
2072
|
+
"Who is online among the members of each record the viewer is a " +
|
|
2073
|
+
"member of (a conversation, a team, a case), on a table that shows its " +
|
|
2074
|
+
"members' online status (a template table's `presence: true`, or the " +
|
|
2075
|
+
"table setting in the Studio). Members hold their OWN read grant on the " +
|
|
2076
|
+
"record, given to them by a person (a grant from a template or one " +
|
|
2077
|
+
"inherited from a parent row names no member). `online` is a Set of " +
|
|
2078
|
+
"the user ids online across the records; `byRecord[recordId]` is " +
|
|
2079
|
+
"{ online_user_ids, online_count } — the ids are capped at 100 per " +
|
|
2080
|
+
"record, so count with `online_count`. A record the viewer only reads " +
|
|
2081
|
+
"(through a group or a grant to everyone) or cannot read answers with " +
|
|
2082
|
+
"nobody. Refreshes every minute while the app is in front, on return " +
|
|
2083
|
+
"to it and on pull-to-refresh. `supported` is true only once the " +
|
|
2084
|
+
"workspace answered: it is false while the workspace does not record " +
|
|
2085
|
+
"user activity, when the table has not opted in, when the viewer hides " +
|
|
2086
|
+
"their own status (useOnlineVisibility), and in a preview — then show " +
|
|
2087
|
+
"NO online state at all, never \"offline\". The viewer's own id may be " +
|
|
2088
|
+
"in `online`; leave it out when you mean \"someone else is here\". " +
|
|
2089
|
+
"Never timestamps. Declare `directory.read:users`: the publish checks " +
|
|
2090
|
+
"do not require it for this hook, but it is the scope the hook belongs to.",
|
|
2091
|
+
returnShape: {
|
|
2092
|
+
supported: "boolean // false: render no online dots, no counts, no 'offline' state",
|
|
2093
|
+
online: "Set<string> // user ids online across the records",
|
|
2094
|
+
byRecord: "{ [recordId]: { online_user_ids: string[], online_count: number } }",
|
|
2095
|
+
loading: "boolean",
|
|
2096
|
+
error: "DirectoryError | null // reason ACTIVITY_UNAVAILABLE, ACTIVITY_DISABLED, ONLINE_STATUS_HIDDEN, PRESENCE_NOT_ENABLED or FORBIDDEN (not a live person) when refused",
|
|
2097
|
+
refetch: "() => Promise<void>",
|
|
2098
|
+
},
|
|
2099
|
+
requiredContextSlice: ["directory.presence"],
|
|
2100
|
+
scopes: ["directory.read:users"],
|
|
2101
|
+
},
|
|
2102
|
+
{
|
|
2103
|
+
name: "useOnlineVisibility",
|
|
2104
|
+
signature: "useOnlineVisibility()",
|
|
2105
|
+
description:
|
|
2106
|
+
"The viewer's own \"Show when I'm online\" switch, on by default. " +
|
|
2107
|
+
"`supported` is false where the workspace shows nobody online: offer " +
|
|
2108
|
+
"the switch only when it is true. `setShowOnline(false)` hides the " +
|
|
2109
|
+
"viewer from everyone, and they then see nobody online either; every " +
|
|
2110
|
+
"useOnlineMembers on the page re-reads. No scope: it is the viewer's " +
|
|
2111
|
+
"own setting.",
|
|
2112
|
+
returnShape: {
|
|
2113
|
+
supported: "boolean",
|
|
2114
|
+
showOnline: "boolean | null // null until known",
|
|
2115
|
+
loading: "boolean",
|
|
2116
|
+
saving: "boolean",
|
|
2117
|
+
error: "DirectoryError | null",
|
|
2118
|
+
setShowOnline: "(showOnline: boolean) => Promise<void> // rejects with DirectoryError",
|
|
2119
|
+
},
|
|
2120
|
+
requiredContextSlice: ["directory.presence"],
|
|
2121
|
+
scopes: null,
|
|
2122
|
+
},
|
|
2067
2123
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
|
|
2068
2124
|
// `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
|
|
2069
2125
|
// Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
|
|
@@ -3214,7 +3270,7 @@ const MANIFEST_SCHEMA = {
|
|
|
3214
3270
|
type: "object",
|
|
3215
3271
|
required: false,
|
|
3216
3272
|
description:
|
|
3217
|
-
"Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation).",
|
|
3273
|
+
"Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table.",
|
|
3218
3274
|
},
|
|
3219
3275
|
translations: {
|
|
3220
3276
|
type: "object",
|
|
@@ -3365,10 +3421,11 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3365
3421
|
"{ me(), users: { list(query?) -> Promise<{ data, meta }>, get(id), invite(body), deactivate(id), reactivate(id) }, " +
|
|
3366
3422
|
"groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
|
|
3367
3423
|
"invites: { list(), revoke(id), resend(id) }, " +
|
|
3368
|
-
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }
|
|
3369
|
-
"
|
|
3424
|
+
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
|
|
3425
|
+
"presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
|
|
3426
|
+
"users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
|
|
3370
3427
|
required: true,
|
|
3371
|
-
fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
|
|
3428
|
+
fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
|
|
3372
3429
|
},
|
|
3373
3430
|
assets: {
|
|
3374
3431
|
description:
|
|
@@ -5460,7 +5517,11 @@ const CONTRACT = deepFreeze({
|
|
|
5460
5517
|
// and `useInactiveUsers` read user activity through the new
|
|
5461
5518
|
// `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
|
|
5462
5519
|
// new `users.activity:*` scope.
|
|
5463
|
-
|
|
5520
|
+
// 1.165.0: additive (sc-11025) — `useOnlineMembers(tableId, recordIds)`
|
|
5521
|
+
// and `useOnlineVisibility()` read who of a record's members is online
|
|
5522
|
+
// and the viewer's own switch, through the new `ctx.directory.presence`.
|
|
5523
|
+
// A `datastoreTemplate` table may declare `presence: true`.
|
|
5524
|
+
version: "1.165.0",
|
|
5464
5525
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5465
5526
|
hooks: HOOKS,
|
|
5466
5527
|
primitives: PRIMITIVES,
|
package/dist/index.d.ts
CHANGED
|
@@ -235,6 +235,12 @@ export interface WidgetDatastoreTemplateTable {
|
|
|
235
235
|
suffix: string;
|
|
236
236
|
/** REQ-ACL-05: when true, the row creator gets full RWD+G on the new record. */
|
|
237
237
|
grantCreatorPermissions?: boolean;
|
|
238
|
+
/**
|
|
239
|
+
* When true, the people who share a row see which of each other are online
|
|
240
|
+
* (`useOnlineMembers`), while the workspace records user activity. An
|
|
241
|
+
* upgrade that declares it turns it on for the existing table.
|
|
242
|
+
*/
|
|
243
|
+
presence?: boolean;
|
|
238
244
|
/**
|
|
239
245
|
* REQ-TEMPLATES-ACL: optional table-level public grant. Omitted means
|
|
240
246
|
* the table starts purely per-record (record-level grants are the only
|
|
@@ -1118,6 +1124,48 @@ export function useInactiveUsers(query?: {
|
|
|
1118
1124
|
remind(id: string, message: { title: string; body: string }): Promise<void>;
|
|
1119
1125
|
};
|
|
1120
1126
|
|
|
1127
|
+
/** One record's online members, as `useOnlineMembers` reports them. */
|
|
1128
|
+
export interface RecordOnlineMembers {
|
|
1129
|
+
/** At most 100 ids; `online_count` counts them all. */
|
|
1130
|
+
online_user_ids: string[];
|
|
1131
|
+
online_count: number;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Who is online among the members of records the viewer is a member of, on a
|
|
1136
|
+
* table that shows its members' online status (template `presence: true` or
|
|
1137
|
+
* the Studio table setting). Members hold their own read grant, given by a
|
|
1138
|
+
* person; a record the viewer only reads answers with nobody. `supported` is
|
|
1139
|
+
* false while the workspace does not record activity, the table has not opted
|
|
1140
|
+
* in, the viewer hides their own status, or in a preview: then show no online
|
|
1141
|
+
* state at all. Refreshes every minute while the app is in front.
|
|
1142
|
+
*/
|
|
1143
|
+
export function useOnlineMembers(
|
|
1144
|
+
tableId: string | null | undefined,
|
|
1145
|
+
recordIds: string[] | null | undefined,
|
|
1146
|
+
): {
|
|
1147
|
+
supported: boolean;
|
|
1148
|
+
/** User ids online across the records. */
|
|
1149
|
+
online: Set<string>;
|
|
1150
|
+
byRecord: Record<string, RecordOnlineMembers>;
|
|
1151
|
+
loading: boolean;
|
|
1152
|
+
/** `reason` when refused: ACTIVITY_UNAVAILABLE, ACTIVITY_DISABLED, ONLINE_STATUS_HIDDEN, PRESENCE_NOT_ENABLED or FORBIDDEN. */
|
|
1153
|
+
error: DirectoryError | null;
|
|
1154
|
+
refetch(): Promise<void>;
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
/** The viewer's own "Show when I'm online" switch; offer it only when `supported`. */
|
|
1158
|
+
export function useOnlineVisibility(): {
|
|
1159
|
+
supported: boolean;
|
|
1160
|
+
/** Null until known. */
|
|
1161
|
+
showOnline: boolean | null;
|
|
1162
|
+
loading: boolean;
|
|
1163
|
+
saving: boolean;
|
|
1164
|
+
error: DirectoryError | null;
|
|
1165
|
+
/** Save the switch; every switch and useOnlineMembers on the page follows. Rejects with DirectoryError. */
|
|
1166
|
+
setShowOnline(showOnline: boolean): Promise<void>;
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1121
1169
|
export function useDatastoreMutation<T = unknown>(
|
|
1122
1170
|
table: string,
|
|
1123
1171
|
): MutationApi<T>;
|
|
@@ -1416,8 +1464,9 @@ export interface DatastoreSchemaColumn {
|
|
|
1416
1464
|
/**
|
|
1417
1465
|
* For RELATION columns only — id of a column on `target_table_id` whose
|
|
1418
1466
|
* value supplies the `label` hydrated next to the related record id in
|
|
1419
|
-
* record responses. `null` falls back to the first
|
|
1420
|
-
* the target table
|
|
1467
|
+
* record responses. `null` falls back to the first non-encrypted
|
|
1468
|
+
* STRING/TEXT column on the target table; an encrypted display column
|
|
1469
|
+
* gives no label (`null`).
|
|
1421
1470
|
*/
|
|
1422
1471
|
display_column_id?: string | null;
|
|
1423
1472
|
/** True when this column is the table's display/identification column. */
|
package/dist/index.js
CHANGED
|
@@ -129,6 +129,8 @@ export {
|
|
|
129
129
|
useUserActivity,
|
|
130
130
|
useInactiveUsers,
|
|
131
131
|
} from "./stats-hooks.js";
|
|
132
|
+
// sc-11025 — one source, bound to how each platform says the app is in front.
|
|
133
|
+
export { useOnlineMembers, useOnlineVisibility } from "./presence.js";
|
|
132
134
|
// sc-6607 — `<Overlay>` is one shared component (./overlay-view.js) bound to
|
|
133
135
|
// each platform's primitives, so a widget's modal escapes its container box
|
|
134
136
|
// identically on the Player and the Expo export.
|
package/dist/index.native.js
CHANGED
|
@@ -127,6 +127,8 @@ export {
|
|
|
127
127
|
useUserActivity,
|
|
128
128
|
useInactiveUsers,
|
|
129
129
|
} from "./stats-hooks.js";
|
|
130
|
+
// sc-11025 — see the note on the web mirror in ./index.js.
|
|
131
|
+
export { useOnlineMembers, useOnlineVisibility } from "./presence.native.js";
|
|
130
132
|
// sc-6607 — see the note on the web mirror in ./index.js.
|
|
131
133
|
export { Overlay } from "./overlay.native.js";
|
|
132
134
|
// sc-8104 — see the note on the web mirror in ./index.js.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// sc-11025 — who is online among the people who share a record, and the
|
|
2
|
+
// viewer's own "Show when I'm online" switch. One source for both entries:
|
|
3
|
+
// ./presence.js and ./presence.native.js only bind how each platform says
|
|
4
|
+
// the app is in front.
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { toDirectoryError, useRefresh, useWidgetContextOrThrow } from "./hooks.js";
|
|
7
|
+
|
|
8
|
+
// Recorded activity moves about once a minute, so polling faster shows nothing new.
|
|
9
|
+
export const PRESENCE_REFRESH_MS = 60 * 1000;
|
|
10
|
+
// Before the feature reaches a workspace, ask again only this seldom.
|
|
11
|
+
export const PRESENCE_UNAVAILABLE_RETRY_MS = 10 * 60 * 1000;
|
|
12
|
+
// The server answers at most this many records per call.
|
|
13
|
+
const RECORDS_PER_CALL = 100;
|
|
14
|
+
|
|
15
|
+
const NO_RECORDS = Object.freeze({});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How a read leaves the hook asking again. `live`: every minute and on
|
|
19
|
+
* return. `refused` (setting off, hidden, table not opted in): on return
|
|
20
|
+
* only. `unavailable` (not released): every ten minutes, never on return.
|
|
21
|
+
*/
|
|
22
|
+
const CADENCE = {
|
|
23
|
+
live: { everyMs: PRESENCE_REFRESH_MS, onReturn: true },
|
|
24
|
+
refused: { everyMs: null, onReturn: true },
|
|
25
|
+
unavailable: { everyMs: PRESENCE_UNAVAILABLE_RETRY_MS, onReturn: false },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function cadenceAfter(error) {
|
|
29
|
+
if (!error || !(error.status === 401 || error.status === 403)) return "live";
|
|
30
|
+
return error.reason === "ACTIVITY_UNAVAILABLE" ? "unavailable" : "refused";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A changed switch re-reads every presence read and updates every switch.
|
|
34
|
+
const presenceReaders = new Set();
|
|
35
|
+
const visibilityHolders = new Set();
|
|
36
|
+
|
|
37
|
+
function presenceClient(ctx) {
|
|
38
|
+
const presence = ctx.directory && ctx.directory.presence;
|
|
39
|
+
return presence && typeof presence.online === "function" ? presence : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function uniqueIds(recordIds) {
|
|
43
|
+
if (!Array.isArray(recordIds)) return [];
|
|
44
|
+
return [...new Set(recordIds.filter((id) => typeof id === "string" && id))].sort();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readOnline(presence, tableId, ids) {
|
|
48
|
+
const calls = [];
|
|
49
|
+
for (let i = 0; i < ids.length; i += RECORDS_PER_CALL) {
|
|
50
|
+
calls.push(presence.online(tableId, ids.slice(i, i + RECORDS_PER_CALL)));
|
|
51
|
+
}
|
|
52
|
+
const pages = await Promise.all(calls);
|
|
53
|
+
const byRecord = {};
|
|
54
|
+
for (const page of pages) {
|
|
55
|
+
for (const entry of (page && Array.isArray(page.data) ? page.data : [])) {
|
|
56
|
+
byRecord[entry.record_id] = {
|
|
57
|
+
online_user_ids: entry.online_user_ids,
|
|
58
|
+
online_count: entry.online_count,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return byRecord;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Runs `onTick` on the cadence's timer while in front, and on return when it says so. */
|
|
66
|
+
function useWhileInFront(appInFront, onTick, cadence) {
|
|
67
|
+
const tickRef = useRef(onTick);
|
|
68
|
+
tickRef.current = onTick;
|
|
69
|
+
const { everyMs, onReturn } = CADENCE[cadence];
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
let timer = null;
|
|
72
|
+
let inFront = appInFront.isActive();
|
|
73
|
+
const start = () => {
|
|
74
|
+
if (everyMs && !timer) timer = setInterval(() => tickRef.current(), everyMs);
|
|
75
|
+
};
|
|
76
|
+
const stop = () => {
|
|
77
|
+
clearInterval(timer);
|
|
78
|
+
timer = null;
|
|
79
|
+
};
|
|
80
|
+
if (inFront) start();
|
|
81
|
+
const unsubscribe = appInFront.subscribe((active) => {
|
|
82
|
+
if (active === inFront) return;
|
|
83
|
+
inFront = active;
|
|
84
|
+
if (!active) return stop();
|
|
85
|
+
if (onReturn) tickRef.current();
|
|
86
|
+
return start();
|
|
87
|
+
});
|
|
88
|
+
return () => {
|
|
89
|
+
stop();
|
|
90
|
+
if (typeof unsubscribe === "function") unsubscribe();
|
|
91
|
+
};
|
|
92
|
+
}, [appInFront, everyMs, onReturn]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Bind the presence hooks to a platform's notion of "the app is in front":
|
|
97
|
+
* `{ isActive(): boolean, subscribe(onChange(active)): unsubscribe }`.
|
|
98
|
+
*/
|
|
99
|
+
export function makePresenceHooks(appInFront) {
|
|
100
|
+
/**
|
|
101
|
+
* Who is online among the members of each record the viewer is a member
|
|
102
|
+
* of. Returns `{ supported, online, byRecord, loading, error, refetch }`:
|
|
103
|
+
* `online` is a Set of the user ids online across the records,
|
|
104
|
+
* `byRecord[id]` is `{ online_user_ids, online_count }` (ids capped at 100).
|
|
105
|
+
* `supported` is true only once the workspace answered; while it is false
|
|
106
|
+
* the widget shows no online state at all.
|
|
107
|
+
*/
|
|
108
|
+
function useOnlineMembers(tableId, recordIds) {
|
|
109
|
+
const ctx = useWidgetContextOrThrow("useOnlineMembers");
|
|
110
|
+
const presence = presenceClient(ctx);
|
|
111
|
+
const presenceRef = useRef(presence);
|
|
112
|
+
presenceRef.current = presence;
|
|
113
|
+
const hasPresence = presence !== null;
|
|
114
|
+
const ids = uniqueIds(recordIds);
|
|
115
|
+
const key = tableId && ids.length > 0 ? `${tableId}:${ids.join(",")}` : null;
|
|
116
|
+
const requestRef = useRef(null);
|
|
117
|
+
requestRef.current = key === null ? null : { tableId, ids };
|
|
118
|
+
const [state, setState] = useState({ tableId: null, supported: false, byRecord: NO_RECORDS, error: null });
|
|
119
|
+
const runRef = useRef(0);
|
|
120
|
+
|
|
121
|
+
const read = useCallback(async () => {
|
|
122
|
+
const run = ++runRef.current;
|
|
123
|
+
const client = presenceRef.current;
|
|
124
|
+
const request = requestRef.current;
|
|
125
|
+
if (!client || !request) {
|
|
126
|
+
setState((prev) => (prev.tableId === null ? prev : { tableId: null, supported: false, byRecord: NO_RECORDS, error: null }));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const byRecord = await readOnline(client, request.tableId, request.ids);
|
|
131
|
+
if (runRef.current === run) setState({ tableId: request.tableId, supported: true, byRecord, error: null });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (runRef.current !== run) return;
|
|
134
|
+
const error = toDirectoryError(err);
|
|
135
|
+
setState((prev) =>
|
|
136
|
+
cadenceAfter(error) === "live"
|
|
137
|
+
? { ...(prev.tableId === request.tableId ? prev : { supported: false, byRecord: NO_RECORDS }), tableId: request.tableId, error }
|
|
138
|
+
: { tableId: request.tableId, supported: false, byRecord: NO_RECORDS, error },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
read();
|
|
145
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
146
|
+
}, [key, hasPresence]);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
presenceReaders.add(read);
|
|
149
|
+
return () => presenceReaders.delete(read);
|
|
150
|
+
}, [read]);
|
|
151
|
+
useRefresh(read);
|
|
152
|
+
useWhileInFront(appInFront, read, key !== null && hasPresence ? cadenceAfter(state.error) : "refused");
|
|
153
|
+
|
|
154
|
+
// Another set of records of the same table keeps showing the last answer.
|
|
155
|
+
const current = key !== null && state.tableId === tableId;
|
|
156
|
+
const byRecord = current ? state.byRecord : NO_RECORDS;
|
|
157
|
+
const online = useMemo(
|
|
158
|
+
() => new Set(Object.values(byRecord).flatMap((entry) => entry.online_user_ids || [])),
|
|
159
|
+
[byRecord],
|
|
160
|
+
);
|
|
161
|
+
return {
|
|
162
|
+
supported: current && state.supported,
|
|
163
|
+
online,
|
|
164
|
+
byRecord,
|
|
165
|
+
loading: key !== null && hasPresence && !current,
|
|
166
|
+
error: current ? state.error : null,
|
|
167
|
+
refetch: read,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The viewer's own "Show when I'm online" switch: `{ supported, showOnline,
|
|
173
|
+
* loading, saving, error, setShowOnline }`. `supported` is false where the
|
|
174
|
+
* workspace shows nobody online, so the switch is not offered there.
|
|
175
|
+
* `setShowOnline(bool)` saves it, updates every switch on the page and
|
|
176
|
+
* re-asks every presence read; hiding also hides everyone from the viewer.
|
|
177
|
+
*/
|
|
178
|
+
function useOnlineVisibility() {
|
|
179
|
+
const ctx = useWidgetContextOrThrow("useOnlineVisibility");
|
|
180
|
+
const presence = presenceClient(ctx);
|
|
181
|
+
const presenceRef = useRef(presence);
|
|
182
|
+
presenceRef.current = presence;
|
|
183
|
+
const hasPresence = presence !== null;
|
|
184
|
+
const [state, setState] = useState({ loading: hasPresence, supported: false, showOnline: null, error: null });
|
|
185
|
+
const [saving, setSaving] = useState(false);
|
|
186
|
+
|
|
187
|
+
const read = useCallback(async () => {
|
|
188
|
+
const client = presenceRef.current;
|
|
189
|
+
if (!client || typeof client.me !== "function") {
|
|
190
|
+
setState({ loading: false, supported: false, showOnline: null, error: null });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const own = await client.me();
|
|
195
|
+
setState({ loading: false, supported: true, showOnline: own.show_online === true, error: null });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
const error = toDirectoryError(err);
|
|
198
|
+
setState((prev) =>
|
|
199
|
+
cadenceAfter(error) === "live"
|
|
200
|
+
? { ...prev, loading: false, error }
|
|
201
|
+
: { loading: false, supported: false, showOnline: null, error },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
read();
|
|
208
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
209
|
+
}, [hasPresence]);
|
|
210
|
+
useRefresh(read);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const holder = (showOnline) => setState((prev) => ({ ...prev, showOnline, error: null }));
|
|
213
|
+
visibilityHolders.add(holder);
|
|
214
|
+
return () => visibilityHolders.delete(holder);
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
217
|
+
const setShowOnline = useCallback(async (showOnline) => {
|
|
218
|
+
const client = presenceRef.current;
|
|
219
|
+
if (!client || typeof client.setShowOnline !== "function") {
|
|
220
|
+
throw new Error("useOnlineVisibility: host did not inject ctx.directory.presence.setShowOnline");
|
|
221
|
+
}
|
|
222
|
+
setSaving(true);
|
|
223
|
+
let saved;
|
|
224
|
+
try {
|
|
225
|
+
saved = (await client.setShowOnline(showOnline === true)).show_online === true;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
throw toDirectoryError(err);
|
|
228
|
+
} finally {
|
|
229
|
+
setSaving(false);
|
|
230
|
+
}
|
|
231
|
+
for (const holder of visibilityHolders) holder(saved);
|
|
232
|
+
for (const reader of presenceReaders) reader();
|
|
233
|
+
}, []);
|
|
234
|
+
|
|
235
|
+
return { ...state, saving, setShowOnline };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { useOnlineMembers, useOnlineVisibility };
|
|
239
|
+
}
|
package/dist/presence.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// sc-11025 — the presence hooks bound to the web: the page is in front while
|
|
2
|
+
// it is visible.
|
|
3
|
+
import { makePresenceHooks } from "./presence-hooks.js";
|
|
4
|
+
|
|
5
|
+
const visible = () =>
|
|
6
|
+
typeof document === "undefined" || document.visibilityState !== "hidden";
|
|
7
|
+
|
|
8
|
+
const appInFront = {
|
|
9
|
+
isActive: visible,
|
|
10
|
+
subscribe(onChange) {
|
|
11
|
+
if (typeof document === "undefined") return () => {};
|
|
12
|
+
const handler = () => onChange(visible());
|
|
13
|
+
document.addEventListener("visibilitychange", handler);
|
|
14
|
+
return () => document.removeEventListener("visibilitychange", handler);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const { useOnlineMembers, useOnlineVisibility } = makePresenceHooks(appInFront);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// sc-11025 — the presence hooks bound to the device: the app is in front
|
|
2
|
+
// unless AppState says "background" ("inactive" is a passing overlay).
|
|
3
|
+
import { AppState } from "react-native";
|
|
4
|
+
import { makePresenceHooks } from "./presence-hooks.js";
|
|
5
|
+
|
|
6
|
+
const inFront = (state) => state !== "background";
|
|
7
|
+
|
|
8
|
+
export const appInFront = {
|
|
9
|
+
isActive: () => inFront(AppState.currentState),
|
|
10
|
+
subscribe(onChange) {
|
|
11
|
+
const subscription = AppState.addEventListener("change", (state) => onChange(inFront(state)));
|
|
12
|
+
return () => subscription?.remove();
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const { useOnlineMembers, useOnlineVisibility } = makePresenceHooks(appInFront);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.206.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
package/src/contract.js
CHANGED
|
@@ -2064,6 +2064,62 @@ const HOOKS = [
|
|
|
2064
2064
|
requiredContextSlice: ["directory.users"],
|
|
2065
2065
|
scopes: ["users.activity:*"],
|
|
2066
2066
|
},
|
|
2067
|
+
// sc-11025 / REQ-USERMGMT-12 — online status for people who share a record.
|
|
2068
|
+
{
|
|
2069
|
+
name: "useOnlineMembers",
|
|
2070
|
+
signature: "useOnlineMembers(tableId, recordIds)",
|
|
2071
|
+
description:
|
|
2072
|
+
"Who is online among the members of each record the viewer is a " +
|
|
2073
|
+
"member of (a conversation, a team, a case), on a table that shows its " +
|
|
2074
|
+
"members' online status (a template table's `presence: true`, or the " +
|
|
2075
|
+
"table setting in the Studio). Members hold their OWN read grant on the " +
|
|
2076
|
+
"record, given to them by a person (a grant from a template or one " +
|
|
2077
|
+
"inherited from a parent row names no member). `online` is a Set of " +
|
|
2078
|
+
"the user ids online across the records; `byRecord[recordId]` is " +
|
|
2079
|
+
"{ online_user_ids, online_count } — the ids are capped at 100 per " +
|
|
2080
|
+
"record, so count with `online_count`. A record the viewer only reads " +
|
|
2081
|
+
"(through a group or a grant to everyone) or cannot read answers with " +
|
|
2082
|
+
"nobody. Refreshes every minute while the app is in front, on return " +
|
|
2083
|
+
"to it and on pull-to-refresh. `supported` is true only once the " +
|
|
2084
|
+
"workspace answered: it is false while the workspace does not record " +
|
|
2085
|
+
"user activity, when the table has not opted in, when the viewer hides " +
|
|
2086
|
+
"their own status (useOnlineVisibility), and in a preview — then show " +
|
|
2087
|
+
"NO online state at all, never \"offline\". The viewer's own id may be " +
|
|
2088
|
+
"in `online`; leave it out when you mean \"someone else is here\". " +
|
|
2089
|
+
"Never timestamps. Declare `directory.read:users`: the publish checks " +
|
|
2090
|
+
"do not require it for this hook, but it is the scope the hook belongs to.",
|
|
2091
|
+
returnShape: {
|
|
2092
|
+
supported: "boolean // false: render no online dots, no counts, no 'offline' state",
|
|
2093
|
+
online: "Set<string> // user ids online across the records",
|
|
2094
|
+
byRecord: "{ [recordId]: { online_user_ids: string[], online_count: number } }",
|
|
2095
|
+
loading: "boolean",
|
|
2096
|
+
error: "DirectoryError | null // reason ACTIVITY_UNAVAILABLE, ACTIVITY_DISABLED, ONLINE_STATUS_HIDDEN, PRESENCE_NOT_ENABLED or FORBIDDEN (not a live person) when refused",
|
|
2097
|
+
refetch: "() => Promise<void>",
|
|
2098
|
+
},
|
|
2099
|
+
requiredContextSlice: ["directory.presence"],
|
|
2100
|
+
scopes: ["directory.read:users"],
|
|
2101
|
+
},
|
|
2102
|
+
{
|
|
2103
|
+
name: "useOnlineVisibility",
|
|
2104
|
+
signature: "useOnlineVisibility()",
|
|
2105
|
+
description:
|
|
2106
|
+
"The viewer's own \"Show when I'm online\" switch, on by default. " +
|
|
2107
|
+
"`supported` is false where the workspace shows nobody online: offer " +
|
|
2108
|
+
"the switch only when it is true. `setShowOnline(false)` hides the " +
|
|
2109
|
+
"viewer from everyone, and they then see nobody online either; every " +
|
|
2110
|
+
"useOnlineMembers on the page re-reads. No scope: it is the viewer's " +
|
|
2111
|
+
"own setting.",
|
|
2112
|
+
returnShape: {
|
|
2113
|
+
supported: "boolean",
|
|
2114
|
+
showOnline: "boolean | null // null until known",
|
|
2115
|
+
loading: "boolean",
|
|
2116
|
+
saving: "boolean",
|
|
2117
|
+
error: "DirectoryError | null",
|
|
2118
|
+
setShowOnline: "(showOnline: boolean) => Promise<void> // rejects with DirectoryError",
|
|
2119
|
+
},
|
|
2120
|
+
requiredContextSlice: ["directory.presence"],
|
|
2121
|
+
scopes: null,
|
|
2122
|
+
},
|
|
2067
2123
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
|
|
2068
2124
|
// `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
|
|
2069
2125
|
// Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
|
|
@@ -3214,7 +3270,7 @@ const MANIFEST_SCHEMA = {
|
|
|
3214
3270
|
type: "object",
|
|
3215
3271
|
required: false,
|
|
3216
3272
|
description:
|
|
3217
|
-
"Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation).",
|
|
3273
|
+
"Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table.",
|
|
3218
3274
|
},
|
|
3219
3275
|
translations: {
|
|
3220
3276
|
type: "object",
|
|
@@ -3365,10 +3421,11 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3365
3421
|
"{ me(), users: { list(query?) -> Promise<{ data, meta }>, get(id), invite(body), deactivate(id), reactivate(id) }, " +
|
|
3366
3422
|
"groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
|
|
3367
3423
|
"invites: { list(), revoke(id), resend(id) }, " +
|
|
3368
|
-
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }
|
|
3369
|
-
"
|
|
3424
|
+
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
|
|
3425
|
+
"presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
|
|
3426
|
+
"users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
|
|
3370
3427
|
required: true,
|
|
3371
|
-
fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
|
|
3428
|
+
fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
|
|
3372
3429
|
},
|
|
3373
3430
|
assets: {
|
|
3374
3431
|
description:
|
|
@@ -5460,7 +5517,11 @@ const CONTRACT = deepFreeze({
|
|
|
5460
5517
|
// and `useInactiveUsers` read user activity through the new
|
|
5461
5518
|
// `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
|
|
5462
5519
|
// new `users.activity:*` scope.
|
|
5463
|
-
|
|
5520
|
+
// 1.165.0: additive (sc-11025) — `useOnlineMembers(tableId, recordIds)`
|
|
5521
|
+
// and `useOnlineVisibility()` read who of a record's members is online
|
|
5522
|
+
// and the viewer's own switch, through the new `ctx.directory.presence`.
|
|
5523
|
+
// A `datastoreTemplate` table may declare `presence: true`.
|
|
5524
|
+
version: "1.165.0",
|
|
5464
5525
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5465
5526
|
hooks: HOOKS,
|
|
5466
5527
|
primitives: PRIMITIVES,
|