@oxyhq/services 27.0.0 → 27.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.
Files changed (60) hide show
  1. package/lib/commonjs/index.js +29 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowButton.js +3 -5
  4. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/FollowTargetButton.js +292 -0
  6. package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -0
  7. package/lib/commonjs/ui/hooks/useFollowTarget.js +140 -0
  8. package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -0
  9. package/lib/commonjs/ui/screens/AccountMembersScreen.js +49 -6
  10. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  11. package/lib/commonjs/ui/stores/followTargetStore.js +113 -0
  12. package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -0
  13. package/lib/commonjs/ui/stores/resetSessionScopedStores.js +2 -0
  14. package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -1
  15. package/lib/module/index.js +4 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/components/FollowButton.js +3 -5
  18. package/lib/module/ui/components/FollowButton.js.map +1 -1
  19. package/lib/module/ui/components/FollowTargetButton.js +287 -0
  20. package/lib/module/ui/components/FollowTargetButton.js.map +1 -0
  21. package/lib/module/ui/hooks/useFollowTarget.js +136 -0
  22. package/lib/module/ui/hooks/useFollowTarget.js.map +1 -0
  23. package/lib/module/ui/screens/AccountMembersScreen.js +49 -6
  24. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  25. package/lib/module/ui/stores/followTargetStore.js +107 -0
  26. package/lib/module/ui/stores/followTargetStore.js.map +1 -0
  27. package/lib/module/ui/stores/resetSessionScopedStores.js +2 -0
  28. package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +5 -0
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +119 -0
  33. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -0
  34. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts +41 -0
  35. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -0
  36. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +70 -0
  38. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
  40. package/lib/typescript/module/index.d.ts +5 -0
  41. package/lib/typescript/module/index.d.ts.map +1 -1
  42. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  43. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +119 -0
  44. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -0
  45. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts +41 -0
  46. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -0
  47. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/stores/followTargetStore.d.ts +70 -0
  49. package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -0
  50. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
  51. package/package.json +3 -3
  52. package/src/index.ts +11 -0
  53. package/src/ui/components/FollowButton.tsx +3 -5
  54. package/src/ui/components/FollowTargetButton.tsx +326 -0
  55. package/src/ui/components/__tests__/followTargetButton.test.ts +132 -0
  56. package/src/ui/hooks/useFollowTarget.ts +194 -0
  57. package/src/ui/screens/AccountMembersScreen.tsx +62 -6
  58. package/src/ui/stores/__tests__/resetSessionScopedStores.test.ts +23 -0
  59. package/src/ui/stores/followTargetStore.ts +119 -0
  60. package/src/ui/stores/resetSessionScopedStores.ts +2 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `useFollowTarget` — the one hook every application uses to follow anything.
3
+ *
4
+ * Wraps the `/v2/follows` SDK methods with the store above, an optimistic
5
+ * update, and a rollback. Nothing in here knows what kind of thing it is
6
+ * following, which is the property that lets an application the SDK has never
7
+ * heard of use it without a release of this package.
8
+ *
9
+ * ## Optimism, and its one limit
10
+ *
11
+ * A follow flips the button immediately and rolls back if the server refuses,
12
+ * because the round trip is long enough to feel like a bug otherwise. But an
13
+ * optimistic follow has no relationship id until the server answers, and every
14
+ * other operation addresses the relationship — so those stay disabled for the
15
+ * width of one request rather than being sent with a guessed id.
16
+ */
17
+ import type { FollowStatus } from '@oxyhq/contracts';
18
+ export interface UseFollowTargetResult {
19
+ status: FollowStatus;
20
+ /**
21
+ * Whether the user follows this at all — NOT `effectiveState`, which reports
22
+ * `not_following` for a follow merely switched off in this application.
23
+ */
24
+ isFollowing: boolean;
25
+ /** True until the first read settles. Distinct from "not following". */
26
+ isUnknown: boolean;
27
+ isPending: boolean;
28
+ error: string | undefined;
29
+ follow: (options?: {
30
+ expiresIn?: number;
31
+ }) => Promise<void>;
32
+ unfollow: () => Promise<void>;
33
+ /** Stop acting on this follow HERE, without giving it up everywhere. */
34
+ disableHere: () => Promise<void>;
35
+ enableHere: () => Promise<void>;
36
+ refresh: () => Promise<void>;
37
+ }
38
+ export declare function useFollowTarget(targetId: string | undefined, options?: {
39
+ initialStatus?: FollowStatus;
40
+ }): UseFollowTargetResult;
41
+ //# sourceMappingURL=useFollowTarget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useFollowTarget.d.ts","sourceRoot":"","sources":["../../../../../src/ui/hooks/useFollowTarget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AASrD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,YAAY,CAAC;IACrB;;;OAGG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,wEAAwE;IACxE,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,MAAM,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,wEAAwE;IACxE,WAAW,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B;AAED,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,OAAO,CAAC,EAAE;IAAE,aAAa,CAAC,EAAE,YAAY,CAAA;CAAE,GACzC,qBAAqB,CAgJvB"}
@@ -1 +1 @@
1
- {"version":3,"file":"AccountMembersScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/AccountMembersScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAiB/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AAkB3D;;;;GAIG;AACH,QAAA,MAAM,oBAAoB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CAyXnD,CAAC;AAEF,eAAe,oBAAoB,CAAC"}
1
+ {"version":3,"file":"AccountMembersScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/AccountMembersScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAkB/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AAkB3D;;;;GAIG;AACH,QAAA,MAAM,oBAAoB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CAgbnD,CAAC;AAEF,eAAe,oBAAoB,CAAC"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The follow-graph store — the client's single cache authority for #809.
3
+ *
4
+ * The SDK caches none of these reads on purpose (a status cached across a write
5
+ * is the "follow reverts after navigating away and back" bug), which makes this
6
+ * store the one place a status lives between a write and the next read. If a
7
+ * second cache appears above it, the two will disagree the moment one is
8
+ * invalidated and the other is not.
9
+ *
10
+ * ## Keyed by target id, not by user id
11
+ *
12
+ * The legacy `followStore` is keyed by user id because the only followable
13
+ * thing was a user. Here a key is a target of any kind — a topic, a store, an
14
+ * artist, a channel — so nothing in this file may assume what it is looking at.
15
+ * That is the property that lets an application the SDK has never heard of use
16
+ * it without a release.
17
+ *
18
+ * ## Why the status is stored whole
19
+ *
20
+ * Not a boolean. `globalState`, `applicationMode` and `effectiveState` are
21
+ * three separate answers, and a UI that keeps only the last one cannot explain
22
+ * why a follow the user can see in their list does nothing in this app.
23
+ */
24
+ import type { FollowApplicationMode, FollowStatus } from '@oxyhq/contracts';
25
+ /** The status of one target, or `undefined` when it has never been read. */
26
+ type StatusMap = Record<string, FollowStatus | undefined>;
27
+ interface FollowTargetState {
28
+ statuses: StatusMap;
29
+ /** In-flight writes, per target. Reads do not set this — only mutations do. */
30
+ pending: Record<string, boolean>;
31
+ errors: Record<string, string | undefined>;
32
+ setStatus: (targetId: string, status: FollowStatus) => void;
33
+ /**
34
+ * Seed many statuses at once — from a follow list, a feed payload, anything
35
+ * that already knows. Saves one request per rendered row, which is the
36
+ * difference between a list that paints and a list that flickers.
37
+ */
38
+ seed: (entries: Record<string, FollowStatus>) => void;
39
+ setPending: (targetId: string, pending: boolean) => void;
40
+ setError: (targetId: string, error: string | undefined) => void;
41
+ /** Drop everything. Called on identity change — see the note below. */
42
+ reset: () => void;
43
+ }
44
+ /**
45
+ * The state one target is in before the server has been asked. Distinct from
46
+ * "not following": a button that renders a follow action while the answer is
47
+ * unknown invites a follow the user did not intend, so callers check
48
+ * `isUnknown` rather than reading this as an answer.
49
+ */
50
+ export declare const UNKNOWN_FOLLOW_STATUS: FollowStatus;
51
+ export declare const useFollowTargetStore: import("zustand").UseBoundStore<import("zustand").StoreApi<FollowTargetState>>;
52
+ /**
53
+ * Whether the user follows this at all, anywhere.
54
+ *
55
+ * Deliberately NOT `effectiveState !== 'not_following'`: a follow switched off
56
+ * in this application reports `not_following` — correctly, since the question
57
+ * that field answers is "does this act here" — and a button that read it as
58
+ * "not followed" would offer to follow something already followed.
59
+ */
60
+ export declare function isFollowedGlobally(status: FollowStatus): boolean;
61
+ /**
62
+ * Apply a mode change to a cached status without a round trip.
63
+ *
64
+ * Exported because the optimistic path and the settled path must agree on what
65
+ * `effectiveState` becomes; deriving it in two places is how they drift. Mirrors
66
+ * the server's own derivation, which is the authority.
67
+ */
68
+ export declare function withApplicationMode(status: FollowStatus, mode: FollowApplicationMode): FollowStatus;
69
+ export {};
70
+ //# sourceMappingURL=followTargetStore.d.ts.map
@@ -0,0 +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,MAAM,kBAAkB,CAAC;AAE5E,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,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IAE3C,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,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAChE,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;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,YAAY,EACpB,IAAI,EAAE,qBAAqB,GAC1B,YAAY,CAad"}
@@ -1 +1 @@
1
- {"version":3,"file":"resetSessionScopedStores.d.ts","sourceRoot":"","sources":["../../../../../src/ui/stores/resetSessionScopedStores.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C"}
1
+ {"version":3,"file":"resetSessionScopedStores.d.ts","sourceRoot":"","sources":["../../../../../src/ui/stores/resetSessionScopedStores.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAG/C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "27.0.0",
3
+ "version": "27.1.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",
@@ -138,7 +138,7 @@
138
138
  }
139
139
  },
140
140
  "dependencies": {
141
- "@oxyhq/contracts": "0.23.0",
141
+ "@oxyhq/contracts": "0.24.0",
142
142
  "@simplewebauthn/browser": "^13.3.0",
143
143
  "color": "^4.2.3"
144
144
  },
@@ -147,7 +147,7 @@
147
147
  "@commitlint/cli": "^21.2.1",
148
148
  "@commitlint/config-conventional": "^21.2.0",
149
149
  "@expo/vector-icons": "^15.0.3",
150
- "@oxyhq/core": "19.0.0",
150
+ "@oxyhq/core": "19.1.0",
151
151
  "@react-native-async-storage/async-storage": "^2.0.0",
152
152
  "@react-native-community/netinfo": "^11.4.1",
153
153
  "@react-native/babel-preset": "^0.86.0",
package/src/index.ts CHANGED
@@ -278,6 +278,17 @@ export type { RequireOxyAuthProps, RequireOxyAuthPrompt } from './ui/components/
278
278
 
279
279
  export { default as FollowButton } from './ui/components/FollowButton';
280
280
  export type { FollowButtonProps, SingleFollowButtonProps, MultiFollowButtonProps } from './ui/components/FollowButton';
281
+ // The follow graph (#809): follows anything registered, not just users.
282
+ export { FollowTargetButton } from './ui/components/FollowTargetButton';
283
+ export type {
284
+ FollowTargetButtonProps,
285
+ FollowVerb,
286
+ FollowLabels,
287
+ FollowDuration,
288
+ } from './ui/components/FollowTargetButton';
289
+ export { useFollowTarget } from './ui/hooks/useFollowTarget';
290
+ export type { UseFollowTargetResult } from './ui/hooks/useFollowTarget';
291
+ export { useFollowTargetStore, UNKNOWN_FOLLOW_STATUS } from './ui/stores/followTargetStore';
281
292
  export { default as OxyPayButton } from './ui/components/OxyPayButton';
282
293
  export { LogoIcon } from './ui/components/logo/LogoIcon';
283
294
  export { LogoText } from './ui/components/logo/LogoText';
@@ -5,10 +5,10 @@ import type {
5
5
  TextStyle,
6
6
  StyleProp,
7
7
  } from 'react-native';
8
+ import { ActivityIndicator } from 'react-native';
8
9
  import { useOxy } from '../context/OxyContext';
9
10
  import { toast } from '@oxyhq/bloom/toast';
10
11
  import { Button } from '@oxyhq/bloom/button';
11
- import { Loading } from '@oxyhq/bloom/loading';
12
12
  import { useFollow, useFollowForButton } from '../hooks/useFollow';
13
13
  import { useFollowStore } from '../stores/followStore';
14
14
  import { useTheme } from '@oxyhq/bloom/theme';
@@ -119,8 +119,7 @@ const FollowButtonInner = memo(function FollowButtonInner({
119
119
  style={style}
120
120
  textStyle={textStyle}
121
121
  icon={showSpinner ? (
122
- <Loading
123
- variant="inline"
122
+ <ActivityIndicator
124
123
  size="small"
125
124
  color={showFollowing ? colors.text : colors.primaryForeground}
126
125
  />
@@ -247,8 +246,7 @@ const FollowButtonMultiInner = memo(function FollowButtonMultiInner({
247
246
  style={style}
248
247
  textStyle={textStyle}
249
248
  icon={showSpinner ? (
250
- <Loading
251
- variant="inline"
249
+ <ActivityIndicator
252
250
  size="small"
253
251
  color={allFollowing ? colors.text : colors.primaryForeground}
254
252
  />
@@ -0,0 +1,326 @@
1
+ /**
2
+ * `FollowTargetButton` — one button for everything an Oxy user can follow.
3
+ *
4
+ * Follows a TARGET of any registered kind: a person, a topic, a channel, a
5
+ * store, an artist. Nothing in this file branches on what the target is, which
6
+ * is the property that lets an application this package has never heard of use
7
+ * it without a release.
8
+ *
9
+ * ## Two controls, not one
10
+ *
11
+ * The main press does the obvious thing. The chevron beside it opens the
12
+ * choices that a single press cannot express — follow for a while, stop showing
13
+ * this here without giving it up everywhere — and those belong behind a
14
+ * disclosure precisely because they are not what most people want most of the
15
+ * time. Bloom's `Menu` renders as a bottom sheet on native and a dropdown on
16
+ * web, so this is one component and not a platform fork.
17
+ *
18
+ * ## Verbs
19
+ *
20
+ * "Follow" is wrong for half the things in the ecosystem: you subscribe to a
21
+ * channel, you join a community, you watch a listing. The verb is a prop with a
22
+ * small vocabulary of defaults and a full override, rather than a lookup keyed
23
+ * on kind — a kind→verb table in this package would need a release every time
24
+ * an application invents a kind, which is exactly the coupling #809 removes.
25
+ *
26
+ * ## Relationship to the legacy `FollowButton`
27
+ *
28
+ * `FollowButton` follows a USER through the Mongo-backed social graph and
29
+ * remains the correct component for that until the user graph is migrated onto
30
+ * `/v2/follows`. When it is, that component is deleted and this one takes the
31
+ * name — not aliased, not deprecated in place.
32
+ */
33
+
34
+ import { memo, useCallback, useMemo } from 'react';
35
+ import type { FollowApplicationMode } from '@oxyhq/contracts';
36
+ import type { StyleProp, ViewStyle } from 'react-native';
37
+ import { View } from 'react-native';
38
+ import { Button } from '@oxyhq/bloom/button';
39
+ import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown } from '@oxyhq/bloom/icons';
40
+ import { Menu, MenuContent, MenuItem, MenuItemText, MenuTrigger } from '@oxyhq/bloom/menu';
41
+ import { toast } from '@oxyhq/bloom/toast';
42
+ import { useFollowTarget } from '../hooks/useFollowTarget';
43
+
44
+ /**
45
+ * The small vocabulary of verbs the ecosystem actually uses. An application
46
+ * whose verb is not here passes `labels` instead — the escape hatch exists so
47
+ * this union never has to grow to accommodate one product's wording.
48
+ */
49
+ export type FollowVerb = 'follow' | 'subscribe' | 'join' | 'watch';
50
+
51
+ export interface FollowLabels {
52
+ /** Not following yet. */
53
+ idle: string;
54
+ /** Following, and this application acts on it. */
55
+ active: string;
56
+ /** Asked, waiting for the other side to accept. */
57
+ pending: string;
58
+ /** Following globally, but switched off in this application. */
59
+ disabled: string;
60
+ }
61
+
62
+ const VERB_LABELS: Record<FollowVerb, FollowLabels> = {
63
+ follow: { idle: 'Follow', active: 'Following', pending: 'Requested', disabled: 'Off here' },
64
+ subscribe: {
65
+ idle: 'Subscribe',
66
+ active: 'Subscribed',
67
+ pending: 'Requested',
68
+ disabled: 'Off here',
69
+ },
70
+ join: { idle: 'Join', active: 'Joined', pending: 'Requested', disabled: 'Off here' },
71
+ watch: { idle: 'Watch', active: 'Watching', pending: 'Requested', disabled: 'Off here' },
72
+ };
73
+
74
+ /** A timed-follow choice offered in the menu. */
75
+ export interface FollowDuration {
76
+ label: string;
77
+ seconds: number;
78
+ }
79
+
80
+ const HOUR = 60 * 60;
81
+
82
+ /**
83
+ * Defaults chosen for the cases a timed follow is actually for: a live event
84
+ * tonight, a story running this week. Not a general-purpose duration picker —
85
+ * an application that needs one passes its own.
86
+ */
87
+ const DEFAULT_DURATIONS: FollowDuration[] = [
88
+ { label: '24 hours', seconds: 24 * HOUR },
89
+ { label: '72 hours', seconds: 72 * HOUR },
90
+ { label: 'A week', seconds: 7 * 24 * HOUR },
91
+ ];
92
+
93
+ /** One line in the disclosure menu. */
94
+ export interface FollowMenuItem {
95
+ key: string;
96
+ label: string;
97
+ /** What the component should call. Named so the table below stays pure. */
98
+ action:
99
+ | { type: 'follow-timed'; seconds: number; durationLabel: string }
100
+ | { type: 'enable-here' }
101
+ | { type: 'disable-here' }
102
+ | { type: 'unfollow' };
103
+ }
104
+
105
+ /**
106
+ * Which choices exist, given the state.
107
+ *
108
+ * Pure and exported because this is the product decision, not a rendering
109
+ * detail: a timed follow is only offered before following, turning it off here
110
+ * is only offered while following, and NEITHER is offered before the server has
111
+ * answered — every one of them addresses a relationship that does not exist
112
+ * yet, so offering them mid-write would mean sending a guessed id.
113
+ */
114
+ export function buildFollowMenuItems(input: {
115
+ following: boolean;
116
+ applicationMode: FollowApplicationMode;
117
+ hasRelationship: boolean;
118
+ isPending: boolean;
119
+ durations: FollowDuration[] | false;
120
+ idleVerb: string;
121
+ applicationName: string;
122
+ }): FollowMenuItem[] {
123
+ const items: FollowMenuItem[] = [];
124
+
125
+ if (!input.following) {
126
+ if (input.durations === false) return items;
127
+ for (const d of input.durations) {
128
+ items.push({
129
+ key: `for-${d.seconds}`,
130
+ label: `${input.idleVerb} for ${d.label.toLowerCase()}`,
131
+ action: { type: 'follow-timed', seconds: d.seconds, durationLabel: d.label },
132
+ });
133
+ }
134
+ return items;
135
+ }
136
+
137
+ if (!input.hasRelationship || input.isPending) return items;
138
+
139
+ items.push(
140
+ input.applicationMode === 'disabled'
141
+ ? { key: 'enable-here', label: `Show in ${input.applicationName}`, action: { type: 'enable-here' } }
142
+ : {
143
+ key: 'disable-here',
144
+ label: `Don’t show in ${input.applicationName}`,
145
+ action: { type: 'disable-here' },
146
+ }
147
+ );
148
+ items.push({
149
+ // Named for what it does. "Unfollow" beside "don't show here" would read as
150
+ // the same action twice, and the user would pick the wrong one.
151
+ key: 'unfollow-everywhere',
152
+ label: 'Unfollow everywhere',
153
+ action: { type: 'unfollow' },
154
+ });
155
+
156
+ return items;
157
+ }
158
+
159
+ export interface FollowTargetButtonProps {
160
+ /** The registered target's id. Registration is separate — see the SDK. */
161
+ targetId: string;
162
+ verb?: FollowVerb;
163
+ /** Full override, for a verb the vocabulary above does not carry. */
164
+ labels?: Partial<FollowLabels>;
165
+ size?: 'small' | 'medium' | 'large';
166
+ style?: StyleProp<ViewStyle>;
167
+ disabled?: boolean;
168
+ /**
169
+ * Hide the chevron. For a dense list row where the extra target is more
170
+ * likely to be hit by accident than used on purpose.
171
+ */
172
+ showOptions?: boolean;
173
+ /**
174
+ * Offer timed follows. Pass `false` where a temporary follow makes no sense —
175
+ * a store you buy from, an account you know.
176
+ */
177
+ durations?: FollowDuration[] | false;
178
+ /**
179
+ * This application's name, for the menu line that turns the follow off here.
180
+ * "Don't show in Mention" is a sentence the user can act on; "disable in this
181
+ * application" is not.
182
+ */
183
+ applicationName?: string;
184
+ onChange?: (following: boolean) => void;
185
+ }
186
+
187
+ export const FollowTargetButton = memo(function FollowTargetButton({
188
+ targetId,
189
+ verb = 'follow',
190
+ labels,
191
+ size = 'medium',
192
+ style,
193
+ disabled = false,
194
+ showOptions = true,
195
+ durations = DEFAULT_DURATIONS,
196
+ applicationName,
197
+ onChange,
198
+ }: FollowTargetButtonProps) {
199
+ const { status, isFollowing, isUnknown, isPending, follow, unfollow, disableHere, enableHere } =
200
+ useFollowTarget(targetId);
201
+
202
+ const text = useMemo(() => ({ ...VERB_LABELS[verb], ...labels }), [verb, labels]);
203
+
204
+ const label = useMemo(() => {
205
+ if (status.globalState === 'requested') return text.pending;
206
+ if (!isFollowing) return text.idle;
207
+ // Followed globally but switched off here. `effectiveState` reports
208
+ // `not_following` for this, correctly — the question it answers is "does
209
+ // this act here" — so the label has to be derived from the other two.
210
+ return status.applicationMode === 'disabled' ? text.disabled : text.active;
211
+ }, [isFollowing, status.globalState, status.applicationMode, text]);
212
+
213
+ const handlePrimary = useCallback(async () => {
214
+ if (isFollowing) {
215
+ await unfollow();
216
+ onChange?.(false);
217
+ return;
218
+ }
219
+ await follow();
220
+ onChange?.(true);
221
+ }, [isFollowing, follow, unfollow, onChange]);
222
+
223
+ const handleTimed = useCallback(
224
+ async (seconds: number, durationLabel: string) => {
225
+ await follow({ expiresIn: seconds });
226
+ onChange?.(true);
227
+ // The confirmation is the point: a follow that ends on its own is a
228
+ // promise, and a user who does not see it made will not believe it.
229
+ toast.success(`Following for ${durationLabel.toLowerCase()}`);
230
+ },
231
+ [follow, onChange]
232
+ );
233
+
234
+ const menuItems = useMemo(
235
+ () =>
236
+ buildFollowMenuItems({
237
+ following: isFollowing,
238
+ applicationMode: status.applicationMode,
239
+ hasRelationship: Boolean(status.relationshipId),
240
+ isPending,
241
+ durations,
242
+ idleVerb: text.idle,
243
+ applicationName: applicationName ?? 'this app',
244
+ }),
245
+ [
246
+ isFollowing,
247
+ status.applicationMode,
248
+ status.relationshipId,
249
+ isPending,
250
+ durations,
251
+ text.idle,
252
+ applicationName,
253
+ ]
254
+ );
255
+
256
+ const runItem = useCallback(
257
+ (item: FollowMenuItem) => {
258
+ switch (item.action.type) {
259
+ case 'follow-timed':
260
+ void handleTimed(item.action.seconds, item.action.durationLabel);
261
+ return;
262
+ case 'enable-here':
263
+ void enableHere();
264
+ return;
265
+ case 'disable-here':
266
+ void disableHere();
267
+ return;
268
+ case 'unfollow':
269
+ void unfollow();
270
+ onChange?.(false);
271
+ }
272
+ },
273
+ [handleTimed, enableHere, disableHere, unfollow, onChange]
274
+ );
275
+
276
+ const primary = (
277
+ <Button
278
+ variant={isFollowing ? 'secondary' : 'primary'}
279
+ size={size}
280
+ // Unknown is not "not following": the button stays inert until the first
281
+ // read settles rather than inviting a follow that may already exist.
282
+ disabled={disabled || isUnknown}
283
+ loading={isPending}
284
+ onPress={() => void handlePrimary()}
285
+ accessibilityLabel={label}
286
+ >
287
+ {label}
288
+ </Button>
289
+ );
290
+
291
+ if (!showOptions || menuItems.length === 0) {
292
+ return <View style={style}>{primary}</View>;
293
+ }
294
+
295
+ return (
296
+ <View style={[{ flexDirection: 'row', alignItems: 'center', gap: 4 }, style]}>
297
+ {primary}
298
+ <Menu>
299
+ <MenuTrigger label={`${label} options`} hint="Opens follow options">
300
+ {({ props }) => (
301
+ // The trigger's props are mapped rather than spread: `MenuTrigger`
302
+ // hands back a DOM-ish bag (focus handlers, an accessibility role)
303
+ // and `Button` accepts a different set, so a spread would pass
304
+ // props it silently drops.
305
+ <Button
306
+ variant="secondary"
307
+ size={size === 'large' ? 'large' : 'small'}
308
+ icon={<ChevronDown width={16} />}
309
+ disabled={disabled || isUnknown}
310
+ onPress={props.onPress}
311
+ accessibilityLabel={props.accessibilityLabel}
312
+ accessibilityHint={props.accessibilityHint}
313
+ />
314
+ )}
315
+ </MenuTrigger>
316
+ <MenuContent showCancel>
317
+ {menuItems.map((item) => (
318
+ <MenuItem key={item.key} label={item.label} onPress={() => runItem(item)}>
319
+ <MenuItemText>{item.label}</MenuItemText>
320
+ </MenuItem>
321
+ ))}
322
+ </MenuContent>
323
+ </Menu>
324
+ </View>
325
+ );
326
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The follow button's product decisions, as a table.
3
+ *
4
+ * `buildFollowMenuItems` is pure and tested here rather than through a render
5
+ * because what matters is WHICH choices exist in which state — an assertion a
6
+ * rendering test would make about the DOM, one layout change away from being
7
+ * about nothing.
8
+ *
9
+ * `withApplicationMode` sits beside it because the two have to agree: the menu
10
+ * offers "don't show here", and the store computes what that does to the
11
+ * effective state. If they disagree the button offers something it then fails
12
+ * to reflect.
13
+ */
14
+
15
+ import { buildFollowMenuItems } from '../FollowTargetButton';
16
+ import {
17
+ isFollowedGlobally,
18
+ UNKNOWN_FOLLOW_STATUS,
19
+ withApplicationMode,
20
+ } from '../../stores/followTargetStore';
21
+
22
+ const DURATIONS = [
23
+ { label: '24 hours', seconds: 86400 },
24
+ { label: 'A week', seconds: 604800 },
25
+ ];
26
+
27
+ const base = {
28
+ following: false,
29
+ applicationMode: 'inherit' as const,
30
+ hasRelationship: false,
31
+ isPending: false,
32
+ durations: DURATIONS,
33
+ idleVerb: 'Follow',
34
+ applicationName: 'Mention',
35
+ };
36
+
37
+ const keys = (items: ReturnType<typeof buildFollowMenuItems>) => items.map((i) => i.key);
38
+
39
+ describe('buildFollowMenuItems', () => {
40
+ describe('before following', () => {
41
+ it('offers the timed follows and nothing else', () => {
42
+ const items = buildFollowMenuItems(base);
43
+ expect(keys(items)).toEqual(['for-86400', 'for-604800']);
44
+ // Nothing that addresses a relationship, because there is none.
45
+ expect(keys(items).some((k) => k.includes('unfollow') || k.includes('here'))).toBe(false);
46
+ });
47
+
48
+ it('phrases the option with the application’s own verb', () => {
49
+ expect(buildFollowMenuItems({ ...base, idleVerb: 'Subscribe' })[0].label).toBe(
50
+ 'Subscribe for 24 hours'
51
+ );
52
+ });
53
+
54
+ it('offers nothing when the application turned timed follows off', () => {
55
+ expect(buildFollowMenuItems({ ...base, durations: false })).toEqual([]);
56
+ });
57
+ });
58
+
59
+ describe('while following', () => {
60
+ const following = { ...base, following: true, hasRelationship: true };
61
+
62
+ it('offers turning it off here, and unfollowing everywhere', () => {
63
+ expect(keys(buildFollowMenuItems(following))).toEqual([
64
+ 'disable-here',
65
+ 'unfollow-everywhere',
66
+ ]);
67
+ });
68
+
69
+ it('names the application, so the line is a sentence the user can act on', () => {
70
+ expect(buildFollowMenuItems(following)[0].label).toBe('Don’t show in Mention');
71
+ });
72
+
73
+ it('offers turning it back on once it is off here', () => {
74
+ const items = buildFollowMenuItems({ ...following, applicationMode: 'disabled' });
75
+ expect(keys(items)).toEqual(['enable-here', 'unfollow-everywhere']);
76
+ expect(items[0].label).toBe('Show in Mention');
77
+ });
78
+
79
+ it('never offers a timed follow', () => {
80
+ // Extending a follow is a different operation from starting one, and
81
+ // offering "Follow for 24 hours" to somebody already following would read
82
+ // as shortening it.
83
+ expect(keys(buildFollowMenuItems(following)).some((k) => k.startsWith('for-'))).toBe(false);
84
+ });
85
+
86
+ it('offers nothing while a write is in flight', () => {
87
+ // Every entry addresses the relationship id, which an optimistic follow
88
+ // does not have yet. Offering them here would mean sending a guess.
89
+ expect(buildFollowMenuItems({ ...following, isPending: true })).toEqual([]);
90
+ });
91
+
92
+ it('offers nothing when the relationship id is not known', () => {
93
+ expect(buildFollowMenuItems({ ...following, hasRelationship: false })).toEqual([]);
94
+ });
95
+ });
96
+ });
97
+
98
+ describe('withApplicationMode', () => {
99
+ const active = { ...UNKNOWN_FOLLOW_STATUS, globalState: 'active' as const };
100
+
101
+ it('makes a disabled follow inactive without giving up the follow', () => {
102
+ const next = withApplicationMode(active, 'disabled');
103
+ expect(next.effectiveState).toBe('not_following');
104
+ // The distinction the whole design exists for, and the reason a client must
105
+ // never read `effectiveState` as "does the user follow this": the follow is
106
+ // still there globally.
107
+ expect(next.globalState).toBe('active');
108
+ expect(isFollowedGlobally(next)).toBe(true);
109
+ });
110
+
111
+ it('restores the global state when enabled again', () => {
112
+ expect(withApplicationMode(withApplicationMode(active, 'disabled'), 'enabled')).toMatchObject({
113
+ applicationMode: 'enabled',
114
+ effectiveState: 'following',
115
+ });
116
+ });
117
+
118
+ it('keeps a requested follow requested rather than promoting it', () => {
119
+ const requested = { ...active, globalState: 'requested' as const };
120
+ expect(withApplicationMode(requested, 'enabled').effectiveState).toBe('requested');
121
+ // And a request in flight still counts as following, so the button offers
122
+ // to cancel rather than to ask again.
123
+ expect(isFollowedGlobally(requested)).toBe(true);
124
+ });
125
+
126
+ it('stays not-following when there is no global relationship to act on', () => {
127
+ expect(withApplicationMode(UNKNOWN_FOLLOW_STATUS, 'enabled').effectiveState).toBe(
128
+ 'not_following'
129
+ );
130
+ expect(isFollowedGlobally(UNKNOWN_FOLLOW_STATUS)).toBe(false);
131
+ });
132
+ });