@oxy.so/services 12.7.0 → 12.9.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.
Files changed (100) hide show
  1. package/lib/commonjs/index.js +7 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/client.js +19 -0
  4. package/lib/commonjs/ui/client.js.map +1 -1
  5. package/lib/commonjs/ui/components/FollowButton.js +8 -12
  6. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  7. package/lib/commonjs/ui/components/FollowTargetButton.js +1 -2
  8. package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -1
  9. package/lib/commonjs/ui/components/followLabels.js +34 -0
  10. package/lib/commonjs/ui/components/followLabels.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/useAccountColor.js +75 -0
  12. package/lib/commonjs/ui/hooks/useAccountColor.js.map +1 -1
  13. package/lib/commonjs/ui/hooks/useFollow.js +5 -4
  14. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  15. package/lib/commonjs/ui/hooks/useFollowTarget.js +8 -8
  16. package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -1
  17. package/lib/commonjs/ui/stores/followStore.js +10 -9
  18. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  19. package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -1
  20. package/lib/commonjs/ui/utils/followErrors.js +45 -0
  21. package/lib/commonjs/ui/utils/followErrors.js.map +1 -0
  22. package/lib/module/index.js +1 -0
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/ui/client.js +2 -1
  25. package/lib/module/ui/client.js.map +1 -1
  26. package/lib/module/ui/components/FollowButton.js +9 -13
  27. package/lib/module/ui/components/FollowButton.js.map +1 -1
  28. package/lib/module/ui/components/FollowTargetButton.js +2 -3
  29. package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
  30. package/lib/module/ui/components/followLabels.js +33 -1
  31. package/lib/module/ui/components/followLabels.js.map +1 -1
  32. package/lib/module/ui/hooks/useAccountColor.js +74 -1
  33. package/lib/module/ui/hooks/useAccountColor.js.map +1 -1
  34. package/lib/module/ui/hooks/useFollow.js +5 -4
  35. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  36. package/lib/module/ui/hooks/useFollowTarget.js +8 -8
  37. package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
  38. package/lib/module/ui/stores/followStore.js +10 -9
  39. package/lib/module/ui/stores/followStore.js.map +1 -1
  40. package/lib/module/ui/stores/followTargetStore.js.map +1 -1
  41. package/lib/module/ui/utils/followErrors.js +41 -0
  42. package/lib/module/ui/utils/followErrors.js.map +1 -0
  43. package/lib/typescript/commonjs/index.d.ts +2 -0
  44. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/client.d.ts +3 -1
  46. package/lib/typescript/commonjs/ui/client.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/ui/components/followLabels.d.ts +13 -0
  50. package/lib/typescript/commonjs/ui/components/followLabels.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/hooks/useAccountColor.d.ts +23 -0
  52. package/lib/typescript/commonjs/ui/hooks/useAccountColor.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +5 -4
  54. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/hooks/useFollow.types.d.ts +3 -2
  56. package/lib/typescript/commonjs/ui/hooks/useFollow.types.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts +4 -2
  58. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/stores/followStore.d.ts +3 -1
  60. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +4 -2
  62. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/utils/followErrors.d.ts +42 -0
  64. package/lib/typescript/commonjs/ui/utils/followErrors.d.ts.map +1 -0
  65. package/lib/typescript/module/index.d.ts +2 -0
  66. package/lib/typescript/module/index.d.ts.map +1 -1
  67. package/lib/typescript/module/ui/client.d.ts +3 -1
  68. package/lib/typescript/module/ui/client.d.ts.map +1 -1
  69. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  70. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
  71. package/lib/typescript/module/ui/components/followLabels.d.ts +13 -0
  72. package/lib/typescript/module/ui/components/followLabels.d.ts.map +1 -1
  73. package/lib/typescript/module/ui/hooks/useAccountColor.d.ts +23 -0
  74. package/lib/typescript/module/ui/hooks/useAccountColor.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/hooks/useFollow.d.ts +5 -4
  76. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  77. package/lib/typescript/module/ui/hooks/useFollow.types.d.ts +3 -2
  78. package/lib/typescript/module/ui/hooks/useFollow.types.d.ts.map +1 -1
  79. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts +4 -2
  80. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/stores/followStore.d.ts +3 -1
  82. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/stores/followTargetStore.d.ts +4 -2
  84. package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/utils/followErrors.d.ts +42 -0
  86. package/lib/typescript/module/ui/utils/followErrors.d.ts.map +1 -0
  87. package/package.json +5 -5
  88. package/src/index.ts +2 -0
  89. package/src/ui/client.ts +8 -1
  90. package/src/ui/components/FollowButton.tsx +14 -12
  91. package/src/ui/components/FollowTargetButton.tsx +7 -3
  92. package/src/ui/components/followLabels.ts +36 -2
  93. package/src/ui/hooks/useAccountColor.ts +71 -1
  94. package/src/ui/hooks/useFollow.ts +22 -10
  95. package/src/ui/hooks/useFollow.types.ts +6 -2
  96. package/src/ui/hooks/useFollowTarget.ts +15 -10
  97. package/src/ui/stores/__tests__/followStore.test.ts +1 -1
  98. package/src/ui/stores/followStore.ts +12 -14
  99. package/src/ui/stores/followTargetStore.ts +4 -2
  100. package/src/ui/utils/followErrors.ts +56 -0
@@ -25,6 +25,11 @@ import {
25
25
  useFollowTargetStore,
26
26
  withApplicationMode,
27
27
  } from '../stores/followTargetStore';
28
+ import {
29
+ type FollowActionErrorCode,
30
+ type FollowErrorCode,
31
+ toFollowErrorCode,
32
+ } from '../utils/followErrors';
28
33
 
29
34
  export interface UseFollowTargetResult {
30
35
  status: FollowStatus;
@@ -36,11 +41,12 @@ export interface UseFollowTargetResult {
36
41
  /** True until the first read settles. Distinct from "not following". */
37
42
  isUnknown: boolean;
38
43
  isPending: boolean;
39
- error: string | undefined;
44
+ /** Why the last read or write failed — a code; `useFollowErrorMessage()` words it. */
45
+ error: FollowErrorCode | undefined;
40
46
  /**
41
47
  * Every mutation resolves to whether the server ACCEPTED it.
42
48
  *
43
- * They never reject — a refusal becomes `error` in the store, so the button
49
+ * They never reject — a refusal becomes an `error` code in the store, so the button
44
50
  * can render it — which means a caller that only awaits them cannot tell a
45
51
  * write that happened from one that did not. Returning the outcome is what
46
52
  * lets a caller mirror the change somewhere else without mirroring failures
@@ -92,14 +98,13 @@ export function useFollowTarget(
92
98
  useFollowTargetStore.getState().setError(targetId, undefined);
93
99
  } catch (e) {
94
100
  const store = useFollowTargetStore.getState();
95
- const message = e instanceof Error ? e.message : 'Could not read follow status';
96
101
  // Leave `isUnknown` false so the button stays interactive and can retry.
97
102
  // A failed read is not the same as "never asked" — disabling forever would
98
103
  // strand the user with no affordance when the network blips.
99
104
  if (!store.statuses[targetId]) {
100
105
  store.setStatus(targetId, UNKNOWN_FOLLOW_STATUS);
101
106
  }
102
- store.setError(targetId, message);
107
+ store.setError(targetId, toFollowErrorCode(e, 'read_failed'));
103
108
  }
104
109
  }, [targetId, canUsePrivateApi, oxyServices]);
105
110
 
@@ -119,7 +124,7 @@ export function useFollowTarget(
119
124
  async (
120
125
  optimistic: FollowStatus,
121
126
  run: () => Promise<FollowStatus | undefined>,
122
- failureMessage: string,
127
+ failure: FollowActionErrorCode,
123
128
  ): Promise<boolean> => {
124
129
  if (!targetId) return false;
125
130
  const store = useFollowTargetStore.getState();
@@ -136,7 +141,7 @@ export function useFollowTarget(
136
141
  // Back to what was true, not to a guess. Clearing the entry instead
137
142
  // would make the next render ask again and flash the wrong state.
138
143
  if (previous) s.setStatus(targetId, previous);
139
- s.setError(targetId, e instanceof Error ? e.message : failureMessage);
144
+ s.setError(targetId, toFollowErrorCode(e, failure));
140
145
  return false;
141
146
  } finally {
142
147
  useFollowTargetStore.getState().setPending(targetId, false);
@@ -161,7 +166,7 @@ export function useFollowTarget(
161
166
  // than reconstructing one: `effectiveState`'s derivation lives there,
162
167
  // and a client recomputing it is a second implementation of one rule.
163
168
  async () => (await oxyServices.follows.followTarget(targetId, opts)).status,
164
- 'Could not follow',
169
+ 'update_failed',
165
170
  );
166
171
  },
167
172
  [targetId, current, mutate, oxyServices],
@@ -176,7 +181,7 @@ export function useFollowTarget(
176
181
  await oxyServices.follows.unfollowTarget(relationshipId);
177
182
  return { ...UNKNOWN_FOLLOW_STATUS };
178
183
  },
179
- 'Could not unfollow',
184
+ 'update_failed',
180
185
  );
181
186
  }, [targetId, current.relationshipId, mutate, oxyServices]);
182
187
 
@@ -190,7 +195,7 @@ export function useFollowTarget(
190
195
  await oxyServices.follows.setApplicationMode(relationshipId, 'disabled');
191
196
  return optimistic;
192
197
  },
193
- 'Could not change this app’s setting for this follow',
198
+ 'mode_failed',
194
199
  );
195
200
  }, [targetId, current, mutate, oxyServices]);
196
201
 
@@ -204,7 +209,7 @@ export function useFollowTarget(
204
209
  await oxyServices.follows.restoreInheritance(relationshipId);
205
210
  return optimistic;
206
211
  },
207
- 'Could not change this app’s setting for this follow',
212
+ 'mode_failed',
208
213
  );
209
214
  }, [targetId, current, mutate, oxyServices]);
210
215
 
@@ -145,7 +145,7 @@ describe('toggleFollowUser (optimistic)', () => {
145
145
  expect(accepted).toBe(false);
146
146
  expect(useFollowStore.getState().followingUsers.u1).toBe(false);
147
147
  expect(useFollowStore.getState().loadingUsers.u1).toBe(false);
148
- expect(useFollowStore.getState().errors.u1).toBe('boom');
148
+ expect(useFollowStore.getState().errors.u1).toBe('update_failed');
149
149
  });
150
150
 
151
151
  it('rolls back to UNKNOWN when there was no prior value', async () => {
@@ -5,6 +5,7 @@ import type {
5
5
  BulkUnfollowResult,
6
6
  FollowMutationResult,
7
7
  } from '@oxy.so/core';
8
+ import { type FollowErrorCode, toFollowErrorCode } from '../utils/followErrors';
8
9
 
9
10
  interface FollowState {
10
11
  // Tri-state follow map: a MISSING key means UNKNOWN (status not yet resolved),
@@ -14,7 +15,8 @@ interface FollowState {
14
15
  followingUsers: Record<string, boolean>;
15
16
  loadingUsers: Record<string, boolean>;
16
17
  fetchingUsers: Record<string, boolean>;
17
- errors: Record<string, string | null>;
18
+ /** Why the last read or write of a user failed, as a code — never text. */
19
+ errors: Record<string, FollowErrorCode | null>;
18
20
  // Follower counts for each user
19
21
  followerCounts: Record<string, number>;
20
22
  followingCounts: Record<string, number>;
@@ -192,14 +194,13 @@ export const useFollowStore = create<FollowState>((set, get) => ({
192
194
  })
193
195
  .catch((error: unknown) => {
194
196
  if (generation !== batchGeneration) return;
195
- const message =
196
- (error instanceof Error ? error.message : null) || 'Failed to fetch follow status';
197
+ const code = toFollowErrorCode(error, 'read_failed');
197
198
  set((state) => {
198
199
  const fetchingUsers = { ...state.fetchingUsers };
199
200
  const errors = { ...state.errors };
200
201
  for (const id of ids) {
201
202
  fetchingUsers[id] = false;
202
- errors[id] = message;
203
+ errors[id] = code;
203
204
  }
204
205
  return { fetchingUsers, errors };
205
206
  });
@@ -274,8 +275,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
274
275
  loadingUsers: { ...state.loadingUsers, [userId]: false },
275
276
  errors: {
276
277
  ...state.errors,
277
- [userId]:
278
- (error instanceof Error ? error.message : null) || 'Failed to update follow status',
278
+ [userId]: toFollowErrorCode(error, 'update_failed'),
279
279
  },
280
280
  };
281
281
  });
@@ -327,15 +327,14 @@ export const useFollowStore = create<FollowState>((set, get) => ({
327
327
  } else {
328
328
  followingUsers[entry.userId] = prev;
329
329
  }
330
- errors[entry.userId] = 'Failed to update follow status';
330
+ errors[entry.userId] = 'update_failed';
331
331
  }
332
332
  }
333
333
  return { followingUsers, loadingUsers, errors };
334
334
  });
335
335
  return result;
336
336
  } catch (error: unknown) {
337
- const message =
338
- (error instanceof Error ? error.message : null) || 'Failed to update follow status';
337
+ const code = toFollowErrorCode(error, 'update_failed');
339
338
  // Whole batch failed — roll ALL targets back to their prior values.
340
339
  set((state) => {
341
340
  const followingUsers = { ...state.followingUsers };
@@ -349,7 +348,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
349
348
  followingUsers[uid] = prev;
350
349
  }
351
350
  loadingUsers[uid] = false;
352
- errors[uid] = message;
351
+ errors[uid] = code;
353
352
  }
354
353
  return { followingUsers, loadingUsers, errors };
355
354
  });
@@ -401,15 +400,14 @@ export const useFollowStore = create<FollowState>((set, get) => ({
401
400
  } else {
402
401
  followingUsers[entry.userId] = prev;
403
402
  }
404
- errors[entry.userId] = 'Failed to update follow status';
403
+ errors[entry.userId] = 'update_failed';
405
404
  }
406
405
  }
407
406
  return { followingUsers, loadingUsers, errors };
408
407
  });
409
408
  return result;
410
409
  } catch (error: unknown) {
411
- const message =
412
- (error instanceof Error ? error.message : null) || 'Failed to update follow status';
410
+ const code = toFollowErrorCode(error, 'update_failed');
413
411
  // Whole batch failed — roll ALL targets back to their prior values.
414
412
  set((state) => {
415
413
  const followingUsers = { ...state.followingUsers };
@@ -423,7 +421,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
423
421
  followingUsers[uid] = prev;
424
422
  }
425
423
  loadingUsers[uid] = false;
426
- errors[uid] = message;
424
+ errors[uid] = code;
427
425
  }
428
426
  return { followingUsers, loadingUsers, errors };
429
427
  });
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { create } from 'zustand';
26
26
  import type { FollowApplicationMode, FollowRecord, FollowStatus } from '@oxy.so/contracts';
27
+ import type { FollowErrorCode } from '../utils/followErrors';
27
28
 
28
29
  /** The status of one target, or `undefined` when it has never been read. */
29
30
  type StatusMap = Record<string, FollowStatus | undefined>;
@@ -32,7 +33,8 @@ interface FollowTargetState {
32
33
  statuses: StatusMap;
33
34
  /** In-flight writes, per target. Reads do not set this — only mutations do. */
34
35
  pending: Record<string, boolean>;
35
- errors: Record<string, string | undefined>;
36
+ /** Why the last read or write of a target failed, as a code — never text. */
37
+ errors: Record<string, FollowErrorCode | undefined>;
36
38
 
37
39
  setStatus: (targetId: string, status: FollowStatus) => void;
38
40
  /**
@@ -42,7 +44,7 @@ interface FollowTargetState {
42
44
  */
43
45
  seed: (entries: Record<string, FollowStatus>) => void;
44
46
  setPending: (targetId: string, pending: boolean) => void;
45
- setError: (targetId: string, error: string | undefined) => void;
47
+ setError: (targetId: string, error: FollowErrorCode | undefined) => void;
46
48
  /** Drop everything. Called on identity change — see the note below. */
47
49
  reset: () => void;
48
50
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What a failed follow read or write leaves behind: a stable CODE, never text.
3
+ *
4
+ * The follow stores and hooks have no translator and must not pretend to — an
5
+ * English sentence written into a store reaches every locale's toast verbatim,
6
+ * and so does a server's `message`. They keep one of these codes instead, and
7
+ * the UI turns it into words in the user's language (`describeFollowError` in
8
+ * `components/followLabels.ts`).
9
+ *
10
+ * A code says either what the caller was doing (`read_failed`, `update_failed`,
11
+ * `mode_failed`) or — when the failure says more than that — why it failed,
12
+ * taken from the API's machine code and HTTP status rather than its message.
13
+ */
14
+ export type FollowErrorCode =
15
+ /** The status could not be read. */
16
+ | 'read_failed'
17
+ /** A follow or unfollow was refused. */
18
+ | 'update_failed'
19
+ /** Switching a follow off or back on in this application was refused. */
20
+ | 'mode_failed'
21
+ /** No response arrived: the device is offline, or the request timed out. */
22
+ | 'offline'
23
+ /** The session is gone; signing in again is the only fix. */
24
+ | 'signed_out'
25
+ /** This application lacks the permission the write needs. */
26
+ | 'forbidden'
27
+ /** The target or the relationship no longer exists. */
28
+ | 'not_found'
29
+ /** Too many writes in a short window. */
30
+ | 'rate_limited';
31
+
32
+ /** The codes that name the caller's action rather than a cause. */
33
+ export type FollowActionErrorCode = 'read_failed' | 'update_failed' | 'mode_failed';
34
+
35
+ const OFFLINE_CODES = new Set(['NETWORK_ERROR', 'CONNECTION_FAILED', 'TIMEOUT']);
36
+
37
+ /**
38
+ * The code for whatever a follow request rejected with.
39
+ *
40
+ * Read structurally — every `OxyServices` call rejects with an `OxyApiError`
41
+ * (or an `OxyAuthenticationError`), and both carry `status` and `code` — so a
42
+ * second copy of `@oxy.so/core` in an app cannot defeat an `instanceof`.
43
+ * Anything the cause does not explain falls back to the caller's `action`.
44
+ */
45
+ export function toFollowErrorCode(error: unknown, action: FollowActionErrorCode): FollowErrorCode {
46
+ const { status, code } = (error && typeof error === 'object' ? error : {}) as {
47
+ status?: unknown;
48
+ code?: unknown;
49
+ };
50
+ if (typeof code === 'string' && OFFLINE_CODES.has(code)) return 'offline';
51
+ if (status === 401) return 'signed_out';
52
+ if (status === 403) return 'forbidden';
53
+ if (status === 404) return 'not_found';
54
+ if (status === 429) return 'rate_limited';
55
+ return action;
56
+ }