@mereb/app-profile 0.0.2 → 0.0.4
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 → headless.d.ts} +1 -1
- package/dist/headless.js +45 -0
- package/dist/{src/index.d.ts → index.d.ts} +1 -1
- package/dist/index.js +432 -0
- package/dist/native.d.ts +1 -0
- package/dist/native.js +1 -0
- package/package.json +24 -12
- package/src/headless.ts +0 -66
- package/src/index.tsx +0 -734
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MeProfileQuery, ProfileByHandleQuery } from '@mereb/shared-graphql';
|
|
2
|
-
export { MeProfileDocument, ProfileByHandleDocument, FollowUserDocument, UnfollowUserDocument, UpdateProfileDocument } from '@mereb/shared-graphql';
|
|
2
|
+
export { MeProfileDocument, ProfileByHandleDocument, FollowUserDocument, UnfollowUserDocument, UpdateProfileDocument, RequestMediaUploadDocument, CompleteMediaUploadDocument } from '@mereb/shared-graphql';
|
|
3
3
|
export type ViewerProfile = MeProfileQuery['me'];
|
|
4
4
|
export type ProfileData = ProfileByHandleQuery['userByHandle'];
|
|
5
5
|
export type ProfilePost = NonNullable<ProfileData>['posts']['edges'][number]['node'];
|
package/dist/headless.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export { MeProfileDocument, ProfileByHandleDocument, FollowUserDocument, UnfollowUserDocument, UpdateProfileDocument, RequestMediaUploadDocument, CompleteMediaUploadDocument } 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
|
+
});
|
package/dist/native.d.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Profile experience components and hooks for Mereb apps",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
-
"types": "./
|
|
11
|
-
"
|
|
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": "./
|
|
15
|
-
"
|
|
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.
|
|
25
|
-
"@mereb/ui-shared": "^0.0.
|
|
30
|
+
"@mereb/shared-graphql": "^0.0.12",
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
});
|