@mereb/app-feed 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 +16 -0
- package/package.json +1 -1
- package/src/headless.ts +31 -1
- package/src/index.tsx +241 -135
package/dist/src/headless.d.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import type { FeedQuery } from '@mereb/shared-graphql';
|
|
2
2
|
export { FeedDocument } from '@mereb/shared-graphql';
|
|
3
3
|
export type FeedNode = FeedQuery['feedHome']['edges'][number]['node'];
|
|
4
|
+
export type CreatePostResponse = {
|
|
5
|
+
createPost: {
|
|
6
|
+
id: string;
|
|
7
|
+
body: string;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
author: {
|
|
10
|
+
id: string;
|
|
11
|
+
handle: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type CreatePostVariables = {
|
|
16
|
+
body: string;
|
|
17
|
+
mediaKeys?: string[];
|
|
18
|
+
};
|
|
19
|
+
export declare const CreatePostDocument: import("@apollo/client").DocumentNode;
|
|
4
20
|
export declare const FEED_PAGE_SIZE = 20;
|
|
5
21
|
export declare function parseFeedTimestamp(value?: string | null): Date | null;
|
|
6
22
|
export declare function formatRelativeFeedTimestamp(value?: string | null, now?: number): string;
|
package/package.json
CHANGED
package/src/headless.ts
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
|
+
import { gql } from '@apollo/client';
|
|
1
2
|
import type { FeedQuery } from '@mereb/shared-graphql';
|
|
2
3
|
export { FeedDocument } from '@mereb/shared-graphql';
|
|
3
4
|
|
|
4
5
|
export type FeedNode = FeedQuery['feedHome']['edges'][number]['node'];
|
|
6
|
+
export type CreatePostResponse = {
|
|
7
|
+
createPost: {
|
|
8
|
+
id: string;
|
|
9
|
+
body: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
author: {
|
|
12
|
+
id: string;
|
|
13
|
+
handle: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export type CreatePostVariables = {
|
|
18
|
+
body: string;
|
|
19
|
+
mediaKeys?: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const CreatePostDocument = gql`
|
|
23
|
+
mutation CreatePost($body: String!, $mediaKeys: [String!]) {
|
|
24
|
+
createPost(body: $body, mediaKeys: $mediaKeys) {
|
|
25
|
+
id
|
|
26
|
+
body
|
|
27
|
+
createdAt
|
|
28
|
+
author {
|
|
29
|
+
id
|
|
30
|
+
handle
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
5
35
|
|
|
6
36
|
export const FEED_PAGE_SIZE = 20;
|
|
7
37
|
|
|
@@ -42,7 +72,7 @@ export function formatRelativeFeedTimestamp(value?: string | null, now: number =
|
|
|
42
72
|
}
|
|
43
73
|
|
|
44
74
|
return date.toLocaleDateString(undefined, {
|
|
45
|
-
year: date.getFullYear()
|
|
75
|
+
year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
|
|
46
76
|
month: 'short',
|
|
47
77
|
day: 'numeric'
|
|
48
78
|
});
|
package/src/index.tsx
CHANGED
|
@@ -1,155 +1,261 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
import {useCallback, useMemo} from 'react';
|
|
2
|
+
import {FlatList, RefreshControl, StyleSheet, Text, TouchableOpacity, View, type ViewStyle} from 'react-native';
|
|
3
|
+
import {useFeedQuery} from '@mereb/shared-graphql';
|
|
4
|
+
import {tokens} from '@mereb/tokens/native';
|
|
5
|
+
import {cardRecipes} from '@mereb/ui-shared';
|
|
6
|
+
import {type FeedNode, formatRelativeFeedTimestamp, selectFeedNodes} from './headless.js';
|
|
7
|
+
|
|
8
|
+
type SeparatorProps = {
|
|
9
|
+
style?: ViewStyle;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function Separator({style}: Readonly<SeparatorProps>) {
|
|
13
|
+
return <View style={style}/>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* Top-level wrapper so FlatList receives a stable component reference */
|
|
17
|
+
function SeparatorWithStyle() {
|
|
18
|
+
return <Separator style={styles.separator} />;
|
|
19
|
+
}
|
|
18
20
|
|
|
19
21
|
type PostCardProps = {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
post: FeedNode;
|
|
23
|
+
onSelectAuthor?: (handle: string) => void;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
|
-
function PostCard({
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
26
|
+
function PostCard({post, onSelectAuthor}: Readonly<PostCardProps>) {
|
|
27
|
+
const handlePress = useCallback(() => {
|
|
28
|
+
onSelectAuthor?.(post.author.handle);
|
|
29
|
+
}, [onSelectAuthor, post.author.handle]);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<View style={styles.card}>
|
|
33
|
+
<View style={styles.metaRow}>
|
|
34
|
+
{onSelectAuthor ? (
|
|
35
|
+
<TouchableOpacity onPress={handlePress} accessibilityRole="button">
|
|
36
|
+
<Text style={styles.handleLink}>@{post.author.handle}</Text>
|
|
37
|
+
</TouchableOpacity>
|
|
38
|
+
) : (
|
|
39
|
+
<Text style={styles.handle}>@{post.author.handle}</Text>
|
|
40
|
+
)}
|
|
41
|
+
<Text style={styles.metaSeparator}>•</Text>
|
|
42
|
+
<Text style={styles.timestamp}>{formatRelativeFeedTimestamp(post.createdAt)}</Text>
|
|
43
|
+
</View>
|
|
44
|
+
<Text style={styles.body}>{post.body}</Text>
|
|
45
|
+
</View>
|
|
46
|
+
);
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
type FeedScreenProps = {
|
|
48
|
-
|
|
50
|
+
onSelectAuthor?: (handle: string) => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type Suggestion = {
|
|
54
|
+
handle: string;
|
|
55
|
+
hint?: string;
|
|
49
56
|
};
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
const FALLBACK_SUGGESTIONS: Suggestion[] = [
|
|
59
|
+
{handle: 'mereb-team', hint: 'Product updates'},
|
|
60
|
+
{handle: 'designers', hint: 'Design drops'},
|
|
61
|
+
{handle: 'dev-journal', hint: 'Engineering notes'}
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function SuggestionsSection({
|
|
65
|
+
suggestions,
|
|
66
|
+
onSelectAuthor
|
|
67
|
+
}: Readonly<{
|
|
68
|
+
suggestions: Suggestion[];
|
|
69
|
+
onSelectAuthor?: (handle: string) => void;
|
|
70
|
+
}>) {
|
|
71
|
+
if (suggestions.length === 0) return null;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<View style={styles.suggestionsCard}>
|
|
75
|
+
<Text style={styles.suggestionsTitle}>Discover people to follow</Text>
|
|
76
|
+
<Text style={styles.suggestionsBody}>
|
|
77
|
+
Follow a few profiles to keep your feed fresh.
|
|
78
|
+
</Text>
|
|
79
|
+
<View style={styles.suggestionsList}>
|
|
80
|
+
{suggestions.map((sugg) => (
|
|
81
|
+
<TouchableOpacity
|
|
82
|
+
key={sugg.handle}
|
|
83
|
+
style={styles.suggestionPill}
|
|
84
|
+
onPress={() => onSelectAuthor?.(sugg.handle)}
|
|
85
|
+
accessibilityRole="button"
|
|
86
|
+
>
|
|
87
|
+
<Text style={styles.suggestionHandle}>@{sugg.handle}</Text>
|
|
88
|
+
{sugg.hint ? (
|
|
89
|
+
<Text style={styles.suggestionHint}>{sugg.hint}</Text>
|
|
90
|
+
) : null}
|
|
91
|
+
</TouchableOpacity>
|
|
92
|
+
))}
|
|
93
|
+
</View>
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function FeedScreen({onSelectAuthor}: Readonly<FeedScreenProps>) {
|
|
99
|
+
const {data, loading, error, refetch} = useFeedQuery();
|
|
53
100
|
|
|
54
|
-
|
|
101
|
+
const posts = useMemo(() => selectFeedNodes(data), [data]);
|
|
102
|
+
const suggestions = useMemo(() => {
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const derived: Suggestion[] = [];
|
|
105
|
+
for (const post of posts) {
|
|
106
|
+
if (seen.has(post.author.handle)) continue;
|
|
107
|
+
seen.add(post.author.handle);
|
|
108
|
+
derived.push({handle: post.author.handle});
|
|
109
|
+
}
|
|
110
|
+
const combined = [...derived, ...FALLBACK_SUGGESTIONS].filter(
|
|
111
|
+
(s) => !seen.has(s.handle) // avoid dupes with fallbacks
|
|
112
|
+
);
|
|
113
|
+
return combined.slice(0, 5);
|
|
114
|
+
}, [posts]);
|
|
115
|
+
const shouldShowSuggestions = !loading && posts.length < 5;
|
|
55
116
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
117
|
+
const handleRefresh = useCallback(() => {
|
|
118
|
+
void refetch();
|
|
119
|
+
}, [refetch]);
|
|
120
|
+
|
|
121
|
+
if (error) {
|
|
122
|
+
return (
|
|
123
|
+
<View style={styles.centered}>
|
|
124
|
+
<Text style={styles.errorText}>Failed to load feed</Text>
|
|
125
|
+
</View>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
59
128
|
|
|
60
|
-
if (error) {
|
|
61
129
|
return (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
130
|
+
<FlatList
|
|
131
|
+
data={posts}
|
|
132
|
+
keyExtractor={(item) => item.id}
|
|
133
|
+
contentContainerStyle={styles.listContent}
|
|
134
|
+
ItemSeparatorComponent={SeparatorWithStyle}
|
|
135
|
+
refreshControl={<RefreshControl refreshing={loading} onRefresh={handleRefresh}/>}
|
|
136
|
+
ListHeaderComponent={
|
|
137
|
+
shouldShowSuggestions ? (
|
|
138
|
+
<SuggestionsSection
|
|
139
|
+
suggestions={suggestions}
|
|
140
|
+
onSelectAuthor={onSelectAuthor}
|
|
141
|
+
/>
|
|
142
|
+
) : null
|
|
143
|
+
}
|
|
144
|
+
renderItem={({item}) => (
|
|
145
|
+
<PostCard post={item} onSelectAuthor={onSelectAuthor}/>
|
|
146
|
+
)}
|
|
147
|
+
ListEmptyComponent={
|
|
148
|
+
loading ? null : (
|
|
149
|
+
<View style={styles.centered}>
|
|
150
|
+
<Text style={styles.emptyTitle}>No posts yet</Text>
|
|
151
|
+
<Text style={styles.emptyBody}>
|
|
152
|
+
Pull down to refresh or check back later for new updates.
|
|
153
|
+
</Text>
|
|
154
|
+
</View>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
65
158
|
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return (
|
|
69
|
-
<FlatList
|
|
70
|
-
data={posts}
|
|
71
|
-
keyExtractor={(item) => item.id}
|
|
72
|
-
contentContainerStyle={styles.listContent}
|
|
73
|
-
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
|
74
|
-
refreshControl={<RefreshControl refreshing={loading} onRefresh={handleRefresh} />}
|
|
75
|
-
renderItem={({ item }) => (
|
|
76
|
-
<PostCard post={item} onSelectAuthor={onSelectAuthor} />
|
|
77
|
-
)}
|
|
78
|
-
ListEmptyComponent={
|
|
79
|
-
!loading ? (
|
|
80
|
-
<View style={styles.centered}>
|
|
81
|
-
<Text style={styles.emptyTitle}>No posts yet</Text>
|
|
82
|
-
<Text style={styles.emptyBody}>
|
|
83
|
-
Pull down to refresh or check back later for new updates.
|
|
84
|
-
</Text>
|
|
85
|
-
</View>
|
|
86
|
-
) : null
|
|
87
|
-
}
|
|
88
|
-
/>
|
|
89
|
-
);
|
|
90
159
|
}
|
|
91
160
|
|
|
92
|
-
const {
|
|
161
|
+
const {color, spacing, radius, shadow} = tokens;
|
|
93
162
|
const feedCardRecipe = cardRecipes.elevated;
|
|
94
163
|
|
|
95
164
|
const styles = StyleSheet.create({
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
165
|
+
listContent: {
|
|
166
|
+
padding: spacing.lg,
|
|
167
|
+
gap: spacing.md
|
|
168
|
+
},
|
|
169
|
+
separator: {
|
|
170
|
+
height: 0
|
|
171
|
+
},
|
|
172
|
+
card: {
|
|
173
|
+
backgroundColor: color[feedCardRecipe.backgroundColor],
|
|
174
|
+
borderRadius: radius[feedCardRecipe.radius],
|
|
175
|
+
padding: spacing[feedCardRecipe.padding],
|
|
176
|
+
...shadow[feedCardRecipe.elevation]
|
|
177
|
+
},
|
|
178
|
+
metaRow: {
|
|
179
|
+
flexDirection: 'row',
|
|
180
|
+
alignItems: 'center',
|
|
181
|
+
marginBottom: spacing.sm
|
|
182
|
+
},
|
|
183
|
+
handle: {
|
|
184
|
+
fontWeight: '600',
|
|
185
|
+
color: color.text
|
|
186
|
+
},
|
|
187
|
+
handleLink: {
|
|
188
|
+
fontWeight: '600',
|
|
189
|
+
color: color.primary
|
|
190
|
+
},
|
|
191
|
+
metaSeparator: {
|
|
192
|
+
marginHorizontal: spacing.xs,
|
|
193
|
+
color: color.textMuted
|
|
194
|
+
},
|
|
195
|
+
timestamp: {
|
|
196
|
+
color: color.textMuted
|
|
197
|
+
},
|
|
198
|
+
body: {
|
|
199
|
+
fontSize: 16,
|
|
200
|
+
lineHeight: 22,
|
|
201
|
+
color: color.text
|
|
202
|
+
},
|
|
203
|
+
centered: {
|
|
204
|
+
flex: 1,
|
|
205
|
+
padding: spacing.xxxl,
|
|
206
|
+
alignItems: 'center',
|
|
207
|
+
justifyContent: 'center'
|
|
208
|
+
},
|
|
209
|
+
errorText: {
|
|
210
|
+
fontSize: 16,
|
|
211
|
+
color: '#d22c2c'
|
|
212
|
+
},
|
|
213
|
+
emptyTitle: {
|
|
214
|
+
fontSize: 18,
|
|
215
|
+
fontWeight: '600',
|
|
216
|
+
color: color.text,
|
|
217
|
+
marginBottom: spacing.sm
|
|
218
|
+
},
|
|
219
|
+
emptyBody: {
|
|
220
|
+
fontSize: 14,
|
|
221
|
+
color: color.textMuted,
|
|
222
|
+
textAlign: 'center'
|
|
223
|
+
},
|
|
224
|
+
suggestionsCard: {
|
|
225
|
+
backgroundColor: color.surface,
|
|
226
|
+
borderRadius: radius.lg,
|
|
227
|
+
padding: spacing.lg,
|
|
228
|
+
marginBottom: spacing.lg
|
|
229
|
+
},
|
|
230
|
+
suggestionsTitle: {
|
|
231
|
+
fontSize: 18,
|
|
232
|
+
fontWeight: '700',
|
|
233
|
+
color: color.text,
|
|
234
|
+
marginBottom: spacing.xs
|
|
235
|
+
},
|
|
236
|
+
suggestionsBody: {
|
|
237
|
+
color: color.textMuted,
|
|
238
|
+
marginBottom: spacing.md
|
|
239
|
+
},
|
|
240
|
+
suggestionsList: {
|
|
241
|
+
flexDirection: 'row',
|
|
242
|
+
flexWrap: 'wrap',
|
|
243
|
+
gap: spacing.sm
|
|
244
|
+
},
|
|
245
|
+
suggestionPill: {
|
|
246
|
+
paddingVertical: spacing.xs,
|
|
247
|
+
paddingHorizontal: spacing.md,
|
|
248
|
+
borderRadius: radius.pill ?? radius.lg,
|
|
249
|
+
backgroundColor: color.neutralMuted ?? color.surfaceMuted,
|
|
250
|
+
flexDirection: 'column',
|
|
251
|
+
gap: spacing.xxs
|
|
252
|
+
},
|
|
253
|
+
suggestionHandle: {
|
|
254
|
+
color: color.text,
|
|
255
|
+
fontWeight: '600'
|
|
256
|
+
},
|
|
257
|
+
suggestionHint: {
|
|
258
|
+
color: color.textMuted,
|
|
259
|
+
fontSize: 12
|
|
260
|
+
}
|
|
155
261
|
});
|