@oxyhq/core 7.0.0 → 7.1.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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Unified account-list projection — THE single source of truth.
3
+ *
4
+ * Produces the flat `SwitchableAccount[]` every account chooser renders, by
5
+ * merging the device's server-authoritative session set (`DeviceSessionState`
6
+ * from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
7
+ * from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
8
+ * `@oxyhq/core` so `@oxyhq/services` (RN) and `@oxyhq/auth` (web) — and
9
+ * `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
10
+ * diverge.
11
+ *
12
+ * Pure and I/O-free: the caller resolves per-account profiles via
13
+ * `oxyServices.getUsersByIds(...)` and passes them in as `profilesById`, and
14
+ * binds `resolveAvatarUrl` to `oxyServices.getFileDownloadUrl`. This is the same
15
+ * split the former `@oxyhq/services` `buildSwitchableAccounts` used — hoisted
16
+ * into core, keyed directly on `DeviceSessionState` (whose `activeAccountId` is
17
+ * atomic, so no cross-call current-row reconciliation is needed).
18
+ */
19
+
20
+ import type { DeviceSessionState } from '@oxyhq/contracts';
21
+ import type { User } from '../models/interfaces';
22
+ import type {
23
+ AccountNode,
24
+ AccountRelationship,
25
+ AccountKind,
26
+ AccountMember,
27
+ } from '../mixins/OxyServices.accounts';
28
+ import { getAccountDisplayName, getAccountFallbackHandle } from '../utils/accountUtils';
29
+
30
+ /**
31
+ * The per-account user shape carried by a {@link SwitchableAccount}. The SDK's
32
+ * canonical {@link User} document — either a profile resolved via
33
+ * `oxyServices.getUsersByIds()` (device rows), the caller-supplied
34
+ * `activeUser` override (the freshest copy of the active row), or the `account`
35
+ * document embedded in an account-graph node (graph-only rows).
36
+ */
37
+ export type SwitchableAccountUser = User;
38
+
39
+ /**
40
+ * One account the signed-in user can switch INTO, in the uniform switch model.
41
+ *
42
+ * A switchable account is either a device sign-in, an account-graph node (owned
43
+ * org / shared-with-you), or BOTH (an account that has been switched into
44
+ * becomes a device session while still being a graph node — the two are deduped
45
+ * into a single row). Every row carries a canonical `accountId` (the uniform
46
+ * switch key); `sessionId` is present IFF the account is currently signed in on
47
+ * THIS device.
48
+ */
49
+ export interface SwitchableAccount {
50
+ /**
51
+ * Canonical account id (the underlying `User._id`). The single key EVERY
52
+ * switch uses — `controller.switchTo(accountId)`. Always present.
53
+ */
54
+ accountId: string;
55
+ /**
56
+ * Device session id, present IFF this account is signed in on THIS device.
57
+ * Absent for a graph account not yet switched into. Used only for
58
+ * device-scoped actions (per-account sign-out); switching ALWAYS goes through
59
+ * `switchTo(accountId)`.
60
+ */
61
+ sessionId?: string;
62
+ /**
63
+ * Device-local account slot index (0..N-1) carried on the underlying
64
+ * `SessionAccount`. Absent for graph-only rows.
65
+ */
66
+ authuser?: number;
67
+ /** Whether this account is the currently-active one (`accountId === activeAccountId`). */
68
+ isCurrent: boolean;
69
+ /** Whether this account is signed in on THIS device (has a `sessionId`). */
70
+ onDevice: boolean;
71
+ /**
72
+ * The caller's relationship to this account when it appears in the account
73
+ * graph: `self` (the caller's own personal account), `owner` (an org/project/
74
+ * bot the caller owns), or `member` (shared with the caller). Absent for an
75
+ * independent device sign-in that is NOT in the active account's graph.
76
+ */
77
+ relationship?: AccountRelationship;
78
+ /** Account classification (personal/organization/…). Cosmetic badge only. */
79
+ kind?: AccountKind;
80
+ /** Parent account id for 2-level tree grouping, or `null` for a root. */
81
+ parentAccountId?: string | null;
82
+ /**
83
+ * The caller's effective membership (role + permissions) in this account when
84
+ * it appears in the graph, or `null`/absent otherwise. Use `permissions` to
85
+ * gate per-account settings UI.
86
+ */
87
+ callerMembership?: AccountMember | null;
88
+ /** Friendly display name (never blank — falls back to a handle/sentinel). */
89
+ displayName: string;
90
+ /**
91
+ * Real account email, or `null` when the account genuinely has none. NEVER a
92
+ * synthesized `username@oxy.so` — a missing email falls back to the `@handle`
93
+ * secondary line.
94
+ */
95
+ email: string | null;
96
+ /** Resolved avatar thumbnail URL, or `undefined` when the account has no avatar. */
97
+ avatarUrl?: string;
98
+ /** Account's preferred Bloom color preset, or `null` when unset. */
99
+ color: string | null;
100
+ /** The underlying per-account user payload. */
101
+ user: SwitchableAccountUser;
102
+ }
103
+
104
+ /** Input to {@link projectSwitchableAccounts}. */
105
+ export interface ProjectSwitchableAccountsInput {
106
+ /**
107
+ * The device-scoped session state from `SessionClient.getState()`. `null`
108
+ * (or an empty account set) contributes no device rows.
109
+ */
110
+ state: DeviceSessionState | null;
111
+ /** The caller's account graph (`oxyServices.listAccounts()`). `[]` when none. */
112
+ graph: AccountNode[];
113
+ /**
114
+ * Per-account profiles resolved via `oxyServices.getUsersByIds()`, keyed by
115
+ * account id (`User.id`). Device accounts whose profile is absent here are
116
+ * omitted until a subsequent fetch resolves them (unless they are the active
117
+ * account and `activeUser` is supplied).
118
+ */
119
+ profilesById: Map<string, User>;
120
+ /**
121
+ * The freshest copy of the ACTIVE account's user (e.g. `useOxy().user`),
122
+ * preferred over `profilesById` for the active row so a just-committed profile
123
+ * edit is reflected immediately. Optional — the controller relies on
124
+ * `profilesById` alone when omitted.
125
+ */
126
+ activeUser?: User | null;
127
+ /** Locale for display-name resolution (passed to `getAccountDisplayName`). */
128
+ locale?: string;
129
+ /**
130
+ * Resolves an avatar file id to a thumbnail URL — bind to
131
+ * `(id) => id ? oxyServices.getFileDownloadUrl(id, 'thumb') : undefined`.
132
+ */
133
+ resolveAvatarUrl: (avatar: string | null | undefined) => string | undefined;
134
+ }
135
+
136
+ /**
137
+ * Pure union of device sign-ins and account-graph nodes into the flat
138
+ * {@link SwitchableAccount}[] every switcher renders.
139
+ *
140
+ * Order: device rows first (in `state.accounts` order, active flagged), then
141
+ * graph-only rows (in graph order). An account present as BOTH a device session
142
+ * and a graph node is deduped into ONE device row enriched with the graph
143
+ * metadata (relationship / kind / parent / membership).
144
+ */
145
+ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[] {
146
+ const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
147
+ const activeAccountId = state?.activeAccountId ?? null;
148
+
149
+ const toRow = (
150
+ accountUser: User,
151
+ opts: {
152
+ sessionId?: string;
153
+ authuser?: number;
154
+ relationship?: AccountRelationship;
155
+ kind?: AccountKind;
156
+ parentAccountId?: string | null;
157
+ callerMembership?: AccountMember | null;
158
+ },
159
+ ): SwitchableAccount => {
160
+ const accountId = accountUser.id?.toString() ?? '';
161
+ const handle = getAccountFallbackHandle(accountUser);
162
+ const secondaryHandle = handle ? `@${handle}` : null;
163
+ return {
164
+ accountId,
165
+ sessionId: opts.sessionId,
166
+ authuser: opts.authuser,
167
+ isCurrent: Boolean(accountId) && accountId === activeAccountId,
168
+ onDevice: Boolean(opts.sessionId),
169
+ relationship: opts.relationship,
170
+ kind: opts.kind,
171
+ parentAccountId: opts.parentAccountId,
172
+ callerMembership: opts.callerMembership,
173
+ displayName: getAccountDisplayName(accountUser, locale),
174
+ // Real email, or the `@handle` fallback (NEVER synthesized).
175
+ email: accountUser.email ?? secondaryHandle,
176
+ avatarUrl: resolveAvatarUrl(accountUser.avatar),
177
+ color: accountUser.color ?? null,
178
+ user: accountUser,
179
+ };
180
+ };
181
+
182
+ // --- Device rows (from the server-authoritative session set) ---
183
+ const deviceRows = (state?.accounts ?? []).flatMap((account): SwitchableAccount[] => {
184
+ const isActive = account.accountId === activeAccountId;
185
+ // The active row prefers the freshest `activeUser` (when supplied), then the
186
+ // batch-resolved profile; every other row uses the batch-resolved profile.
187
+ const accountUser: User | undefined = isActive && activeUser
188
+ ? activeUser
189
+ : profilesById.get(account.accountId);
190
+ if (!accountUser) {
191
+ return [];
192
+ }
193
+ return [toRow(accountUser, { sessionId: account.sessionId, authuser: account.authuser })];
194
+ });
195
+
196
+ // --- Merge graph nodes, deduping by account id ---
197
+ const byAccountId = new Map<string, SwitchableAccount>();
198
+ const order: string[] = [];
199
+ const remember = (row: SwitchableAccount): void => {
200
+ if (!row.accountId || byAccountId.has(row.accountId)) {
201
+ return;
202
+ }
203
+ byAccountId.set(row.accountId, row);
204
+ order.push(row.accountId);
205
+ };
206
+
207
+ for (const row of deviceRows) {
208
+ remember(row);
209
+ }
210
+
211
+ for (const node of graph) {
212
+ const existing = byAccountId.get(node.accountId);
213
+ if (existing) {
214
+ // On-device account that is ALSO in the graph: enrich the device row with
215
+ // graph metadata; keep its (freshest) profile + sessionId + active flag.
216
+ byAccountId.set(node.accountId, {
217
+ ...existing,
218
+ relationship: node.relationship,
219
+ kind: node.kind,
220
+ parentAccountId: node.parentAccountId,
221
+ callerMembership: node.callerMembership,
222
+ });
223
+ continue;
224
+ }
225
+ // Graph-only account (owned org / shared, not yet a device session).
226
+ remember(toRow(node.account, {
227
+ relationship: node.relationship,
228
+ kind: node.kind,
229
+ parentAccountId: node.parentAccountId,
230
+ callerMembership: node.callerMembership,
231
+ }));
232
+ }
233
+
234
+ return order.flatMap((id) => {
235
+ const row = byAccountId.get(id);
236
+ return row ? [row] : [];
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Every distinct account id referenced by a device session set AND an account
242
+ * graph, sorted for a stable profile-fetch key. Feed to
243
+ * `oxyServices.getUsersByIds(...)`; graph nodes already embed their `account`
244
+ * document, but including their ids lets the caller pass one id set and lets the
245
+ * projection prefer freshly-fetched profiles uniformly.
246
+ */
247
+ export function switchableAccountIds(
248
+ state: DeviceSessionState | null,
249
+ graph: AccountNode[],
250
+ ): string[] {
251
+ const ids = new Set<string>();
252
+ for (const account of state?.accounts ?? []) {
253
+ if (account.accountId) {
254
+ ids.add(account.accountId);
255
+ }
256
+ }
257
+ for (const node of graph) {
258
+ if (node.accountId) {
259
+ ids.add(node.accountId);
260
+ }
261
+ }
262
+ return Array.from(ids).sort();
263
+ }
@@ -1,5 +1,5 @@
1
1
  import type { OxyServices } from '../OxyServices';
2
- import { SessionClient, type TokenTransport } from './SessionClient';
2
+ import { SessionClient, type SessionClientOptions, type TokenTransport } from './SessionClient';
3
3
  import type { SocketIOFactory } from './socketLoader';
4
4
  import { createSessionClientHost } from './sessionClientHost';
5
5
 
@@ -29,11 +29,18 @@ export function createSessionClient(
29
29
  oxyServices: OxyServices,
30
30
  transport: TokenTransport,
31
31
  socketFactory?: SocketIOFactory,
32
+ /**
33
+ * Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
34
+ * while signed out so an idle tab receives its device pushes) and
35
+ * `onSessionAppeared` (self-acquire when a sibling signs in). See
36
+ * {@link SessionClientOptions}.
37
+ */
38
+ extra?: Pick<SessionClientOptions, 'signedOutSocketAuth' | 'onSessionAppeared'>,
32
39
  ): {
33
40
  client: SessionClient;
34
41
  host: ReturnType<typeof createSessionClientHost>;
35
42
  } {
36
43
  const host = createSessionClientHost(oxyServices);
37
- const client = new SessionClient(host, { transport, socketFactory });
44
+ const client = new SessionClient(host, { transport, socketFactory, ...extra });
38
45
  return { client, host };
39
46
  }