@oxyhq/services 10.2.11 → 10.3.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.map +1 -1
- package/lib/commonjs/ui/components/FollowButton.js +107 -1
- package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollow.js +7 -0
- package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollow.types.js +4 -0
- package/lib/commonjs/ui/stores/followStore.js +68 -0
- package/lib/commonjs/ui/stores/followStore.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/FollowButton.js +109 -3
- package/lib/module/ui/components/FollowButton.js.map +1 -1
- package/lib/module/ui/hooks/useFollow.js +7 -0
- package/lib/module/ui/hooks/useFollow.js.map +1 -1
- package/lib/module/ui/hooks/useFollow.types.js +2 -0
- package/lib/module/ui/stores/followStore.js +68 -0
- package/lib/module/ui/stores/followStore.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowButton.d.ts +24 -4
- package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +3 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.types.d.ts +2 -0
- package/lib/typescript/commonjs/ui/hooks/useFollow.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts +2 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowButton.d.ts +24 -4
- package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollow.d.ts +3 -1
- package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollow.types.d.ts +2 -0
- package/lib/typescript/module/ui/hooks/useFollow.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts +2 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/ui/components/FollowButton.tsx +154 -10
- package/src/ui/hooks/useFollow.ts +8 -1
- package/src/ui/hooks/useFollow.types.ts +3 -0
- package/src/ui/stores/followStore.ts +47 -1
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
}:
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -144,6 +144,12 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
144
144
|
await Promise.all(userIds.map(uid => store.fetchFollowStatus(uid, oxyServices)));
|
|
145
145
|
}, [canUsePrivateApi, userIds, oxyServices]);
|
|
146
146
|
|
|
147
|
+
// Bulk follow — follows ALL users in ONE network call (never unfollows).
|
|
148
|
+
const followAllUsers = useCallback(async (): Promise<BulkFollowResult> => {
|
|
149
|
+
if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
|
|
150
|
+
return useFollowStore.getState().followManyUsers(userIds, oxyServices);
|
|
151
|
+
}, [canUsePrivateApi, userIds, oxyServices]);
|
|
152
|
+
|
|
147
153
|
const clearErrorForUser = useCallback((targetUserId: string) => {
|
|
148
154
|
useFollowStore.getState().clearFollowError(targetUserId);
|
|
149
155
|
}, []);
|
|
@@ -177,6 +183,7 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
177
183
|
setFollowStatusForUser,
|
|
178
184
|
fetchStatusForUser,
|
|
179
185
|
fetchAllStatuses,
|
|
186
|
+
followAllUsers,
|
|
180
187
|
clearErrorForUser,
|
|
181
188
|
isAnyLoading: multiUserLoadingState.isAnyLoading,
|
|
182
189
|
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
|
})),
|