@mereb/app-profile 0.0.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.
- package/dist/src/headless.d.ts +8 -0
- package/dist/src/index.d.ts +11 -0
- package/package.json +42 -0
- package/src/headless.ts +66 -0
- package/src/index.tsx +734 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MeProfileQuery, ProfileByHandleQuery } from '@mereb/shared-graphql';
|
|
2
|
+
export { MeProfileDocument, ProfileByHandleDocument, FollowUserDocument, UnfollowUserDocument, UpdateProfileDocument } from '@mereb/shared-graphql';
|
|
3
|
+
export type ViewerProfile = MeProfileQuery['me'];
|
|
4
|
+
export type ProfileData = ProfileByHandleQuery['userByHandle'];
|
|
5
|
+
export type ProfilePost = NonNullable<ProfileData>['posts']['edges'][number]['node'];
|
|
6
|
+
export declare function parseProfileTimestamp(value?: string | null): Date | null;
|
|
7
|
+
export declare function formatRelativeProfileTimestamp(value?: string | null, now?: number): string;
|
|
8
|
+
export declare function selectProfilePosts(profile?: ProfileData | null): ProfilePost[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type AuthControls = {
|
|
2
|
+
token?: string;
|
|
3
|
+
login?: () => Promise<void>;
|
|
4
|
+
logout?: () => Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
type ProfileScreenProps = {
|
|
7
|
+
auth?: AuthControls;
|
|
8
|
+
handle?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function ProfileScreen({ auth, handle }: Readonly<ProfileScreenProps>): import("react").JSX.Element;
|
|
11
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mereb/app-profile",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Profile experience components and hooks for Mereb apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.tsx",
|
|
7
|
+
"types": "src/index.tsx",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.tsx",
|
|
11
|
+
"default": "./src/index.tsx"
|
|
12
|
+
},
|
|
13
|
+
"./headless": {
|
|
14
|
+
"types": "./src/headless.ts",
|
|
15
|
+
"default": "./src/headless.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"dist",
|
|
21
|
+
"package.json"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@mereb/shared-graphql": "^0.0.6",
|
|
25
|
+
"@mereb/ui-shared": "^0.0.1",
|
|
26
|
+
"@mereb/tokens": "^0.0.4"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@apollo/client": ">=3.8.0",
|
|
30
|
+
"expo-router": ">=3.0.0",
|
|
31
|
+
"react": ">=18.2.0",
|
|
32
|
+
"react-native": ">=0.72.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/react": "~18.2.79",
|
|
36
|
+
"typescript": ">=5.3.3"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit --project tsconfig.json",
|
|
40
|
+
"build": "tsc --project tsconfig.json"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/headless.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MeProfileQuery,
|
|
3
|
+
ProfileByHandleQuery
|
|
4
|
+
} from '@mereb/shared-graphql';
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
MeProfileDocument,
|
|
8
|
+
ProfileByHandleDocument,
|
|
9
|
+
FollowUserDocument,
|
|
10
|
+
UnfollowUserDocument,
|
|
11
|
+
UpdateProfileDocument
|
|
12
|
+
} from '@mereb/shared-graphql';
|
|
13
|
+
|
|
14
|
+
export type ViewerProfile = MeProfileQuery['me'];
|
|
15
|
+
export type ProfileData = ProfileByHandleQuery['userByHandle'];
|
|
16
|
+
export type ProfilePost = NonNullable<ProfileData>['posts']['edges'][number]['node'];
|
|
17
|
+
|
|
18
|
+
const MINUTE = 60 * 1000;
|
|
19
|
+
const HOUR = 60 * MINUTE;
|
|
20
|
+
const DAY = 24 * HOUR;
|
|
21
|
+
|
|
22
|
+
export function parseProfileTimestamp(value?: string | null): Date | null {
|
|
23
|
+
if (!value) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const numeric = Number(value);
|
|
27
|
+
if (!Number.isNaN(numeric) && numeric > 0) {
|
|
28
|
+
const numericDate = new Date(numeric);
|
|
29
|
+
return Number.isNaN(numericDate.getTime()) ? null : numericDate;
|
|
30
|
+
}
|
|
31
|
+
const date = new Date(value);
|
|
32
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatRelativeProfileTimestamp(value?: string | null, now: number = Date.now()): string {
|
|
36
|
+
const parsed = parseProfileTimestamp(value);
|
|
37
|
+
if (!parsed) {
|
|
38
|
+
return 'Just now';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const diff = now - parsed.getTime();
|
|
42
|
+
if (diff < MINUTE) {
|
|
43
|
+
return 'Just now';
|
|
44
|
+
}
|
|
45
|
+
if (diff < HOUR) {
|
|
46
|
+
const mins = Math.round(diff / MINUTE);
|
|
47
|
+
return `${mins} min${mins === 1 ? '' : 's'} ago`;
|
|
48
|
+
}
|
|
49
|
+
if (diff < DAY) {
|
|
50
|
+
const hours = Math.round(diff / HOUR);
|
|
51
|
+
return `${hours} hr${hours === 1 ? '' : 's'} ago`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return parsed.toLocaleDateString(undefined, {
|
|
55
|
+
year: parsed.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
|
|
56
|
+
month: 'short',
|
|
57
|
+
day: 'numeric'
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function selectProfilePosts(profile?: ProfileData | null): ProfilePost[] {
|
|
62
|
+
if (!profile?.posts?.edges?.length) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return profile.posts.edges.map((edge) => edge.node);
|
|
66
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
import {useCallback, useEffect, useMemo, useState} from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Image,
|
|
5
|
+
RefreshControl,
|
|
6
|
+
ScrollView,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
Text,
|
|
9
|
+
TextInput,
|
|
10
|
+
TouchableOpacity,
|
|
11
|
+
View
|
|
12
|
+
} from 'react-native';
|
|
13
|
+
import {
|
|
14
|
+
useFollowUserMutation,
|
|
15
|
+
useMeProfileQuery,
|
|
16
|
+
useProfileByHandleQuery,
|
|
17
|
+
useUnfollowUserMutation,
|
|
18
|
+
useUpdateProfileMutation
|
|
19
|
+
} from '@mereb/shared-graphql';
|
|
20
|
+
import {tokens} from '@mereb/tokens/native';
|
|
21
|
+
import {cardRecipes, placeholderAvatarRecipe} from '@mereb/ui-shared';
|
|
22
|
+
import {formatRelativeProfileTimestamp, selectProfilePosts} from './headless.js';
|
|
23
|
+
|
|
24
|
+
type AuthControls = {
|
|
25
|
+
token?: string;
|
|
26
|
+
login?: () => Promise<void>;
|
|
27
|
+
logout?: () => Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type ProfileScreenProps = {
|
|
31
|
+
auth?: AuthControls;
|
|
32
|
+
handle?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type GraphQLErrorLike = {
|
|
36
|
+
message?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function extractGraphQLErrors(
|
|
40
|
+
error: unknown
|
|
41
|
+
): GraphQLErrorLike[] | undefined {
|
|
42
|
+
if (
|
|
43
|
+
error &&
|
|
44
|
+
typeof error === 'object' &&
|
|
45
|
+
'graphQLErrors' in error &&
|
|
46
|
+
Array.isArray((error as { graphQLErrors?: unknown }).graphQLErrors)
|
|
47
|
+
) {
|
|
48
|
+
return (error as { graphQLErrors?: GraphQLErrorLike[] }).graphQLErrors;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function PlaceholderAvatar() {
|
|
54
|
+
return (
|
|
55
|
+
<View style={styles.placeholderAvatar} accessible={false}>
|
|
56
|
+
<View style={styles.placeholderHead}/>
|
|
57
|
+
<View style={styles.placeholderShoulders}/>
|
|
58
|
+
</View>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ProfileScreen({auth, handle}: Readonly<ProfileScreenProps>) {
|
|
63
|
+
const token = auth?.token;
|
|
64
|
+
const viewingOwnProfile = !handle;
|
|
65
|
+
const trimmedHandle = handle?.replace(/^@/, '') ?? '';
|
|
66
|
+
|
|
67
|
+
const {
|
|
68
|
+
data: meData,
|
|
69
|
+
loading: loadingMe,
|
|
70
|
+
error: meError,
|
|
71
|
+
refetch: refetchMe
|
|
72
|
+
} = useMeProfileQuery({
|
|
73
|
+
variables: {postsLimit: 10},
|
|
74
|
+
skip: !viewingOwnProfile || !token,
|
|
75
|
+
notifyOnNetworkStatusChange: true
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const {
|
|
79
|
+
data: handleData,
|
|
80
|
+
loading: loadingHandle,
|
|
81
|
+
error: handleError,
|
|
82
|
+
refetch: refetchHandle
|
|
83
|
+
} = useProfileByHandleQuery({
|
|
84
|
+
variables: {handle: trimmedHandle, postsLimit: 10},
|
|
85
|
+
skip: viewingOwnProfile || trimmedHandle.length === 0,
|
|
86
|
+
notifyOnNetworkStatusChange: true
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const activeError = viewingOwnProfile ? meError : handleError;
|
|
90
|
+
const activeLoading = viewingOwnProfile ? loadingMe : loadingHandle;
|
|
91
|
+
const activeRefetch = viewingOwnProfile ? refetchMe : refetchHandle;
|
|
92
|
+
const profile = viewingOwnProfile ? meData?.me : handleData?.userByHandle;
|
|
93
|
+
|
|
94
|
+
const followButtonLabel = profile?.followedByMe ? 'Following' : 'Follow';
|
|
95
|
+
const activityIndicatorColor = profile?.followedByMe ? '#0f172a' : '#fff';
|
|
96
|
+
|
|
97
|
+
const unauthenticatedError = viewingOwnProfile
|
|
98
|
+
? Boolean(
|
|
99
|
+
extractGraphQLErrors(meError)?.some((graphError) =>
|
|
100
|
+
graphError.message?.toUpperCase().includes('UNAUTHENTICATED')
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
: false;
|
|
104
|
+
|
|
105
|
+
const shouldPromptSignIn = viewingOwnProfile && (!token || unauthenticatedError);
|
|
106
|
+
|
|
107
|
+
const [updateProfileMutation, {loading: saving}] =
|
|
108
|
+
useUpdateProfileMutation();
|
|
109
|
+
const [followUserMutation, {loading: followLoading}] =
|
|
110
|
+
useFollowUserMutation();
|
|
111
|
+
const [unfollowUserMutation, {loading: unfollowLoading}] =
|
|
112
|
+
useUnfollowUserMutation();
|
|
113
|
+
|
|
114
|
+
const [displayName, setDisplayName] = useState('');
|
|
115
|
+
const [bio, setBio] = useState('');
|
|
116
|
+
const [formError, setFormError] = useState<string>();
|
|
117
|
+
const [successMessage, setSuccessMessage] = useState<string>();
|
|
118
|
+
const [followError, setFollowError] = useState<string>();
|
|
119
|
+
const followMutationInFlight = followLoading || unfollowLoading;
|
|
120
|
+
const isAuthenticated = Boolean(token);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (viewingOwnProfile && profile) {
|
|
124
|
+
setDisplayName(profile.displayName);
|
|
125
|
+
setBio(profile.bio ?? '');
|
|
126
|
+
}
|
|
127
|
+
}, [viewingOwnProfile, profile?.id, profile?.displayName, profile?.bio]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!successMessage) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const timeout = setTimeout(() => setSuccessMessage(undefined), 2500);
|
|
134
|
+
return () => clearTimeout(timeout);
|
|
135
|
+
}, [successMessage]);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
setFollowError(undefined);
|
|
139
|
+
}, [profile?.id]);
|
|
140
|
+
|
|
141
|
+
const posts = useMemo(() => selectProfilePosts(profile), [profile]);
|
|
142
|
+
|
|
143
|
+
const handleRefresh = useCallback(() => {
|
|
144
|
+
if (shouldPromptSignIn) return;
|
|
145
|
+
activeRefetch?.();
|
|
146
|
+
}, [shouldPromptSignIn, activeRefetch]);
|
|
147
|
+
|
|
148
|
+
const handleSave = useCallback(async () => {
|
|
149
|
+
if (!viewingOwnProfile) return;
|
|
150
|
+
|
|
151
|
+
const trimmedName = displayName.trim();
|
|
152
|
+
const trimmedBio = bio.trim();
|
|
153
|
+
|
|
154
|
+
if (!trimmedName) {
|
|
155
|
+
setFormError('Display name is required.');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setFormError(undefined);
|
|
160
|
+
setSuccessMessage(undefined);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const result = await updateProfileMutation({
|
|
164
|
+
variables: {
|
|
165
|
+
displayName: trimmedName,
|
|
166
|
+
bio: trimmedBio.length ? trimmedBio : null
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (result.data?.updateProfile) {
|
|
171
|
+
setSuccessMessage('Profile updated');
|
|
172
|
+
await refetchMe();
|
|
173
|
+
}
|
|
174
|
+
} catch (mutationError) {
|
|
175
|
+
console.warn('Failed to update profile', mutationError);
|
|
176
|
+
setFormError('We could not update your profile. Please try again.');
|
|
177
|
+
}
|
|
178
|
+
}, [viewingOwnProfile, displayName, bio, updateProfileMutation, refetchMe]);
|
|
179
|
+
|
|
180
|
+
const handleLogin = useCallback(() => {
|
|
181
|
+
auth?.login?.();
|
|
182
|
+
}, [auth]);
|
|
183
|
+
|
|
184
|
+
const handleLogout = useCallback(() => {
|
|
185
|
+
auth?.logout?.();
|
|
186
|
+
}, [auth]);
|
|
187
|
+
|
|
188
|
+
const handleToggleFollow = useCallback(async () => {
|
|
189
|
+
if (viewingOwnProfile || !profile?.id || followMutationInFlight) return;
|
|
190
|
+
|
|
191
|
+
if (!isAuthenticated) {
|
|
192
|
+
auth?.login?.();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
setFollowError(undefined);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
if (profile.followedByMe) {
|
|
200
|
+
await unfollowUserMutation({ variables: { userId: profile.id } });
|
|
201
|
+
} else {
|
|
202
|
+
await followUserMutation({ variables: { userId: profile.id } });
|
|
203
|
+
}
|
|
204
|
+
await activeRefetch?.();
|
|
205
|
+
} catch (mutationError) {
|
|
206
|
+
console.warn('Failed to update follow status', mutationError);
|
|
207
|
+
setFollowError('We could not update the follow status. Please try again.');
|
|
208
|
+
}
|
|
209
|
+
}, [
|
|
210
|
+
viewingOwnProfile,
|
|
211
|
+
profile?.id,
|
|
212
|
+
profile?.followedByMe,
|
|
213
|
+
followMutationInFlight,
|
|
214
|
+
isAuthenticated,
|
|
215
|
+
auth,
|
|
216
|
+
unfollowUserMutation,
|
|
217
|
+
followUserMutation,
|
|
218
|
+
activeRefetch
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
// --- small renderer helpers to lower complexity ---
|
|
222
|
+
const renderNoHandle = () => (
|
|
223
|
+
<View style={styles.centeredContainer}>
|
|
224
|
+
<View style={styles.placeholderCard}>
|
|
225
|
+
<Text style={styles.placeholderTitle}>No handle provided</Text>
|
|
226
|
+
<Text style={styles.placeholderBody}>
|
|
227
|
+
We could not determine which profile to show. Return to the feed and try again.
|
|
228
|
+
</Text>
|
|
229
|
+
</View>
|
|
230
|
+
</View>
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const renderSignInPrompt = () => (
|
|
234
|
+
<View style={styles.centeredContainer}>
|
|
235
|
+
<View style={styles.placeholderCard}>
|
|
236
|
+
<Text style={styles.placeholderTitle}>Sign in to continue</Text>
|
|
237
|
+
<Text style={styles.placeholderBody}>
|
|
238
|
+
Log in to view and edit your profile, see your recent activity, and
|
|
239
|
+
connect with teammates.
|
|
240
|
+
</Text>
|
|
241
|
+
<TouchableOpacity
|
|
242
|
+
style={styles.primaryButton}
|
|
243
|
+
onPress={handleLogin}
|
|
244
|
+
accessibilityRole="button"
|
|
245
|
+
>
|
|
246
|
+
<Text style={styles.primaryButtonText}>Sign in</Text>
|
|
247
|
+
</TouchableOpacity>
|
|
248
|
+
{token ? (
|
|
249
|
+
<TouchableOpacity
|
|
250
|
+
style={styles.secondaryButton}
|
|
251
|
+
onPress={handleLogout}
|
|
252
|
+
accessibilityRole="button"
|
|
253
|
+
>
|
|
254
|
+
<Text style={styles.secondaryButtonText}>Log out</Text>
|
|
255
|
+
</TouchableOpacity>
|
|
256
|
+
) : null}
|
|
257
|
+
</View>
|
|
258
|
+
</View>
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const renderFollowAction = () => (
|
|
262
|
+
<TouchableOpacity
|
|
263
|
+
style={[
|
|
264
|
+
styles.followActionButton,
|
|
265
|
+
profile?.followedByMe ? styles.followingButton : styles.followButton,
|
|
266
|
+
followMutationInFlight ? styles.buttonDisabled : null
|
|
267
|
+
]}
|
|
268
|
+
onPress={handleToggleFollow}
|
|
269
|
+
disabled={followMutationInFlight}
|
|
270
|
+
accessibilityRole="button"
|
|
271
|
+
>
|
|
272
|
+
{followMutationInFlight ? (
|
|
273
|
+
<ActivityIndicator color={activityIndicatorColor} />
|
|
274
|
+
) : (
|
|
275
|
+
<Text
|
|
276
|
+
style={
|
|
277
|
+
profile?.followedByMe
|
|
278
|
+
? styles.followingButtonText
|
|
279
|
+
: styles.followButtonText
|
|
280
|
+
}
|
|
281
|
+
>
|
|
282
|
+
{followButtonLabel}
|
|
283
|
+
</Text>
|
|
284
|
+
)}
|
|
285
|
+
</TouchableOpacity>
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const renderProfileHeader = () => (
|
|
289
|
+
<View style={styles.card}>
|
|
290
|
+
<View style={styles.avatarCircle}>
|
|
291
|
+
{profile?.avatarUrl ? (
|
|
292
|
+
<Image
|
|
293
|
+
source={{ uri: profile.avatarUrl }}
|
|
294
|
+
style={styles.avatarImage}
|
|
295
|
+
resizeMode="cover"
|
|
296
|
+
/>
|
|
297
|
+
) : (
|
|
298
|
+
<PlaceholderAvatar />
|
|
299
|
+
)}
|
|
300
|
+
</View>
|
|
301
|
+
|
|
302
|
+
<View style={styles.headerText}>
|
|
303
|
+
<Text style={styles.displayName}>
|
|
304
|
+
{profile?.displayName ?? 'Set your display name'}
|
|
305
|
+
</Text>
|
|
306
|
+
<Text style={styles.handleText}>
|
|
307
|
+
{profile ? `@${profile.handle}` : 'No handle yet'}
|
|
308
|
+
</Text>
|
|
309
|
+
|
|
310
|
+
<View style={styles.metricsRow}>
|
|
311
|
+
<View style={styles.metricItem}>
|
|
312
|
+
<Text style={styles.metricValue}>
|
|
313
|
+
{profile?.followersCount ?? 0}
|
|
314
|
+
</Text>
|
|
315
|
+
<Text style={styles.metricLabel}>Followers</Text>
|
|
316
|
+
</View>
|
|
317
|
+
<View style={styles.metricItem}>
|
|
318
|
+
<Text style={styles.metricValue}>
|
|
319
|
+
{profile?.followingCount ?? 0}
|
|
320
|
+
</Text>
|
|
321
|
+
<Text style={styles.metricLabel}>Following</Text>
|
|
322
|
+
</View>
|
|
323
|
+
</View>
|
|
324
|
+
</View>
|
|
325
|
+
|
|
326
|
+
{viewingOwnProfile ? (
|
|
327
|
+
<TouchableOpacity
|
|
328
|
+
style={styles.logoutChip}
|
|
329
|
+
onPress={handleLogout}
|
|
330
|
+
accessibilityRole="button"
|
|
331
|
+
>
|
|
332
|
+
<Text style={styles.logoutChipText}>Log out</Text>
|
|
333
|
+
</TouchableOpacity>
|
|
334
|
+
) : null}
|
|
335
|
+
|
|
336
|
+
{viewingOwnProfile ? null : renderFollowAction()}
|
|
337
|
+
</View>
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
const renderEditableProfile = () => (
|
|
342
|
+
<View style={styles.card}>
|
|
343
|
+
<Text style={styles.sectionTitle}>Profile details</Text>
|
|
344
|
+
|
|
345
|
+
<View style={styles.formGroup}>
|
|
346
|
+
<Text style={styles.label}>Display name</Text>
|
|
347
|
+
<TextInput
|
|
348
|
+
value={displayName}
|
|
349
|
+
onChangeText={setDisplayName}
|
|
350
|
+
placeholder="How should teammates see you?"
|
|
351
|
+
style={styles.textInput}
|
|
352
|
+
editable={!saving}
|
|
353
|
+
autoCapitalize="words"
|
|
354
|
+
/>
|
|
355
|
+
</View>
|
|
356
|
+
|
|
357
|
+
<View style={styles.formGroup}>
|
|
358
|
+
<Text style={styles.label}>Bio</Text>
|
|
359
|
+
<TextInput
|
|
360
|
+
value={bio}
|
|
361
|
+
onChangeText={setBio}
|
|
362
|
+
placeholder="Share a short introduction"
|
|
363
|
+
style={[styles.textInput, styles.bioInput]}
|
|
364
|
+
multiline
|
|
365
|
+
numberOfLines={4}
|
|
366
|
+
textAlignVertical="top"
|
|
367
|
+
editable={!saving}
|
|
368
|
+
/>
|
|
369
|
+
</View>
|
|
370
|
+
|
|
371
|
+
{formError ? <Text style={styles.errorText}>{formError}</Text> : null}
|
|
372
|
+
{successMessage ? <Text style={styles.successText}>{successMessage}</Text> : null}
|
|
373
|
+
|
|
374
|
+
<TouchableOpacity
|
|
375
|
+
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
|
|
376
|
+
onPress={handleSave}
|
|
377
|
+
disabled={saving}
|
|
378
|
+
accessibilityRole="button"
|
|
379
|
+
>
|
|
380
|
+
{saving ? <ActivityIndicator color="#fff" /> : <Text style={styles.primaryButtonText}>Save changes</Text>}
|
|
381
|
+
</TouchableOpacity>
|
|
382
|
+
</View>
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const aboutSection = profile?.bio ? (
|
|
386
|
+
<View style={styles.card}>
|
|
387
|
+
<Text style={styles.sectionTitle}>About</Text>
|
|
388
|
+
<Text style={styles.readOnlyBio}>{profile.bio}</Text>
|
|
389
|
+
</View>
|
|
390
|
+
) : null;
|
|
391
|
+
|
|
392
|
+
const renderRecentPosts = () => (
|
|
393
|
+
<View style={styles.card}>
|
|
394
|
+
<Text style={styles.sectionTitle}>Recent posts</Text>
|
|
395
|
+
|
|
396
|
+
{activeLoading && !profile ? <ActivityIndicator style={styles.postsLoading} /> : null}
|
|
397
|
+
|
|
398
|
+
{!activeLoading && posts.length === 0 ? (
|
|
399
|
+
<Text style={styles.placeholderBody}>
|
|
400
|
+
{viewingOwnProfile
|
|
401
|
+
? 'You have not shared anything yet. Post from the feed to see it listed here.'
|
|
402
|
+
: 'No recent posts to show yet. Check back later!'}
|
|
403
|
+
</Text>
|
|
404
|
+
) : null}
|
|
405
|
+
|
|
406
|
+
{posts.map((post) => (
|
|
407
|
+
<View key={post.id} style={styles.postCard}>
|
|
408
|
+
<Text style={styles.postTimestamp}>
|
|
409
|
+
{formatRelativeProfileTimestamp(post.createdAt)}
|
|
410
|
+
</Text>
|
|
411
|
+
<Text style={styles.postBody}>{post.body}</Text>
|
|
412
|
+
</View>
|
|
413
|
+
))}
|
|
414
|
+
</View>
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const renderActiveError = () =>
|
|
418
|
+
activeError && !unauthenticatedError ? (
|
|
419
|
+
<View style={styles.errorCard}>
|
|
420
|
+
<Text style={styles.errorTitle}>We hit a snag</Text>
|
|
421
|
+
<Text style={styles.errorBody}>
|
|
422
|
+
Something went wrong while loading your profile. Pull to refresh or
|
|
423
|
+
try again.
|
|
424
|
+
</Text>
|
|
425
|
+
</View>
|
|
426
|
+
) : null;
|
|
427
|
+
|
|
428
|
+
const renderProfileNotFound = () =>
|
|
429
|
+
!activeLoading && !profile && !activeError ? (
|
|
430
|
+
<View style={styles.errorCard}>
|
|
431
|
+
<Text style={styles.errorTitle}>Profile not found</Text>
|
|
432
|
+
<Text style={styles.errorBody}>
|
|
433
|
+
This handle does not exist or is no longer available.
|
|
434
|
+
</Text>
|
|
435
|
+
</View>
|
|
436
|
+
) : null;
|
|
437
|
+
|
|
438
|
+
// --- top-level early returns to simplify main render ---
|
|
439
|
+
if (!viewingOwnProfile && trimmedHandle.length === 0) return renderNoHandle();
|
|
440
|
+
if (shouldPromptSignIn) return renderSignInPrompt();
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<ScrollView
|
|
444
|
+
style={styles.scroll}
|
|
445
|
+
contentContainerStyle={styles.scrollContent}
|
|
446
|
+
keyboardShouldPersistTaps="handled"
|
|
447
|
+
refreshControl={
|
|
448
|
+
<RefreshControl refreshing={activeLoading} onRefresh={handleRefresh} />
|
|
449
|
+
}
|
|
450
|
+
>
|
|
451
|
+
{renderProfileHeader()}
|
|
452
|
+
|
|
453
|
+
{!viewingOwnProfile && followError ? (
|
|
454
|
+
<Text style={styles.errorText}>{followError}</Text>
|
|
455
|
+
) : null}
|
|
456
|
+
|
|
457
|
+
{viewingOwnProfile ? renderEditableProfile() : aboutSection}
|
|
458
|
+
|
|
459
|
+
{renderRecentPosts()}
|
|
460
|
+
|
|
461
|
+
{renderActiveError()}
|
|
462
|
+
|
|
463
|
+
{renderProfileNotFound()}
|
|
464
|
+
</ScrollView>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const {color, spacing, radius, shadow} = tokens;
|
|
469
|
+
const profileCardRecipe = cardRecipes.surface;
|
|
470
|
+
const postCardRecipe = cardRecipes.subdued;
|
|
471
|
+
const placeholderPalette = placeholderAvatarRecipe;
|
|
472
|
+
|
|
473
|
+
const styles = StyleSheet.create({
|
|
474
|
+
scroll: {
|
|
475
|
+
flex: 1,
|
|
476
|
+
backgroundColor: color.surfaceMuted
|
|
477
|
+
},
|
|
478
|
+
scrollContent: {
|
|
479
|
+
padding: spacing.lg,
|
|
480
|
+
gap: spacing.lg
|
|
481
|
+
},
|
|
482
|
+
centeredContainer: {
|
|
483
|
+
flex: 1,
|
|
484
|
+
alignItems: 'center',
|
|
485
|
+
justifyContent: 'center',
|
|
486
|
+
padding: spacing.xxl,
|
|
487
|
+
backgroundColor: color.surfaceMuted
|
|
488
|
+
},
|
|
489
|
+
placeholderCard: {
|
|
490
|
+
width: '100%',
|
|
491
|
+
maxWidth: 360,
|
|
492
|
+
backgroundColor: color[profileCardRecipe.backgroundColor],
|
|
493
|
+
borderRadius: radius[profileCardRecipe.radius],
|
|
494
|
+
padding: spacing.xxl,
|
|
495
|
+
alignItems: 'center',
|
|
496
|
+
gap: spacing.lg,
|
|
497
|
+
...shadow[profileCardRecipe.elevation]
|
|
498
|
+
},
|
|
499
|
+
placeholderTitle: {
|
|
500
|
+
fontSize: 20,
|
|
501
|
+
fontWeight: '700',
|
|
502
|
+
color: color.text,
|
|
503
|
+
textAlign: 'center'
|
|
504
|
+
},
|
|
505
|
+
placeholderBody: {
|
|
506
|
+
fontSize: 14,
|
|
507
|
+
color: color.textMuted,
|
|
508
|
+
textAlign: 'center',
|
|
509
|
+
lineHeight: 20
|
|
510
|
+
},
|
|
511
|
+
primaryButton: {
|
|
512
|
+
width: '100%',
|
|
513
|
+
backgroundColor: color.primary,
|
|
514
|
+
borderRadius: radius.pill,
|
|
515
|
+
paddingVertical: spacing.mdPlus,
|
|
516
|
+
paddingHorizontal: spacing.xxl,
|
|
517
|
+
alignItems: 'center',
|
|
518
|
+
justifyContent: 'center',
|
|
519
|
+
...shadow.md,
|
|
520
|
+
shadowColor: color.primary
|
|
521
|
+
},
|
|
522
|
+
primaryButtonText: {
|
|
523
|
+
fontSize: 16,
|
|
524
|
+
fontWeight: '600',
|
|
525
|
+
color: color.surface
|
|
526
|
+
},
|
|
527
|
+
secondaryButton: {
|
|
528
|
+
width: '100%',
|
|
529
|
+
borderRadius: radius.pill,
|
|
530
|
+
paddingVertical: spacing.md,
|
|
531
|
+
alignItems: 'center',
|
|
532
|
+
justifyContent: 'center',
|
|
533
|
+
borderWidth: 1,
|
|
534
|
+
borderColor: color.borderStrong
|
|
535
|
+
},
|
|
536
|
+
secondaryButtonText: {
|
|
537
|
+
fontSize: 15,
|
|
538
|
+
fontWeight: '600',
|
|
539
|
+
color: color.text
|
|
540
|
+
},
|
|
541
|
+
buttonDisabled: {
|
|
542
|
+
opacity: 0.65
|
|
543
|
+
},
|
|
544
|
+
card: {
|
|
545
|
+
backgroundColor: color[profileCardRecipe.backgroundColor],
|
|
546
|
+
borderRadius: radius[profileCardRecipe.radius],
|
|
547
|
+
padding: spacing[profileCardRecipe.padding],
|
|
548
|
+
gap: spacing.lg,
|
|
549
|
+
...shadow[profileCardRecipe.elevation]
|
|
550
|
+
},
|
|
551
|
+
avatarCircle: {
|
|
552
|
+
width: 96,
|
|
553
|
+
height: 96,
|
|
554
|
+
borderRadius: 48,
|
|
555
|
+
overflow: 'hidden',
|
|
556
|
+
backgroundColor: color[placeholderPalette.container],
|
|
557
|
+
alignSelf: 'center',
|
|
558
|
+
alignItems: 'center',
|
|
559
|
+
justifyContent: 'center'
|
|
560
|
+
},
|
|
561
|
+
avatarImage: {
|
|
562
|
+
width: '100%',
|
|
563
|
+
height: '100%'
|
|
564
|
+
},
|
|
565
|
+
placeholderAvatar: {
|
|
566
|
+
width: '100%',
|
|
567
|
+
height: '100%',
|
|
568
|
+
alignItems: 'center',
|
|
569
|
+
justifyContent: 'center'
|
|
570
|
+
},
|
|
571
|
+
placeholderHead: {
|
|
572
|
+
width: 44,
|
|
573
|
+
height: 44,
|
|
574
|
+
borderRadius: 22,
|
|
575
|
+
backgroundColor: color[placeholderPalette.head]
|
|
576
|
+
},
|
|
577
|
+
placeholderShoulders: {
|
|
578
|
+
width: 68,
|
|
579
|
+
height: 36,
|
|
580
|
+
borderRadius: 18,
|
|
581
|
+
backgroundColor: color[placeholderPalette.shoulders],
|
|
582
|
+
marginTop: spacing.sm
|
|
583
|
+
},
|
|
584
|
+
headerText: {
|
|
585
|
+
alignItems: 'center',
|
|
586
|
+
gap: spacing.xxs
|
|
587
|
+
},
|
|
588
|
+
displayName: {
|
|
589
|
+
fontSize: 22,
|
|
590
|
+
fontWeight: '700',
|
|
591
|
+
color: color.text
|
|
592
|
+
},
|
|
593
|
+
handleText: {
|
|
594
|
+
fontSize: 14,
|
|
595
|
+
color: color.textSubdued
|
|
596
|
+
},
|
|
597
|
+
metricsRow: {
|
|
598
|
+
flexDirection: 'row',
|
|
599
|
+
gap: spacing.xxl,
|
|
600
|
+
marginTop: spacing.sm
|
|
601
|
+
},
|
|
602
|
+
metricItem: {
|
|
603
|
+
alignItems: 'center'
|
|
604
|
+
},
|
|
605
|
+
metricValue: {
|
|
606
|
+
fontSize: 18,
|
|
607
|
+
fontWeight: '700',
|
|
608
|
+
color: color.text
|
|
609
|
+
},
|
|
610
|
+
metricLabel: {
|
|
611
|
+
fontSize: 12,
|
|
612
|
+
color: color.textMuted,
|
|
613
|
+
textTransform: 'uppercase',
|
|
614
|
+
letterSpacing: 0.6
|
|
615
|
+
},
|
|
616
|
+
logoutChip: {
|
|
617
|
+
alignSelf: 'center',
|
|
618
|
+
borderRadius: radius.pill,
|
|
619
|
+
borderWidth: 1,
|
|
620
|
+
borderColor: color.borderStrong,
|
|
621
|
+
paddingHorizontal: spacing.lg,
|
|
622
|
+
paddingVertical: spacing.sm
|
|
623
|
+
},
|
|
624
|
+
logoutChipText: {
|
|
625
|
+
fontSize: 12,
|
|
626
|
+
fontWeight: '600',
|
|
627
|
+
color: color.text
|
|
628
|
+
},
|
|
629
|
+
followActionButton: {
|
|
630
|
+
marginTop: spacing.md,
|
|
631
|
+
alignSelf: 'center',
|
|
632
|
+
borderRadius: radius.pill,
|
|
633
|
+
paddingHorizontal: spacing.xxl,
|
|
634
|
+
paddingVertical: spacing.smPlus,
|
|
635
|
+
minWidth: 120,
|
|
636
|
+
alignItems: 'center'
|
|
637
|
+
},
|
|
638
|
+
followButton: {
|
|
639
|
+
backgroundColor: color.primary
|
|
640
|
+
},
|
|
641
|
+
followingButton: {
|
|
642
|
+
backgroundColor: color.surfaceSubdued
|
|
643
|
+
},
|
|
644
|
+
followButtonText: {
|
|
645
|
+
fontSize: 14,
|
|
646
|
+
fontWeight: '600',
|
|
647
|
+
color: color.surface
|
|
648
|
+
},
|
|
649
|
+
followingButtonText: {
|
|
650
|
+
fontSize: 14,
|
|
651
|
+
fontWeight: '600',
|
|
652
|
+
color: color.text
|
|
653
|
+
},
|
|
654
|
+
sectionTitle: {
|
|
655
|
+
fontSize: 16,
|
|
656
|
+
fontWeight: '700',
|
|
657
|
+
color: color.text
|
|
658
|
+
},
|
|
659
|
+
readOnlyBio: {
|
|
660
|
+
fontSize: 15,
|
|
661
|
+
lineHeight: 20,
|
|
662
|
+
color: color.text
|
|
663
|
+
},
|
|
664
|
+
formGroup: {
|
|
665
|
+
gap: spacing.sm
|
|
666
|
+
},
|
|
667
|
+
label: {
|
|
668
|
+
fontSize: 13,
|
|
669
|
+
fontWeight: '600',
|
|
670
|
+
color: color.text
|
|
671
|
+
},
|
|
672
|
+
textInput: {
|
|
673
|
+
borderRadius: radius.md,
|
|
674
|
+
borderWidth: 1,
|
|
675
|
+
borderColor: color.borderStrong,
|
|
676
|
+
paddingHorizontal: spacing.mdPlus,
|
|
677
|
+
paddingVertical: spacing.md,
|
|
678
|
+
fontSize: 15,
|
|
679
|
+
color: color.text,
|
|
680
|
+
backgroundColor: color.surfaceAlt
|
|
681
|
+
},
|
|
682
|
+
bioInput: {
|
|
683
|
+
minHeight: 120
|
|
684
|
+
},
|
|
685
|
+
successText: {
|
|
686
|
+
fontSize: 13,
|
|
687
|
+
color: '#1d7e1f',
|
|
688
|
+
fontWeight: '600'
|
|
689
|
+
},
|
|
690
|
+
errorText: {
|
|
691
|
+
fontSize: 13,
|
|
692
|
+
color: '#d22c2c',
|
|
693
|
+
fontWeight: '600'
|
|
694
|
+
},
|
|
695
|
+
postsLoading: {
|
|
696
|
+
marginTop: spacing.md
|
|
697
|
+
},
|
|
698
|
+
postCard: {
|
|
699
|
+
borderRadius: radius[postCardRecipe.radius],
|
|
700
|
+
borderWidth: postCardRecipe.borderColor ? 1 : 0,
|
|
701
|
+
borderColor: postCardRecipe.borderColor ? color[postCardRecipe.borderColor] : undefined,
|
|
702
|
+
padding: spacing[postCardRecipe.padding],
|
|
703
|
+
gap: spacing.sm,
|
|
704
|
+
backgroundColor: color[postCardRecipe.backgroundColor],
|
|
705
|
+
marginTop: spacing.md
|
|
706
|
+
},
|
|
707
|
+
postTimestamp: {
|
|
708
|
+
fontSize: 12,
|
|
709
|
+
color: color.textMuted
|
|
710
|
+
},
|
|
711
|
+
postBody: {
|
|
712
|
+
fontSize: 15,
|
|
713
|
+
color: color.text,
|
|
714
|
+
lineHeight: 20
|
|
715
|
+
},
|
|
716
|
+
errorCard: {
|
|
717
|
+
backgroundColor: '#fff4f4',
|
|
718
|
+
borderRadius: radius.lg,
|
|
719
|
+
padding: spacing.lg,
|
|
720
|
+
borderWidth: 1,
|
|
721
|
+
borderColor: '#f6d5d5',
|
|
722
|
+
gap: spacing.xs
|
|
723
|
+
},
|
|
724
|
+
errorTitle: {
|
|
725
|
+
fontSize: 15,
|
|
726
|
+
fontWeight: '700',
|
|
727
|
+
color: '#b11f1f'
|
|
728
|
+
},
|
|
729
|
+
errorBody: {
|
|
730
|
+
fontSize: 13,
|
|
731
|
+
color: '#7a2f2f',
|
|
732
|
+
lineHeight: 18
|
|
733
|
+
}
|
|
734
|
+
});
|