@mereb/app-profile 0.0.2 → 0.0.3

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.
@@ -0,0 +1,45 @@
1
+ export { MeProfileDocument, ProfileByHandleDocument, FollowUserDocument, UnfollowUserDocument, UpdateProfileDocument } from '@mereb/shared-graphql';
2
+ const MINUTE = 60 * 1000;
3
+ const HOUR = 60 * MINUTE;
4
+ const DAY = 24 * HOUR;
5
+ export function parseProfileTimestamp(value) {
6
+ if (!value) {
7
+ return null;
8
+ }
9
+ const numeric = Number(value);
10
+ if (!Number.isNaN(numeric) && numeric > 0) {
11
+ const numericDate = new Date(numeric);
12
+ return Number.isNaN(numericDate.getTime()) ? null : numericDate;
13
+ }
14
+ const date = new Date(value);
15
+ return Number.isNaN(date.getTime()) ? null : date;
16
+ }
17
+ export function formatRelativeProfileTimestamp(value, now = Date.now()) {
18
+ const parsed = parseProfileTimestamp(value);
19
+ if (!parsed) {
20
+ return 'Just now';
21
+ }
22
+ const diff = now - parsed.getTime();
23
+ if (diff < MINUTE) {
24
+ return 'Just now';
25
+ }
26
+ if (diff < HOUR) {
27
+ const mins = Math.round(diff / MINUTE);
28
+ return `${mins} min${mins === 1 ? '' : 's'} ago`;
29
+ }
30
+ if (diff < DAY) {
31
+ const hours = Math.round(diff / HOUR);
32
+ return `${hours} hr${hours === 1 ? '' : 's'} ago`;
33
+ }
34
+ return parsed.toLocaleDateString(undefined, {
35
+ year: parsed.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
36
+ month: 'short',
37
+ day: 'numeric'
38
+ });
39
+ }
40
+ export function selectProfilePosts(profile) {
41
+ if (!profile?.posts?.edges?.length) {
42
+ return [];
43
+ }
44
+ return profile.posts.edges.map((edge) => edge.node);
45
+ }
@@ -7,5 +7,5 @@ type ProfileScreenProps = {
7
7
  auth?: AuthControls;
8
8
  handle?: string;
9
9
  };
10
- export declare function ProfileScreen({ auth, handle }: Readonly<ProfileScreenProps>): import("react").JSX.Element;
10
+ export declare function ProfileScreen({ auth, handle }: Readonly<ProfileScreenProps>): import("react/jsx-runtime.js").JSX.Element;
11
11
  export {};
package/dist/index.js ADDED
@@ -0,0 +1,432 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { ActivityIndicator, Image, RefreshControl, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
4
+ import { useFollowUserMutation, useMeProfileQuery, useProfileByHandleQuery, useUnfollowUserMutation, useUpdateProfileMutation } from '@mereb/shared-graphql';
5
+ import { tokens } from '@mereb/tokens/native';
6
+ import { cardRecipes, placeholderAvatarRecipe } from '@mereb/ui-shared';
7
+ import { formatRelativeProfileTimestamp, selectProfilePosts } from './headless.js';
8
+ function extractGraphQLErrors(error) {
9
+ if (error &&
10
+ typeof error === 'object' &&
11
+ 'graphQLErrors' in error &&
12
+ Array.isArray(error.graphQLErrors)) {
13
+ return error.graphQLErrors;
14
+ }
15
+ return undefined;
16
+ }
17
+ function PlaceholderAvatar() {
18
+ return (_jsxs(View, { style: styles.placeholderAvatar, accessible: false, children: [_jsx(View, { style: styles.placeholderHead }), _jsx(View, { style: styles.placeholderShoulders })] }));
19
+ }
20
+ export function ProfileScreen({ auth, handle }) {
21
+ const token = auth?.token;
22
+ const viewingOwnProfile = !handle;
23
+ const trimmedHandle = handle?.replace(/^@/, '') ?? '';
24
+ const { data: meData, loading: loadingMe, error: meError, refetch: refetchMe } = useMeProfileQuery({
25
+ variables: { postsLimit: 10 },
26
+ skip: !viewingOwnProfile || !token,
27
+ notifyOnNetworkStatusChange: true
28
+ });
29
+ const { data: handleData, loading: loadingHandle, error: handleError, refetch: refetchHandle } = useProfileByHandleQuery({
30
+ variables: { handle: trimmedHandle, postsLimit: 10 },
31
+ skip: viewingOwnProfile || trimmedHandle.length === 0,
32
+ notifyOnNetworkStatusChange: true
33
+ });
34
+ const activeError = viewingOwnProfile ? meError : handleError;
35
+ const activeLoading = viewingOwnProfile ? loadingMe : loadingHandle;
36
+ const activeRefetch = viewingOwnProfile ? refetchMe : refetchHandle;
37
+ const profile = viewingOwnProfile ? meData?.me : handleData?.userByHandle;
38
+ const followButtonLabel = profile?.followedByMe ? 'Following' : 'Follow';
39
+ const activityIndicatorColor = profile?.followedByMe ? '#0f172a' : '#fff';
40
+ const unauthenticatedError = viewingOwnProfile
41
+ ? Boolean(extractGraphQLErrors(meError)?.some((graphError) => graphError.message?.toUpperCase().includes('UNAUTHENTICATED')))
42
+ : false;
43
+ const shouldPromptSignIn = viewingOwnProfile && (!token || unauthenticatedError);
44
+ const [updateProfileMutation, { loading: saving }] = useUpdateProfileMutation();
45
+ const [followUserMutation, { loading: followLoading }] = useFollowUserMutation();
46
+ const [unfollowUserMutation, { loading: unfollowLoading }] = useUnfollowUserMutation();
47
+ const [displayName, setDisplayName] = useState('');
48
+ const [bio, setBio] = useState('');
49
+ const [formError, setFormError] = useState();
50
+ const [successMessage, setSuccessMessage] = useState();
51
+ const [followError, setFollowError] = useState();
52
+ const followMutationInFlight = followLoading || unfollowLoading;
53
+ const isAuthenticated = Boolean(token);
54
+ useEffect(() => {
55
+ if (viewingOwnProfile && profile) {
56
+ setDisplayName(profile.displayName);
57
+ setBio(profile.bio ?? '');
58
+ }
59
+ }, [viewingOwnProfile, profile?.id, profile?.displayName, profile?.bio]);
60
+ useEffect(() => {
61
+ if (!successMessage) {
62
+ return;
63
+ }
64
+ const timeout = setTimeout(() => setSuccessMessage(undefined), 2500);
65
+ return () => clearTimeout(timeout);
66
+ }, [successMessage]);
67
+ useEffect(() => {
68
+ setFollowError(undefined);
69
+ }, [profile?.id]);
70
+ const posts = useMemo(() => selectProfilePosts(profile), [profile]);
71
+ const handleRefresh = useCallback(() => {
72
+ if (shouldPromptSignIn)
73
+ return;
74
+ activeRefetch?.();
75
+ }, [shouldPromptSignIn, activeRefetch]);
76
+ const handleSave = useCallback(async () => {
77
+ if (!viewingOwnProfile)
78
+ return;
79
+ const trimmedName = displayName.trim();
80
+ const trimmedBio = bio.trim();
81
+ if (!trimmedName) {
82
+ setFormError('Display name is required.');
83
+ return;
84
+ }
85
+ setFormError(undefined);
86
+ setSuccessMessage(undefined);
87
+ try {
88
+ const result = await updateProfileMutation({
89
+ variables: {
90
+ displayName: trimmedName,
91
+ bio: trimmedBio.length ? trimmedBio : null
92
+ }
93
+ });
94
+ if (result.data?.updateProfile) {
95
+ setSuccessMessage('Profile updated');
96
+ await refetchMe();
97
+ }
98
+ }
99
+ catch (mutationError) {
100
+ console.warn('Failed to update profile', mutationError);
101
+ setFormError('We could not update your profile. Please try again.');
102
+ }
103
+ }, [viewingOwnProfile, displayName, bio, updateProfileMutation, refetchMe]);
104
+ const handleLogin = useCallback(() => {
105
+ auth?.login?.();
106
+ }, [auth]);
107
+ const handleLogout = useCallback(() => {
108
+ auth?.logout?.();
109
+ }, [auth]);
110
+ const handleToggleFollow = useCallback(async () => {
111
+ if (viewingOwnProfile || !profile?.id || followMutationInFlight)
112
+ return;
113
+ if (!isAuthenticated) {
114
+ auth?.login?.();
115
+ return;
116
+ }
117
+ setFollowError(undefined);
118
+ try {
119
+ if (profile.followedByMe) {
120
+ await unfollowUserMutation({ variables: { userId: profile.id } });
121
+ }
122
+ else {
123
+ await followUserMutation({ variables: { userId: profile.id } });
124
+ }
125
+ await activeRefetch?.();
126
+ }
127
+ catch (mutationError) {
128
+ console.warn('Failed to update follow status', mutationError);
129
+ setFollowError('We could not update the follow status. Please try again.');
130
+ }
131
+ }, [
132
+ viewingOwnProfile,
133
+ profile?.id,
134
+ profile?.followedByMe,
135
+ followMutationInFlight,
136
+ isAuthenticated,
137
+ auth,
138
+ unfollowUserMutation,
139
+ followUserMutation,
140
+ activeRefetch
141
+ ]);
142
+ // --- small renderer helpers to lower complexity ---
143
+ const renderNoHandle = () => (_jsx(View, { style: styles.centeredContainer, children: _jsxs(View, { style: styles.placeholderCard, children: [_jsx(Text, { style: styles.placeholderTitle, children: "No handle provided" }), _jsx(Text, { style: styles.placeholderBody, children: "We could not determine which profile to show. Return to the feed and try again." })] }) }));
144
+ const renderSignInPrompt = () => (_jsx(View, { style: styles.centeredContainer, children: _jsxs(View, { style: styles.placeholderCard, children: [_jsx(Text, { style: styles.placeholderTitle, children: "Sign in to continue" }), _jsx(Text, { style: styles.placeholderBody, children: "Log in to view and edit your profile, see your recent activity, and connect with teammates." }), _jsx(TouchableOpacity, { style: styles.primaryButton, onPress: handleLogin, accessibilityRole: "button", children: _jsx(Text, { style: styles.primaryButtonText, children: "Sign in" }) }), token ? (_jsx(TouchableOpacity, { style: styles.secondaryButton, onPress: handleLogout, accessibilityRole: "button", children: _jsx(Text, { style: styles.secondaryButtonText, children: "Log out" }) })) : null] }) }));
145
+ const renderFollowAction = () => (_jsx(TouchableOpacity, { style: [
146
+ styles.followActionButton,
147
+ profile?.followedByMe ? styles.followingButton : styles.followButton,
148
+ followMutationInFlight ? styles.buttonDisabled : null
149
+ ], onPress: handleToggleFollow, disabled: followMutationInFlight, accessibilityRole: "button", children: followMutationInFlight ? (_jsx(ActivityIndicator, { color: activityIndicatorColor })) : (_jsx(Text, { style: profile?.followedByMe
150
+ ? styles.followingButtonText
151
+ : styles.followButtonText, children: followButtonLabel })) }));
152
+ const renderProfileHeader = () => (_jsxs(View, { style: styles.card, children: [_jsx(View, { style: styles.avatarCircle, children: profile?.avatarUrl ? (_jsx(Image, { source: { uri: profile.avatarUrl }, style: styles.avatarImage, resizeMode: "cover" })) : (_jsx(PlaceholderAvatar, {})) }), _jsxs(View, { style: styles.headerText, children: [_jsx(Text, { style: styles.displayName, children: profile?.displayName ?? 'Set your display name' }), _jsx(Text, { style: styles.handleText, children: profile ? `@${profile.handle}` : 'No handle yet' }), _jsxs(View, { style: styles.metricsRow, children: [_jsxs(View, { style: styles.metricItem, children: [_jsx(Text, { style: styles.metricValue, children: profile?.followersCount ?? 0 }), _jsx(Text, { style: styles.metricLabel, children: "Followers" })] }), _jsxs(View, { style: styles.metricItem, children: [_jsx(Text, { style: styles.metricValue, children: profile?.followingCount ?? 0 }), _jsx(Text, { style: styles.metricLabel, children: "Following" })] })] })] }), viewingOwnProfile ? (_jsx(TouchableOpacity, { style: styles.logoutChip, onPress: handleLogout, accessibilityRole: "button", children: _jsx(Text, { style: styles.logoutChipText, children: "Log out" }) })) : null, viewingOwnProfile ? null : renderFollowAction()] }));
153
+ const renderEditableProfile = () => (_jsxs(View, { style: styles.card, children: [_jsx(Text, { style: styles.sectionTitle, children: "Profile details" }), _jsxs(View, { style: styles.formGroup, children: [_jsx(Text, { style: styles.label, children: "Display name" }), _jsx(TextInput, { value: displayName, onChangeText: setDisplayName, placeholder: "How should teammates see you?", style: styles.textInput, editable: !saving, autoCapitalize: "words" })] }), _jsxs(View, { style: styles.formGroup, children: [_jsx(Text, { style: styles.label, children: "Bio" }), _jsx(TextInput, { value: bio, onChangeText: setBio, placeholder: "Share a short introduction", style: [styles.textInput, styles.bioInput], multiline: true, numberOfLines: 4, textAlignVertical: "top", editable: !saving })] }), formError ? _jsx(Text, { style: styles.errorText, children: formError }) : null, successMessage ? _jsx(Text, { style: styles.successText, children: successMessage }) : null, _jsx(TouchableOpacity, { style: [styles.primaryButton, saving ? styles.buttonDisabled : null], onPress: handleSave, disabled: saving, accessibilityRole: "button", children: saving ? _jsx(ActivityIndicator, { color: "#fff" }) : _jsx(Text, { style: styles.primaryButtonText, children: "Save changes" }) })] }));
154
+ const aboutSection = profile?.bio ? (_jsxs(View, { style: styles.card, children: [_jsx(Text, { style: styles.sectionTitle, children: "About" }), _jsx(Text, { style: styles.readOnlyBio, children: profile.bio })] })) : null;
155
+ const renderRecentPosts = () => (_jsxs(View, { style: styles.card, children: [_jsx(Text, { style: styles.sectionTitle, children: "Recent posts" }), activeLoading && !profile ? _jsx(ActivityIndicator, { style: styles.postsLoading }) : null, !activeLoading && posts.length === 0 ? (_jsx(Text, { style: styles.placeholderBody, children: viewingOwnProfile
156
+ ? 'You have not shared anything yet. Post from the feed to see it listed here.'
157
+ : 'No recent posts to show yet. Check back later!' })) : null, posts.map((post) => (_jsxs(View, { style: styles.postCard, children: [_jsx(Text, { style: styles.postTimestamp, children: formatRelativeProfileTimestamp(post.createdAt) }), _jsx(Text, { style: styles.postBody, children: post.body })] }, post.id)))] }));
158
+ const renderActiveError = () => activeError && !unauthenticatedError ? (_jsxs(View, { style: styles.errorCard, children: [_jsx(Text, { style: styles.errorTitle, children: "We hit a snag" }), _jsx(Text, { style: styles.errorBody, children: "Something went wrong while loading your profile. Pull to refresh or try again." })] })) : null;
159
+ const renderProfileNotFound = () => !activeLoading && !profile && !activeError ? (_jsxs(View, { style: styles.errorCard, children: [_jsx(Text, { style: styles.errorTitle, children: "Profile not found" }), _jsx(Text, { style: styles.errorBody, children: "This handle does not exist or is no longer available." })] })) : null;
160
+ // --- top-level early returns to simplify main render ---
161
+ if (!viewingOwnProfile && trimmedHandle.length === 0)
162
+ return renderNoHandle();
163
+ if (shouldPromptSignIn)
164
+ return renderSignInPrompt();
165
+ return (_jsxs(ScrollView, { style: styles.scroll, contentContainerStyle: styles.scrollContent, keyboardShouldPersistTaps: "handled", refreshControl: _jsx(RefreshControl, { refreshing: activeLoading, onRefresh: handleRefresh }), children: [renderProfileHeader(), !viewingOwnProfile && followError ? (_jsx(Text, { style: styles.errorText, children: followError })) : null, viewingOwnProfile ? renderEditableProfile() : aboutSection, renderRecentPosts(), renderActiveError(), renderProfileNotFound()] }));
166
+ }
167
+ const { color, spacing, radius, shadow } = tokens;
168
+ const profileCardRecipe = cardRecipes.surface;
169
+ const postCardRecipe = cardRecipes.subdued;
170
+ const placeholderPalette = placeholderAvatarRecipe;
171
+ const styles = StyleSheet.create({
172
+ scroll: {
173
+ flex: 1,
174
+ backgroundColor: color.surfaceMuted
175
+ },
176
+ scrollContent: {
177
+ padding: spacing.lg,
178
+ gap: spacing.lg
179
+ },
180
+ centeredContainer: {
181
+ flex: 1,
182
+ alignItems: 'center',
183
+ justifyContent: 'center',
184
+ padding: spacing.xxl,
185
+ backgroundColor: color.surfaceMuted
186
+ },
187
+ placeholderCard: {
188
+ width: '100%',
189
+ maxWidth: 360,
190
+ backgroundColor: color[profileCardRecipe.backgroundColor],
191
+ borderRadius: radius[profileCardRecipe.radius],
192
+ padding: spacing.xxl,
193
+ alignItems: 'center',
194
+ gap: spacing.lg,
195
+ ...shadow[profileCardRecipe.elevation]
196
+ },
197
+ placeholderTitle: {
198
+ fontSize: 20,
199
+ fontWeight: '700',
200
+ color: color.text,
201
+ textAlign: 'center'
202
+ },
203
+ placeholderBody: {
204
+ fontSize: 14,
205
+ color: color.textMuted,
206
+ textAlign: 'center',
207
+ lineHeight: 20
208
+ },
209
+ primaryButton: {
210
+ width: '100%',
211
+ backgroundColor: color.primary,
212
+ borderRadius: radius.pill,
213
+ paddingVertical: spacing.mdPlus,
214
+ paddingHorizontal: spacing.xxl,
215
+ alignItems: 'center',
216
+ justifyContent: 'center',
217
+ ...shadow.md,
218
+ shadowColor: color.primary
219
+ },
220
+ primaryButtonText: {
221
+ fontSize: 16,
222
+ fontWeight: '600',
223
+ color: color.surface
224
+ },
225
+ secondaryButton: {
226
+ width: '100%',
227
+ borderRadius: radius.pill,
228
+ paddingVertical: spacing.md,
229
+ alignItems: 'center',
230
+ justifyContent: 'center',
231
+ borderWidth: 1,
232
+ borderColor: color.borderStrong
233
+ },
234
+ secondaryButtonText: {
235
+ fontSize: 15,
236
+ fontWeight: '600',
237
+ color: color.text
238
+ },
239
+ buttonDisabled: {
240
+ opacity: 0.65
241
+ },
242
+ card: {
243
+ backgroundColor: color[profileCardRecipe.backgroundColor],
244
+ borderRadius: radius[profileCardRecipe.radius],
245
+ padding: spacing[profileCardRecipe.padding],
246
+ gap: spacing.lg,
247
+ ...shadow[profileCardRecipe.elevation]
248
+ },
249
+ avatarCircle: {
250
+ width: 96,
251
+ height: 96,
252
+ borderRadius: 48,
253
+ overflow: 'hidden',
254
+ backgroundColor: color[placeholderPalette.container],
255
+ alignSelf: 'center',
256
+ alignItems: 'center',
257
+ justifyContent: 'center'
258
+ },
259
+ avatarImage: {
260
+ width: '100%',
261
+ height: '100%'
262
+ },
263
+ placeholderAvatar: {
264
+ width: '100%',
265
+ height: '100%',
266
+ alignItems: 'center',
267
+ justifyContent: 'center'
268
+ },
269
+ placeholderHead: {
270
+ width: 44,
271
+ height: 44,
272
+ borderRadius: 22,
273
+ backgroundColor: color[placeholderPalette.head]
274
+ },
275
+ placeholderShoulders: {
276
+ width: 68,
277
+ height: 36,
278
+ borderRadius: 18,
279
+ backgroundColor: color[placeholderPalette.shoulders],
280
+ marginTop: spacing.sm
281
+ },
282
+ headerText: {
283
+ alignItems: 'center',
284
+ gap: spacing.xxs
285
+ },
286
+ displayName: {
287
+ fontSize: 22,
288
+ fontWeight: '700',
289
+ color: color.text
290
+ },
291
+ handleText: {
292
+ fontSize: 14,
293
+ color: color.textSubdued
294
+ },
295
+ metricsRow: {
296
+ flexDirection: 'row',
297
+ gap: spacing.xxl,
298
+ marginTop: spacing.sm
299
+ },
300
+ metricItem: {
301
+ alignItems: 'center'
302
+ },
303
+ metricValue: {
304
+ fontSize: 18,
305
+ fontWeight: '700',
306
+ color: color.text
307
+ },
308
+ metricLabel: {
309
+ fontSize: 12,
310
+ color: color.textMuted,
311
+ textTransform: 'uppercase',
312
+ letterSpacing: 0.6
313
+ },
314
+ logoutChip: {
315
+ alignSelf: 'center',
316
+ borderRadius: radius.pill,
317
+ borderWidth: 1,
318
+ borderColor: color.borderStrong,
319
+ paddingHorizontal: spacing.lg,
320
+ paddingVertical: spacing.sm
321
+ },
322
+ logoutChipText: {
323
+ fontSize: 12,
324
+ fontWeight: '600',
325
+ color: color.text
326
+ },
327
+ followActionButton: {
328
+ marginTop: spacing.md,
329
+ alignSelf: 'center',
330
+ borderRadius: radius.pill,
331
+ paddingHorizontal: spacing.xxl,
332
+ paddingVertical: spacing.smPlus,
333
+ minWidth: 120,
334
+ alignItems: 'center'
335
+ },
336
+ followButton: {
337
+ backgroundColor: color.primary
338
+ },
339
+ followingButton: {
340
+ backgroundColor: color.surfaceSubdued
341
+ },
342
+ followButtonText: {
343
+ fontSize: 14,
344
+ fontWeight: '600',
345
+ color: color.surface
346
+ },
347
+ followingButtonText: {
348
+ fontSize: 14,
349
+ fontWeight: '600',
350
+ color: color.text
351
+ },
352
+ sectionTitle: {
353
+ fontSize: 16,
354
+ fontWeight: '700',
355
+ color: color.text
356
+ },
357
+ readOnlyBio: {
358
+ fontSize: 15,
359
+ lineHeight: 20,
360
+ color: color.text
361
+ },
362
+ formGroup: {
363
+ gap: spacing.sm
364
+ },
365
+ label: {
366
+ fontSize: 13,
367
+ fontWeight: '600',
368
+ color: color.text
369
+ },
370
+ textInput: {
371
+ borderRadius: radius.md,
372
+ borderWidth: 1,
373
+ borderColor: color.borderStrong,
374
+ paddingHorizontal: spacing.mdPlus,
375
+ paddingVertical: spacing.md,
376
+ fontSize: 15,
377
+ color: color.text,
378
+ backgroundColor: color.surfaceAlt
379
+ },
380
+ bioInput: {
381
+ minHeight: 120
382
+ },
383
+ successText: {
384
+ fontSize: 13,
385
+ color: '#1d7e1f',
386
+ fontWeight: '600'
387
+ },
388
+ errorText: {
389
+ fontSize: 13,
390
+ color: '#d22c2c',
391
+ fontWeight: '600'
392
+ },
393
+ postsLoading: {
394
+ marginTop: spacing.md
395
+ },
396
+ postCard: {
397
+ borderRadius: radius[postCardRecipe.radius],
398
+ borderWidth: postCardRecipe.borderColor ? 1 : 0,
399
+ borderColor: postCardRecipe.borderColor ? color[postCardRecipe.borderColor] : undefined,
400
+ padding: spacing[postCardRecipe.padding],
401
+ gap: spacing.sm,
402
+ backgroundColor: color[postCardRecipe.backgroundColor],
403
+ marginTop: spacing.md
404
+ },
405
+ postTimestamp: {
406
+ fontSize: 12,
407
+ color: color.textMuted
408
+ },
409
+ postBody: {
410
+ fontSize: 15,
411
+ color: color.text,
412
+ lineHeight: 20
413
+ },
414
+ errorCard: {
415
+ backgroundColor: '#fff4f4',
416
+ borderRadius: radius.lg,
417
+ padding: spacing.lg,
418
+ borderWidth: 1,
419
+ borderColor: '#f6d5d5',
420
+ gap: spacing.xs
421
+ },
422
+ errorTitle: {
423
+ fontSize: 15,
424
+ fontWeight: '700',
425
+ color: '#b11f1f'
426
+ },
427
+ errorBody: {
428
+ fontSize: 13,
429
+ color: '#7a2f2f',
430
+ lineHeight: 18
431
+ }
432
+ });
@@ -0,0 +1 @@
1
+ export * from './index.js';
package/dist/native.js ADDED
@@ -0,0 +1 @@
1
+ export * from './index.js';
package/package.json CHANGED
@@ -1,28 +1,34 @@
1
1
  {
2
2
  "name": "@mereb/app-profile",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Profile experience components and hooks for Mereb apps",
5
5
  "type": "module",
6
- "main": "src/index.tsx",
7
- "types": "src/index.tsx",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./src/index.tsx",
11
- "default": "./src/index.tsx"
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./native": {
15
+ "types": "./dist/native.d.ts",
16
+ "import": "./dist/native.js",
17
+ "default": "./dist/native.js"
12
18
  },
13
19
  "./headless": {
14
- "types": "./src/headless.ts",
15
- "default": "./src/headless.ts"
20
+ "types": "./dist/headless.d.ts",
21
+ "import": "./dist/headless.js",
22
+ "default": "./dist/headless.js"
16
23
  }
17
24
  },
18
25
  "files": [
19
- "src",
20
26
  "dist",
21
27
  "package.json"
22
28
  ],
23
29
  "dependencies": {
24
- "@mereb/shared-graphql": "^0.0.9",
25
- "@mereb/ui-shared": "^0.0.1",
30
+ "@mereb/shared-graphql": "^0.0.11",
31
+ "@mereb/ui-shared": "^0.0.3",
26
32
  "@mereb/tokens": "^0.0.4"
27
33
  },
28
34
  "peerDependencies": {
@@ -33,10 +39,16 @@
33
39
  },
34
40
  "devDependencies": {
35
41
  "@types/react": "~18.2.79",
36
- "typescript": ">=5.3.3"
42
+ "husky": "^9.1.7",
43
+ "typescript": ">=5.3.3",
44
+ "vitest": "^3.2.4"
37
45
  },
38
46
  "scripts": {
47
+ "clean": "node -e \"const fs=require('node:fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('tsconfig.tsbuildinfo',{force:true});\"",
39
48
  "typecheck": "tsc --noEmit --project tsconfig.json",
40
- "build": "tsc --project tsconfig.json"
49
+ "test": "pnpm exec vitest run --config vitest.config.ts",
50
+ "test:watch": "pnpm exec vitest --config vitest.config.ts",
51
+ "build": "pnpm run clean && tsc --project tsconfig.json",
52
+ "version:bump": "node ./scripts/bump-version.mjs"
41
53
  }
42
54
  }
package/src/headless.ts DELETED
@@ -1,66 +0,0 @@
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 DELETED
@@ -1,734 +0,0 @@
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
- });
File without changes