@oxy.so/services 12.6.0 → 12.8.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/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/client.js +20 -0
- package/lib/commonjs/ui/client.js.map +1 -1
- package/lib/commonjs/ui/components/FollowButton.js +8 -12
- package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
- package/lib/commonjs/ui/components/FollowTargetButton.js +1 -2
- package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -1
- package/lib/commonjs/ui/components/followLabels.js +34 -0
- package/lib/commonjs/ui/components/followLabels.js.map +1 -1
- package/lib/commonjs/ui/hooks/useAccountColor.js +56 -0
- package/lib/commonjs/ui/hooks/useAccountColor.js.map +1 -0
- package/lib/commonjs/ui/hooks/useFollow.js +5 -4
- package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollowTarget.js +8 -8
- package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -1
- package/lib/commonjs/ui/stores/followStore.js +10 -9
- package/lib/commonjs/ui/stores/followStore.js.map +1 -1
- package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -1
- package/lib/commonjs/ui/utils/followErrors.js +45 -0
- package/lib/commonjs/ui/utils/followErrors.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/client.js +2 -0
- package/lib/module/ui/client.js.map +1 -1
- package/lib/module/ui/components/FollowButton.js +9 -13
- package/lib/module/ui/components/FollowButton.js.map +1 -1
- package/lib/module/ui/components/FollowTargetButton.js +2 -3
- package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
- package/lib/module/ui/components/followLabels.js +33 -1
- package/lib/module/ui/components/followLabels.js.map +1 -1
- package/lib/module/ui/hooks/useAccountColor.js +51 -0
- package/lib/module/ui/hooks/useAccountColor.js.map +1 -0
- package/lib/module/ui/hooks/useFollow.js +5 -4
- package/lib/module/ui/hooks/useFollow.js.map +1 -1
- package/lib/module/ui/hooks/useFollowTarget.js +8 -8
- package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
- package/lib/module/ui/stores/followStore.js +10 -9
- package/lib/module/ui/stores/followStore.js.map +1 -1
- package/lib/module/ui/stores/followTargetStore.js.map +1 -1
- package/lib/module/ui/utils/followErrors.js +41 -0
- package/lib/module/ui/utils/followErrors.js.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +2 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/client.d.ts +3 -0
- package/lib/typescript/commonjs/ui/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/followLabels.d.ts +13 -0
- package/lib/typescript/commonjs/ui/components/followLabels.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useAccountColor.d.ts +25 -0
- package/lib/typescript/commonjs/ui/hooks/useAccountColor.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +5 -4
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.types.d.ts +3 -2
- package/lib/typescript/commonjs/ui/hooks/useFollow.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts +4 -2
- package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts +3 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +4 -2
- package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/followErrors.d.ts +42 -0
- package/lib/typescript/commonjs/ui/utils/followErrors.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +2 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/client.d.ts +3 -0
- package/lib/typescript/module/ui/client.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/followLabels.d.ts +13 -0
- package/lib/typescript/module/ui/components/followLabels.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useAccountColor.d.ts +25 -0
- package/lib/typescript/module/ui/hooks/useAccountColor.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useFollow.d.ts +5 -4
- package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollow.types.d.ts +3 -2
- package/lib/typescript/module/ui/hooks/useFollow.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts +4 -2
- package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts +3 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followTargetStore.d.ts +4 -2
- package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/followErrors.d.ts +42 -0
- package/lib/typescript/module/ui/utils/followErrors.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/index.ts +2 -0
- package/src/ui/client.ts +3 -0
- package/src/ui/components/FollowButton.tsx +14 -12
- package/src/ui/components/FollowTargetButton.tsx +7 -3
- package/src/ui/components/followLabels.ts +36 -2
- package/src/ui/hooks/useAccountColor.ts +52 -0
- package/src/ui/hooks/useFollow.ts +22 -10
- package/src/ui/hooks/useFollow.types.ts +6 -2
- package/src/ui/hooks/useFollowTarget.ts +15 -10
- package/src/ui/stores/__tests__/followStore.test.ts +1 -1
- package/src/ui/stores/followStore.ts +12 -14
- package/src/ui/stores/followTargetStore.ts +4 -2
- package/src/ui/utils/followErrors.ts +56 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"followTargetStore.d.ts","sourceRoot":"","sources":["../../../../../src/ui/stores/followTargetStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,qBAAqB,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"followTargetStore.d.ts","sourceRoot":"","sources":["../../../../../src/ui/stores/followTargetStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,qBAAqB,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC3F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAAuB,CAAC;AAE7D,4EAA4E;AAC5E,KAAK,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,CAAC,CAAC;AAE1D,UAAU,iBAAiB;IACzB,QAAQ,EAAE,SAAS,CAAC;IACpB,+EAA+E;IAC/E,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS,CAAC,CAAC;IAEpD,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,KAAK,IAAI,CAAC;IAC5D;;;;OAIG;IACH,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,KAAK,IAAI,CAAC;IACtD,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IACzD,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,SAAS,KAAK,IAAI,CAAC;IACzE,uEAAuE;IACvE,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,YAInC,CAAC;AAEF,eAAO,MAAM,oBAAoB,gFAoB9B,CAAC;AAEJ;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAEhE;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAGpE;AAED,kFAAkF;AAClF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAQvE;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAM9F;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,YAAY,EACpB,IAAI,EAAE,qBAAqB,GAC1B,YAAY,CAad"}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
/** The codes that name the caller's action rather than a cause. */
|
|
32
|
+
export type FollowActionErrorCode = 'read_failed' | 'update_failed' | 'mode_failed';
|
|
33
|
+
/**
|
|
34
|
+
* The code for whatever a follow request rejected with.
|
|
35
|
+
*
|
|
36
|
+
* Read structurally — every `OxyServices` call rejects with an `OxyApiError`
|
|
37
|
+
* (or an `OxyAuthenticationError`), and both carry `status` and `code` — so a
|
|
38
|
+
* second copy of `@oxy.so/core` in an app cannot defeat an `instanceof`.
|
|
39
|
+
* Anything the cause does not explain falls back to the caller's `action`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function toFollowErrorCode(error: unknown, action: FollowActionErrorCode): FollowErrorCode;
|
|
42
|
+
//# sourceMappingURL=followErrors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"followErrors.d.ts","sourceRoot":"","sources":["../../../../../src/ui/utils/followErrors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,eAAe;AACzB,oCAAoC;AAClC,aAAa;AACf,wCAAwC;GACtC,eAAe;AACjB,yEAAyE;GACvE,aAAa;AACf,4EAA4E;GAC1E,SAAS;AACX,6DAA6D;GAC3D,YAAY;AACd,6DAA6D;GAC3D,WAAW;AACb,uDAAuD;GACrD,WAAW;AACb,yCAAyC;GACvC,cAAc,CAAC;AAEnB,mEAAmE;AACnE,MAAM,MAAM,qBAAqB,GAAG,aAAa,GAAG,eAAe,GAAG,aAAa,CAAC;AAIpF;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,qBAAqB,GAAG,eAAe,CAWhG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/services",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.8.0",
|
|
4
4
|
"description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
"prepublishOnly": "node ../../scripts/assert-bun-publish.mjs && bun run typescript && bun run test && bun run build"
|
|
152
152
|
},
|
|
153
153
|
"dependencies": {
|
|
154
|
-
"@oxy.so/contracts": "^5.
|
|
154
|
+
"@oxy.so/contracts": "^5.5.0",
|
|
155
155
|
"color": "^5.0.3",
|
|
156
156
|
"@peable.to/sdk": "0.4.1"
|
|
157
157
|
},
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
"@commitlint/config-conventional": "^21.2.0",
|
|
161
161
|
"@expo/app-integrity": "~58.0.2",
|
|
162
162
|
"@expo/vector-icons": "^15.0.3",
|
|
163
|
-
"@oxy.so/core": "6.
|
|
163
|
+
"@oxy.so/core": "6.11.0",
|
|
164
164
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
165
165
|
"@react-native-community/netinfo": "^12.0.1",
|
|
166
166
|
"@react-native/babel-preset": "^0.88.0",
|
|
@@ -210,12 +210,12 @@
|
|
|
210
210
|
"expo-video": "~58.0.11"
|
|
211
211
|
},
|
|
212
212
|
"//@oxy.so/bloom": "Bloom 11.1.0 adds the shared grouped sections, usage card, subscription plans, frame appearance and overlay navigation APIs used by this SDK. First-party Bloom peers remain open above this tested minimum.",
|
|
213
|
-
"//@oxy.so/core": "Core 6.
|
|
213
|
+
"//@oxy.so/core": "Core 6.11.0 supplies the `follow.errors.*` strings the follow error codes are worded with; 6.4.0 the `follow.*` strings the follow buttons read for every verb and language; 6.1.0 the `oxy.auth.email.{startChange,change,remove}` and `oxy.auth.password.remove` the sign-in & security panels call. Keep one Core instance per app; raise this floor with the root catalogue when new runtime APIs are required.",
|
|
214
214
|
"peerDependencies": {
|
|
215
215
|
"@expo/app-integrity": ">=56.0.0 <59.0.0",
|
|
216
216
|
"@expo/vector-icons": "^15.0.3",
|
|
217
217
|
"@oxy.so/bloom": ">=11.1.0",
|
|
218
|
-
"@oxy.so/core": "^6.
|
|
218
|
+
"@oxy.so/core": "^6.11.0",
|
|
219
219
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
220
220
|
"@react-native-community/netinfo": ">=11.4.1 <13.0.0",
|
|
221
221
|
"@tanstack/query-async-storage-persister": "^5.101",
|
package/src/index.ts
CHANGED
|
@@ -346,6 +346,8 @@ export {
|
|
|
346
346
|
export type { FollowDuration, FollowMenuItem } from './ui/components/followRules';
|
|
347
347
|
export type { FollowTargetButtonProps } from './ui/components/FollowTargetButton';
|
|
348
348
|
export type { FollowVerb, FollowLabels } from './ui/components/followLabels';
|
|
349
|
+
export { useFollowErrorMessage } from './ui/components/followLabels';
|
|
350
|
+
export type { FollowErrorCode } from './ui/utils/followErrors';
|
|
349
351
|
export { useFollowTarget } from './ui/hooks/useFollowTarget';
|
|
350
352
|
export type { UseFollowTargetResult } from './ui/hooks/useFollowTarget';
|
|
351
353
|
export {
|
package/src/ui/client.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type {
|
|
|
31
31
|
export { FollowTargetButton } from './components/FollowTargetButton';
|
|
32
32
|
export type { FollowTargetButtonProps } from './components/FollowTargetButton';
|
|
33
33
|
export type { FollowVerb, FollowLabels } from './components/followLabels';
|
|
34
|
+
export { useFollowErrorMessage } from './components/followLabels';
|
|
35
|
+
export type { FollowErrorCode } from './utils/followErrors';
|
|
34
36
|
export { default as ProfileButton } from './components/ProfileButton';
|
|
35
37
|
export type { ProfileButtonProps } from './components/ProfileButton';
|
|
36
38
|
export { default as PeableButton } from './components/PeableButton';
|
|
@@ -45,6 +47,7 @@ export { useOxy, useOptionalOxy, OxyProviderMissingError } from './context/OxyCo
|
|
|
45
47
|
|
|
46
48
|
// Hooks
|
|
47
49
|
export { useAuth } from './hooks/useAuth';
|
|
50
|
+
export { useAccountColor, resolveAccountColor } from './hooks/useAccountColor';
|
|
48
51
|
// This app's Oxy-signed assertion of which app it is (ADR 0045), for an SDK
|
|
49
52
|
// that attaches it to its own requests without pulling in the account screens.
|
|
50
53
|
export {
|
|
@@ -9,8 +9,14 @@ import { useFollowStore } from '../stores/followStore';
|
|
|
9
9
|
import type { OxyServices, BulkFollowResult, BulkUnfollowResult } from '@oxy.so/core';
|
|
10
10
|
import { useShallow } from 'zustand/react/shallow';
|
|
11
11
|
import { useI18n } from '../hooks/useI18n';
|
|
12
|
+
import { toFollowErrorCode } from '../utils/followErrors';
|
|
12
13
|
import { describeFollowAllButton, describeFollowButton } from './followButtonAccessibility';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
describeFollowError,
|
|
16
|
+
type FollowLabels,
|
|
17
|
+
type FollowVerb,
|
|
18
|
+
useFollowLabels,
|
|
19
|
+
} from './followLabels';
|
|
14
20
|
|
|
15
21
|
/** Props shared by both single- and multi-user follow modes. */
|
|
16
22
|
interface FollowButtonBaseProps {
|
|
@@ -101,13 +107,10 @@ const FollowButtonInner = memo(function FollowButtonInner({
|
|
|
101
107
|
// Ignore presses while a mutation is in flight or the status is still unknown.
|
|
102
108
|
if (disabled || isLoading || !isKnown) return;
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
109
|
-
toast.error(error.message || t('follow.errors.update'));
|
|
110
|
-
}
|
|
110
|
+
// `toggleFollow` never rejects: a refusal is rolled back and its code
|
|
111
|
+
// recorded in the store, which is what the toast words.
|
|
112
|
+
if (await toggleFollow()) onFollowChange?.(!isFollowing);
|
|
113
|
+
else toast.error(describeFollowError(useFollowStore.getState().errors[userId], t));
|
|
111
114
|
},
|
|
112
115
|
[
|
|
113
116
|
disabled,
|
|
@@ -117,6 +120,7 @@ const FollowButtonInner = memo(function FollowButtonInner({
|
|
|
117
120
|
onFollowChange,
|
|
118
121
|
isFollowing,
|
|
119
122
|
preventParentActions,
|
|
123
|
+
userId,
|
|
120
124
|
t,
|
|
121
125
|
],
|
|
122
126
|
);
|
|
@@ -234,8 +238,7 @@ const FollowButtonMultiInner = memo(function FollowButtonMultiInner({
|
|
|
234
238
|
onFollowChange?.(false);
|
|
235
239
|
onBulkUnfollow?.(result);
|
|
236
240
|
} catch (err: unknown) {
|
|
237
|
-
|
|
238
|
-
toast.error(error.message || t('follow.errors.update'));
|
|
241
|
+
toast.error(describeFollowError(toFollowErrorCode(err, 'update_failed'), t));
|
|
239
242
|
} finally {
|
|
240
243
|
setIsSubmitting(false);
|
|
241
244
|
}
|
|
@@ -261,8 +264,7 @@ const FollowButtonMultiInner = memo(function FollowButtonMultiInner({
|
|
|
261
264
|
}
|
|
262
265
|
onBulkFollow?.(result);
|
|
263
266
|
} catch (err: unknown) {
|
|
264
|
-
|
|
265
|
-
toast.error(error.message || t('follow.errors.update'));
|
|
267
|
+
toast.error(describeFollowError(toFollowErrorCode(err, 'update_failed'), t));
|
|
266
268
|
} finally {
|
|
267
269
|
setIsSubmitting(false);
|
|
268
270
|
}
|
|
@@ -60,7 +60,12 @@ import {
|
|
|
60
60
|
type FollowDuration,
|
|
61
61
|
type FollowMenuItem,
|
|
62
62
|
} from './followRules';
|
|
63
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
describeFollowError,
|
|
65
|
+
type FollowLabels,
|
|
66
|
+
type FollowVerb,
|
|
67
|
+
useFollowLabels,
|
|
68
|
+
} from './followLabels';
|
|
64
69
|
import { useI18n } from '../hooks/useI18n';
|
|
65
70
|
|
|
66
71
|
const HOUR = 60 * 60;
|
|
@@ -149,8 +154,7 @@ export const FollowTargetButton = memo(function FollowTargetButton({
|
|
|
149
154
|
);
|
|
150
155
|
|
|
151
156
|
const reportMutationFailure = useCallback(() => {
|
|
152
|
-
|
|
153
|
-
toast.error(message);
|
|
157
|
+
toast.error(describeFollowError(useFollowTargetStore.getState().errors[targetId], t));
|
|
154
158
|
}, [targetId, t]);
|
|
155
159
|
|
|
156
160
|
const label = useMemo(() => {
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* drawn.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useMemo } from 'react';
|
|
12
|
-
import { useI18n } from '../hooks/useI18n';
|
|
11
|
+
import { useCallback, useMemo } from 'react';
|
|
12
|
+
import { type Translate, useI18n } from '../hooks/useI18n';
|
|
13
|
+
import type { FollowErrorCode } from '../utils/followErrors';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The small vocabulary of verbs the ecosystem actually uses. An application
|
|
@@ -46,3 +47,36 @@ export function useFollowLabels(
|
|
|
46
47
|
[t, verb, overrides],
|
|
47
48
|
);
|
|
48
49
|
}
|
|
50
|
+
|
|
51
|
+
/** The `follow.errors.*` string for each code — the one place a code becomes words. */
|
|
52
|
+
const FOLLOW_ERROR_KEYS: Record<FollowErrorCode, string> = {
|
|
53
|
+
read_failed: 'follow.errors.read',
|
|
54
|
+
update_failed: 'follow.errors.update',
|
|
55
|
+
mode_failed: 'follow.errors.mode',
|
|
56
|
+
offline: 'follow.errors.offline',
|
|
57
|
+
signed_out: 'follow.errors.signedOut',
|
|
58
|
+
forbidden: 'follow.errors.forbidden',
|
|
59
|
+
not_found: 'follow.errors.notFound',
|
|
60
|
+
rate_limited: 'follow.errors.rateLimited',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What a follow error says, in the current language. Every component that
|
|
65
|
+
* shows a follow failure reads it here; a missing code (a failure the store
|
|
66
|
+
* did not record) reads as the generic "could not update".
|
|
67
|
+
*/
|
|
68
|
+
export function describeFollowError(
|
|
69
|
+
code: FollowErrorCode | null | undefined,
|
|
70
|
+
t: Translate,
|
|
71
|
+
): string {
|
|
72
|
+
return t(FOLLOW_ERROR_KEYS[code ?? 'update_failed']);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `describeFollowError` bound to the current language, for an application that
|
|
77
|
+
* renders `useFollowTarget().error` (or `useFollow().error`) itself.
|
|
78
|
+
*/
|
|
79
|
+
export function useFollowErrorMessage(): (code: FollowErrorCode | null | undefined) => string {
|
|
80
|
+
const { t } = useI18n();
|
|
81
|
+
return useCallback((code) => describeFollowError(code, t), [t]);
|
|
82
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAccountColor
|
|
3
|
+
*
|
|
4
|
+
* The account's colour is one value that every Oxy app wears: `users.color`, a
|
|
5
|
+
* Bloom preset NAME the API only stores after its entitlement check (`oxy` and
|
|
6
|
+
* `faircoin` belong to the account with that handle, `mono` to an active
|
|
7
|
+
* benefit). This hook is the single place an app turns that stored name into its
|
|
8
|
+
* Bloom theme, so two apps cannot disagree about which field wins.
|
|
9
|
+
*
|
|
10
|
+
* The portable `themePreference.colorPreset` is the fallback for an account that
|
|
11
|
+
* has no `color` yet. Mode (light/dark) is deliberately out of scope: whether to
|
|
12
|
+
* follow the account's mode is each app's own choice, the colour is not.
|
|
13
|
+
*
|
|
14
|
+
* An app with a stricter rule of its own (a plan the SDK cannot see) passes
|
|
15
|
+
* `canUse`; a name it refuses is simply not applied, so the app keeps its own
|
|
16
|
+
* default instead of a colour the viewer is not entitled to.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect } from 'react';
|
|
20
|
+
import type { User } from '@oxy.so/core';
|
|
21
|
+
import { APP_COLOR_PRESETS, useBloomTheme, type AppColorName } from '@oxy.so/bloom/theme';
|
|
22
|
+
import { useAuth } from './useAuth';
|
|
23
|
+
|
|
24
|
+
function isAppColorName(value: unknown): value is AppColorName {
|
|
25
|
+
return (
|
|
26
|
+
typeof value === 'string' && Object.prototype.hasOwnProperty.call(APP_COLOR_PRESETS, value)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The preset an account wears, or `null` when it has none Bloom knows. */
|
|
31
|
+
export function resolveAccountColor(
|
|
32
|
+
user: Pick<User, 'color' | 'themePreference'> | null | undefined,
|
|
33
|
+
): AppColorName | null {
|
|
34
|
+
const stored = user?.color ?? user?.themePreference?.colorPreset;
|
|
35
|
+
return isAppColorName(stored) ? stored : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useAccountColor({
|
|
39
|
+
canUse,
|
|
40
|
+
}: {
|
|
41
|
+
canUse?: (name: AppColorName) => boolean;
|
|
42
|
+
} = {}): void {
|
|
43
|
+
const { isAuthenticated, user } = useAuth();
|
|
44
|
+
const { colorPreset, setColorPreset } = useBloomTheme();
|
|
45
|
+
const accountColor = resolveAccountColor(user);
|
|
46
|
+
const allowed = accountColor !== null && (canUse?.(accountColor) ?? true);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!isAuthenticated || !accountColor || !allowed || accountColor === colorPreset) return;
|
|
50
|
+
setColorPreset(accountColor);
|
|
51
|
+
}, [isAuthenticated, accountColor, allowed, colorPreset, setColorPreset]);
|
|
52
|
+
}
|
|
@@ -2,7 +2,13 @@ import { useCallback, useMemo, useState } from 'react';
|
|
|
2
2
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
3
|
import { useFollowStore } from '../stores/followStore';
|
|
4
4
|
import { useOxy } from '../context/oxyRuntimeContext';
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
OxyAuthenticationError,
|
|
7
|
+
type OxyServices,
|
|
8
|
+
type BulkFollowResult,
|
|
9
|
+
type BulkUnfollowResult,
|
|
10
|
+
} from '@oxy.so/core';
|
|
11
|
+
import type { FollowErrorCode } from '../utils/followErrors';
|
|
6
12
|
import { useShallow } from 'zustand/react/shallow';
|
|
7
13
|
import { queryKeys } from './queries/queryKeys';
|
|
8
14
|
import { patchCachedUserRelationship } from './queries/userCacheRelationship';
|
|
@@ -79,8 +85,8 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
79
85
|
// nested `followData` shape is then assembled in a `useMemo` below.
|
|
80
86
|
const followFlat = useFollowStore(
|
|
81
87
|
useShallow((s) => {
|
|
82
|
-
if (isSingleUser) return {} as Record<string, boolean |
|
|
83
|
-
const flat: Record<string, boolean |
|
|
88
|
+
if (isSingleUser) return {} as Record<string, boolean | FollowErrorCode | null>;
|
|
89
|
+
const flat: Record<string, boolean | FollowErrorCode | null> = {};
|
|
84
90
|
for (const uid of userIds) {
|
|
85
91
|
flat[`${uid}:isFollowing`] = s.followingUsers[uid] ?? false;
|
|
86
92
|
flat[`${uid}:isLoading`] = s.loadingUsers[uid] ?? false;
|
|
@@ -91,14 +97,16 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
91
97
|
);
|
|
92
98
|
|
|
93
99
|
const followData = useMemo(() => {
|
|
94
|
-
const data: Record<
|
|
95
|
-
|
|
100
|
+
const data: Record<
|
|
101
|
+
string,
|
|
102
|
+
{ isFollowing: boolean; isLoading: boolean; error: FollowErrorCode | null }
|
|
103
|
+
> = {};
|
|
96
104
|
if (isSingleUser) return data;
|
|
97
105
|
for (const uid of userIds) {
|
|
98
106
|
data[uid] = {
|
|
99
107
|
isFollowing: Boolean(followFlat[`${uid}:isFollowing`]),
|
|
100
108
|
isLoading: Boolean(followFlat[`${uid}:isLoading`]),
|
|
101
|
-
error: (followFlat[`${uid}:error`] as
|
|
109
|
+
error: (followFlat[`${uid}:error`] as FollowErrorCode | null) ?? null,
|
|
102
110
|
};
|
|
103
111
|
}
|
|
104
112
|
return data;
|
|
@@ -128,7 +136,8 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
128
136
|
const toggleFollow = useCallback(async () => {
|
|
129
137
|
if (!isSingleUser || !userId)
|
|
130
138
|
throw new Error('toggleFollow is only available for single user mode');
|
|
131
|
-
if (!canUsePrivateApi)
|
|
139
|
+
if (!canUsePrivateApi)
|
|
140
|
+
throw new OxyAuthenticationError('Authentication is required to follow users');
|
|
132
141
|
const currentlyFollowing = useFollowStore.getState().followingUsers[userId] ?? false;
|
|
133
142
|
const accepted = await useFollowStore
|
|
134
143
|
.getState()
|
|
@@ -209,7 +218,8 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
209
218
|
// Multi-user callbacks
|
|
210
219
|
const toggleFollowForUser = useCallback(
|
|
211
220
|
async (targetUserId: string) => {
|
|
212
|
-
if (!canUsePrivateApi)
|
|
221
|
+
if (!canUsePrivateApi)
|
|
222
|
+
throw new OxyAuthenticationError('Authentication is required to follow users');
|
|
213
223
|
const currentState = useFollowStore.getState().followingUsers[targetUserId] ?? false;
|
|
214
224
|
const accepted = await useFollowStore
|
|
215
225
|
.getState()
|
|
@@ -240,13 +250,15 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
240
250
|
|
|
241
251
|
// Bulk follow — follows ALL users in ONE network call (never unfollows).
|
|
242
252
|
const followAllUsers = useCallback(async (): Promise<BulkFollowResult> => {
|
|
243
|
-
if (!canUsePrivateApi)
|
|
253
|
+
if (!canUsePrivateApi)
|
|
254
|
+
throw new OxyAuthenticationError('Authentication is required to follow users');
|
|
244
255
|
return useFollowStore.getState().followManyUsers(userIds, oxyServices);
|
|
245
256
|
}, [canUsePrivateApi, userIds, oxyServices]);
|
|
246
257
|
|
|
247
258
|
// Bulk unfollow — unfollows ALL users in ONE network call (idempotent; never follows).
|
|
248
259
|
const unfollowAllUsers = useCallback(async (): Promise<BulkUnfollowResult> => {
|
|
249
|
-
if (!canUsePrivateApi)
|
|
260
|
+
if (!canUsePrivateApi)
|
|
261
|
+
throw new OxyAuthenticationError('Authentication is required to unfollow users');
|
|
250
262
|
return useFollowStore.getState().unfollowManyUsers(userIds, oxyServices);
|
|
251
263
|
}, [canUsePrivateApi, userIds, oxyServices]);
|
|
252
264
|
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// Expand this as needed to better reflect the real return type.
|
|
3
3
|
|
|
4
4
|
import type { BulkFollowResult } from '@oxy.so/core';
|
|
5
|
+
import type { FollowErrorCode } from '../utils/followErrors';
|
|
5
6
|
|
|
6
7
|
export type SingleFollowResult = {
|
|
7
8
|
isFollowing: boolean;
|
|
8
9
|
isLoading: boolean;
|
|
9
|
-
error:
|
|
10
|
+
error: FollowErrorCode | null;
|
|
10
11
|
toggleFollow: () => Promise<void>;
|
|
11
12
|
setFollowStatus: (following: boolean) => void;
|
|
12
13
|
fetchStatus: () => Promise<void>;
|
|
@@ -20,7 +21,10 @@ export type SingleFollowResult = {
|
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
export type MultiFollowResult = {
|
|
23
|
-
followData: Record<
|
|
24
|
+
followData: Record<
|
|
25
|
+
string,
|
|
26
|
+
{ isFollowing: boolean; isLoading: boolean; error: FollowErrorCode | null }
|
|
27
|
+
>;
|
|
24
28
|
toggleFollowForUser: (userId: string) => Promise<void>;
|
|
25
29
|
setFollowStatusForUser: (userId: string, following: boolean) => void;
|
|
26
30
|
fetchStatusForUser: (userId: string) => Promise<void>;
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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('
|
|
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
|
-
|
|
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
|
|
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] =
|
|
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] = '
|
|
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
|
|
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] =
|
|
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] = '
|
|
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
|
|
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] =
|
|
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
|
-
|
|
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:
|
|
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
|
}
|