@oxyhq/services 10.2.11 → 10.3.1

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 (42) hide show
  1. package/lib/commonjs/index.js.map +1 -1
  2. package/lib/commonjs/ui/components/FollowButton.js +107 -1
  3. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  4. package/lib/commonjs/ui/hooks/useFollow.js +29 -6
  5. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  6. package/lib/commonjs/ui/hooks/useFollow.types.js +4 -0
  7. package/lib/commonjs/ui/stores/followStore.js +68 -0
  8. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  9. package/lib/module/index.js.map +1 -1
  10. package/lib/module/ui/components/FollowButton.js +109 -3
  11. package/lib/module/ui/components/FollowButton.js.map +1 -1
  12. package/lib/module/ui/hooks/useFollow.js +29 -6
  13. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  14. package/lib/module/ui/hooks/useFollow.types.js +2 -0
  15. package/lib/module/ui/stores/followStore.js +68 -0
  16. package/lib/module/ui/stores/followStore.js.map +1 -1
  17. package/lib/typescript/commonjs/index.d.ts +1 -0
  18. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts +24 -4
  20. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +3 -1
  22. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/ui/hooks/useFollow.types.d.ts +2 -0
  24. package/lib/typescript/commonjs/ui/hooks/useFollow.types.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/ui/stores/followStore.d.ts +2 -1
  26. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  27. package/lib/typescript/module/index.d.ts +1 -0
  28. package/lib/typescript/module/index.d.ts.map +1 -1
  29. package/lib/typescript/module/ui/components/FollowButton.d.ts +24 -4
  30. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  31. package/lib/typescript/module/ui/hooks/useFollow.d.ts +3 -1
  32. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  33. package/lib/typescript/module/ui/hooks/useFollow.types.d.ts +2 -0
  34. package/lib/typescript/module/ui/hooks/useFollow.types.d.ts.map +1 -1
  35. package/lib/typescript/module/ui/stores/followStore.d.ts +2 -1
  36. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  37. package/package.json +2 -2
  38. package/src/index.ts +1 -0
  39. package/src/ui/components/FollowButton.tsx +154 -10
  40. package/src/ui/hooks/useFollow.ts +35 -11
  41. package/src/ui/hooks/useFollow.types.ts +3 -0
  42. package/src/ui/stores/followStore.ts +47 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "10.2.11",
3
+ "version": "10.3.1",
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",
@@ -161,7 +161,7 @@
161
161
  "peerDependencies": {
162
162
  "@expo/vector-icons": "^15.0.3",
163
163
  "@oxyhq/bloom": ">=0.5.0",
164
- "@oxyhq/core": "^3.4.17",
164
+ "@oxyhq/core": "^3.5.0",
165
165
  "@react-native-community/netinfo": "^11.4.1",
166
166
  "@tanstack/query-async-storage-persister": "^5.100",
167
167
  "@tanstack/query-sync-storage-persister": "^5.100",
package/src/index.ts CHANGED
@@ -193,6 +193,7 @@ export { OxyAuthPrompt } from './ui/components/OxyAuthPrompt';
193
193
  export type { OxyAuthPromptProps } from './ui/components/OxyAuthPrompt';
194
194
  export { default as OxyLogo } from './ui/components/OxyLogo';
195
195
  export { default as FollowButton } from './ui/components/FollowButton';
196
+ export type { FollowButtonProps, SingleFollowButtonProps, MultiFollowButtonProps } from './ui/components/FollowButton';
196
197
  export { LogoIcon } from './ui/components/logo/LogoIcon';
197
198
  export { LogoText } from './ui/components/logo/LogoText';
198
199
 
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useCallback, memo } from 'react';
1
+ import React, { useEffect, useCallback, useMemo, useState, memo } from 'react';
2
2
  import {
3
3
  TouchableOpacity,
4
4
  Text,
@@ -10,23 +10,51 @@ import {
10
10
  } from 'react-native';
11
11
  import { useOxy } from '../context/OxyContext';
12
12
  import { toast } from '@oxyhq/bloom';
13
- import { useFollowForButton } from '../hooks/useFollow';
13
+ import { useFollow, useFollowForButton } from '../hooks/useFollow';
14
14
  import { useTheme } from '@oxyhq/bloom/theme';
15
- import type { OxyServices } from '@oxyhq/core';
15
+ import type { OxyServices, BulkFollowResult } from '@oxyhq/core';
16
16
 
17
- export interface FollowButtonProps {
18
- userId: string;
19
- initiallyFollowing?: boolean;
17
+ const DEFAULT_FOLLOW_ALL_LABEL = 'Follow all';
18
+ const DEFAULT_FOLLOWED_ALL_LABEL = 'Following';
19
+
20
+ /** Props shared by both single- and multi-user follow modes. */
21
+ interface FollowButtonBaseProps {
20
22
  size?: 'small' | 'medium' | 'large';
21
- onFollowChange?: (isFollowing: boolean) => void;
22
23
  style?: StyleProp<ViewStyle>;
23
24
  textStyle?: StyleProp<TextStyle>;
24
25
  disabled?: boolean;
25
26
  showLoadingState?: boolean;
26
27
  preventParentActions?: boolean;
27
28
  theme?: 'light' | 'dark';
29
+ onFollowChange?: (isFollowing: boolean) => void;
28
30
  }
29
31
 
32
+ /** Single-user mode — follows/unfollows one user (existing behavior). */
33
+ export interface SingleFollowButtonProps extends FollowButtonBaseProps {
34
+ userId: string;
35
+ initiallyFollowing?: boolean;
36
+ userIds?: never;
37
+ }
38
+
39
+ /** Multi-user mode — follows MANY users in one "Follow all" action (follow-only). */
40
+ export interface MultiFollowButtonProps extends FollowButtonBaseProps {
41
+ userIds: string[];
42
+ initiallyAllFollowing?: boolean;
43
+ followAllLabel?: string;
44
+ followedAllLabel?: string;
45
+ onBulkFollow?: (result: BulkFollowResult) => void;
46
+ userId?: never;
47
+ }
48
+
49
+ /**
50
+ * FollowButton accepts EITHER single-user mode (`userId`) or multi-user mode
51
+ * (`userIds`), never both. Existing `{ userId, ... }` callers remain valid.
52
+ */
53
+ export type FollowButtonProps = SingleFollowButtonProps | MultiFollowButtonProps;
54
+
55
+ const isMultiMode = (props: FollowButtonProps): props is MultiFollowButtonProps =>
56
+ 'userIds' in props && Array.isArray(props.userIds);
57
+
30
58
  const FollowButtonInner = memo(function FollowButtonInner({
31
59
  userId,
32
60
  oxyServices,
@@ -38,7 +66,7 @@ const FollowButtonInner = memo(function FollowButtonInner({
38
66
  disabled = false,
39
67
  showLoadingState = true,
40
68
  preventParentActions = true,
41
- }: FollowButtonProps & { oxyServices: OxyServices }) {
69
+ }: SingleFollowButtonProps & { oxyServices: OxyServices }) {
42
70
  const { colors } = useTheme();
43
71
 
44
72
  const {
@@ -107,13 +135,129 @@ const FollowButtonInner = memo(function FollowButtonInner({
107
135
  );
108
136
  });
109
137
 
138
+ const FollowButtonMultiInner = memo(function FollowButtonMultiInner({
139
+ userIds,
140
+ initiallyAllFollowing = false,
141
+ size = 'medium',
142
+ followAllLabel = DEFAULT_FOLLOW_ALL_LABEL,
143
+ followedAllLabel = DEFAULT_FOLLOWED_ALL_LABEL,
144
+ onFollowChange,
145
+ onBulkFollow,
146
+ style,
147
+ textStyle,
148
+ disabled = false,
149
+ showLoadingState = true,
150
+ preventParentActions = true,
151
+ }: MultiFollowButtonProps) {
152
+ const { colors } = useTheme();
153
+ const follow = useFollow(userIds);
154
+ const followAllUsers = 'followAllUsers' in follow ? follow.followAllUsers : undefined;
155
+ const isAnyLoading = 'isAnyLoading' in follow ? follow.isAnyLoading : false;
156
+
157
+ const [allFollowing, setAllFollowing] = useState(initiallyAllFollowing);
158
+ const [isSubmitting, setIsSubmitting] = useState(false);
159
+
160
+ const isLoading = isSubmitting || isAnyLoading;
161
+
162
+ const handlePress = useCallback(async (event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
163
+ if (preventParentActions && event?.preventDefault) {
164
+ event.preventDefault();
165
+ event.stopPropagation?.();
166
+ }
167
+ if (disabled || isLoading || allFollowing || !followAllUsers) return;
168
+
169
+ setIsSubmitting(true);
170
+ try {
171
+ const result = await followAllUsers();
172
+ const allAlreadyFollowing = result.followedCount === 0
173
+ && result.results.length > 0
174
+ && result.results.every((entry) => entry.alreadyFollowing);
175
+ const anyFollowed = result.followedCount > 0
176
+ || result.results.some((entry) => entry.success || entry.alreadyFollowing);
177
+
178
+ if (allAlreadyFollowing || anyFollowed) {
179
+ setAllFollowing(true);
180
+ onFollowChange?.(true);
181
+ }
182
+ onBulkFollow?.(result);
183
+ } catch (err: unknown) {
184
+ const error = err instanceof Error ? err : new Error(String(err));
185
+ toast.error(error.message || 'Failed to update follow status');
186
+ } finally {
187
+ setIsSubmitting(false);
188
+ }
189
+ }, [disabled, isLoading, allFollowing, followAllUsers, onFollowChange, onBulkFollow, preventParentActions]);
190
+
191
+ const baseButtonStyle = getBaseButtonStyle(size, style);
192
+ const baseTextStyle = getBaseTextStyle(size, textStyle);
193
+
194
+ return (
195
+ <TouchableOpacity
196
+ className={allFollowing
197
+ ? 'bg-background border-border'
198
+ : 'bg-primary border-primary'
199
+ }
200
+ style={baseButtonStyle}
201
+ onPress={handlePress}
202
+ disabled={disabled || isLoading || allFollowing}
203
+ activeOpacity={0.8}
204
+ >
205
+ {showLoadingState && isLoading ? (
206
+ <ActivityIndicator
207
+ size="small"
208
+ color={allFollowing ? colors.text : colors.negativeForeground}
209
+ />
210
+ ) : (
211
+ <Text
212
+ className={allFollowing ? 'text-foreground' : 'text-primary-foreground'}
213
+ style={baseTextStyle}
214
+ >
215
+ {allFollowing ? followedAllLabel : followAllLabel}
216
+ </Text>
217
+ )}
218
+ </TouchableOpacity>
219
+ );
220
+ });
221
+
110
222
  const FollowButton: React.FC<FollowButtonProps> = (props) => {
111
223
  const { oxyServices, canUsePrivateApi, user: currentUser } = useOxy();
112
224
 
113
225
  const currentUserId = currentUser?.id ? String(currentUser.id).trim() : '';
114
- const targetUserId = props.userId ? String(props.userId).trim() : '';
115
226
 
116
- if (!canUsePrivateApi || !targetUserId || (currentUserId && currentUserId === targetUserId)) {
227
+ const rawUserIds = isMultiMode(props) ? props.userIds : null;
228
+
229
+ // Multi-user mode: dedupe, trim, drop the current user, and bail if empty.
230
+ const multiUserIds = useMemo(() => {
231
+ if (!rawUserIds) return [];
232
+ const seen = new Set<string>();
233
+ const cleaned: string[] = [];
234
+ for (const raw of rawUserIds) {
235
+ const id = raw ? String(raw).trim() : '';
236
+ if (!id || id === currentUserId || seen.has(id)) continue;
237
+ seen.add(id);
238
+ cleaned.push(id);
239
+ }
240
+ return cleaned;
241
+ }, [rawUserIds, currentUserId]);
242
+
243
+ if (!canUsePrivateApi) {
244
+ return null;
245
+ }
246
+
247
+ if (isMultiMode(props)) {
248
+ if (multiUserIds.length === 0) {
249
+ return null;
250
+ }
251
+ return (
252
+ <FollowButtonMultiInner
253
+ {...props}
254
+ userIds={multiUserIds}
255
+ />
256
+ );
257
+ }
258
+
259
+ const targetUserId = props.userId ? String(props.userId).trim() : '';
260
+ if (!targetUserId || (currentUserId && currentUserId === targetUserId)) {
117
261
  return null;
118
262
  }
119
263
 
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useMemo, useEffect } from 'react';
2
2
  import { useFollowStore } from '../stores/followStore';
3
3
  import { useOxy } from '../context/OxyContext';
4
- import { logger as loggerUtil, type OxyServices } from '@oxyhq/core';
4
+ import { logger as loggerUtil, type OxyServices, type BulkFollowResult } from '@oxyhq/core';
5
5
  import { useShallow } from 'zustand/react/shallow';
6
6
 
7
7
  /**
@@ -42,22 +42,39 @@ export const useFollow = (userId?: string | string[]) => {
42
42
  useCallback((s) => (isSingleUser && userId ? s.loadingCounts[userId] ?? false : false), [isSingleUser, userId])
43
43
  );
44
44
 
45
- // For multi-user mode, use shallow comparison to avoid re-renders when unrelated users change
46
- const followData = useFollowStore(
45
+ // For multi-user mode, subscribe to a FLAT snapshot of the per-user fields.
46
+ // `useShallow` only compares one level deep, so the selector must NOT return a
47
+ // nested object — a freshly allocated `{ isFollowing, isLoading, error }` per
48
+ // user would never shallow-compare equal, making `useSyncExternalStore`
49
+ // resubscribe every render and loop until React throws "Maximum update depth
50
+ // exceeded". Keeping the snapshot flat lets the shallow compare cache it; the
51
+ // nested `followData` shape is then assembled in a `useMemo` below.
52
+ const followFlat = useFollowStore(
47
53
  useShallow((s) => {
48
- if (isSingleUser) return {};
49
- const data: Record<string, { isFollowing: boolean; isLoading: boolean; error: string | null }> = {};
54
+ if (isSingleUser) return {} as Record<string, boolean | string | null>;
55
+ const flat: Record<string, boolean | string | null> = {};
50
56
  for (const uid of userIds) {
51
- data[uid] = {
52
- isFollowing: s.followingUsers[uid] ?? false,
53
- isLoading: s.loadingUsers[uid] ?? false,
54
- error: s.errors[uid] ?? null,
55
- };
57
+ flat[`${uid}:isFollowing`] = s.followingUsers[uid] ?? false;
58
+ flat[`${uid}:isLoading`] = s.loadingUsers[uid] ?? false;
59
+ flat[`${uid}:error`] = s.errors[uid] ?? null;
56
60
  }
57
- return data;
61
+ return flat;
58
62
  })
59
63
  );
60
64
 
65
+ const followData = useMemo(() => {
66
+ const data: Record<string, { isFollowing: boolean; isLoading: boolean; error: string | null }> = {};
67
+ if (isSingleUser) return data;
68
+ for (const uid of userIds) {
69
+ data[uid] = {
70
+ isFollowing: Boolean(followFlat[`${uid}:isFollowing`]),
71
+ isLoading: Boolean(followFlat[`${uid}:isLoading`]),
72
+ error: (followFlat[`${uid}:error`] as string | null) ?? null,
73
+ };
74
+ }
75
+ return data;
76
+ }, [isSingleUser, userIds, followFlat]);
77
+
61
78
  // Multi-user aggregate selectors
62
79
  const multiUserLoadingState = useFollowStore(
63
80
  useShallow((s) => {
@@ -144,6 +161,12 @@ export const useFollow = (userId?: string | string[]) => {
144
161
  await Promise.all(userIds.map(uid => store.fetchFollowStatus(uid, oxyServices)));
145
162
  }, [canUsePrivateApi, userIds, oxyServices]);
146
163
 
164
+ // Bulk follow — follows ALL users in ONE network call (never unfollows).
165
+ const followAllUsers = useCallback(async (): Promise<BulkFollowResult> => {
166
+ if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
167
+ return useFollowStore.getState().followManyUsers(userIds, oxyServices);
168
+ }, [canUsePrivateApi, userIds, oxyServices]);
169
+
147
170
  const clearErrorForUser = useCallback((targetUserId: string) => {
148
171
  useFollowStore.getState().clearFollowError(targetUserId);
149
172
  }, []);
@@ -177,6 +200,7 @@ export const useFollow = (userId?: string | string[]) => {
177
200
  setFollowStatusForUser,
178
201
  fetchStatusForUser,
179
202
  fetchAllStatuses,
203
+ followAllUsers,
180
204
  clearErrorForUser,
181
205
  isAnyLoading: multiUserLoadingState.isAnyLoading,
182
206
  hasAnyError: multiUserLoadingState.hasAnyError,
@@ -1,6 +1,8 @@
1
1
  // Type-only definition for the useFollow hook to allow context exposure without runtime import cycles.
2
2
  // Expand this as needed to better reflect the real return type.
3
3
 
4
+ import type { BulkFollowResult } from '@oxyhq/core';
5
+
4
6
  export type SingleFollowResult = {
5
7
  isFollowing: boolean;
6
8
  isLoading: boolean;
@@ -23,6 +25,7 @@ export type MultiFollowResult = {
23
25
  setFollowStatusForUser: (userId: string, following: boolean) => void;
24
26
  fetchStatusForUser: (userId: string) => Promise<void>;
25
27
  fetchAllStatuses: () => Promise<void>;
28
+ followAllUsers: () => Promise<BulkFollowResult>;
26
29
  clearErrorForUser: (userId: string) => void;
27
30
  isAnyLoading: boolean;
28
31
  hasAnyError: boolean;
@@ -1,5 +1,5 @@
1
1
  import { create } from 'zustand';
2
- import type { OxyServices } from '@oxyhq/core';
2
+ import type { OxyServices, BulkFollowResult } from '@oxyhq/core';
3
3
 
4
4
  interface FollowState {
5
5
  followingUsers: Record<string, boolean>;
@@ -16,6 +16,8 @@ interface FollowState {
16
16
  resetFollowState: () => void;
17
17
  fetchFollowStatus: (userId: string, oxyServices: OxyServices) => Promise<void>;
18
18
  toggleFollowUser: (userId: string, oxyServices: OxyServices, isCurrentlyFollowing: boolean) => Promise<void>;
19
+ // Bulk follow — follows MANY users in one network call; never unfollows.
20
+ followManyUsers: (userIds: string[], oxyServices: OxyServices) => Promise<BulkFollowResult>;
19
21
  // New methods for follower counts
20
22
  setFollowerCount: (userId: string, count: number) => void;
21
23
  setFollowingCount: (userId: string, count: number) => void;
@@ -126,6 +128,50 @@ export const useFollowStore = create<FollowState>((set: any, get: any) => ({
126
128
  }));
127
129
  }
128
130
  },
131
+ followManyUsers: async (userIds: string[], oxyServices: OxyServices): Promise<BulkFollowResult> => {
132
+ set((state: FollowState) => {
133
+ const loadingUsers = { ...state.loadingUsers };
134
+ const errors = { ...state.errors };
135
+ for (const uid of userIds) {
136
+ loadingUsers[uid] = true;
137
+ errors[uid] = null;
138
+ }
139
+ return { loadingUsers, errors };
140
+ });
141
+ try {
142
+ const result = await oxyServices.followUsers(userIds);
143
+ set((state: FollowState) => {
144
+ const followingUsers = { ...state.followingUsers };
145
+ const loadingUsers = { ...state.loadingUsers };
146
+ const errors = { ...state.errors };
147
+ for (const uid of userIds) {
148
+ loadingUsers[uid] = false;
149
+ }
150
+ for (const entry of result.results) {
151
+ if (entry.success || entry.alreadyFollowing) {
152
+ followingUsers[entry.userId] = true;
153
+ errors[entry.userId] = null;
154
+ } else {
155
+ errors[entry.userId] = 'Failed to update follow status';
156
+ }
157
+ }
158
+ return { followingUsers, loadingUsers, errors };
159
+ });
160
+ return result;
161
+ } catch (error: unknown) {
162
+ const message = (error instanceof Error ? error.message : null) || 'Failed to update follow status';
163
+ set((state: FollowState) => {
164
+ const loadingUsers = { ...state.loadingUsers };
165
+ const errors = { ...state.errors };
166
+ for (const uid of userIds) {
167
+ loadingUsers[uid] = false;
168
+ errors[uid] = message;
169
+ }
170
+ return { loadingUsers, errors };
171
+ });
172
+ throw error;
173
+ }
174
+ },
129
175
  setFollowerCount: (userId: string, count: number) => set((state: FollowState) => ({
130
176
  followerCounts: { ...state.followerCounts, [userId]: count },
131
177
  })),