@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mereb/app-feed",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Feed experience building blocks for Mereb apps",
5
5
  "type": "module",
6
6
  "main": "src/index.tsx",
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() !== new Date().getFullYear() ? 'numeric' : undefined,
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 { useCallback, useMemo } from 'react';
2
- import {
3
- FlatList,
4
- RefreshControl,
5
- StyleSheet,
6
- Text,
7
- TouchableOpacity,
8
- View
9
- } from 'react-native';
10
- import { useFeedQuery } from '@mereb/shared-graphql';
11
- import { tokens } from '@mereb/tokens/native';
12
- import { cardRecipes } from '@mereb/ui-shared';
13
- import {
14
- formatRelativeFeedTimestamp,
15
- selectFeedNodes,
16
- type FeedNode
17
- } from './headless.js';
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
- post: FeedNode;
21
- onSelectAuthor?: (handle: string) => void;
22
+ post: FeedNode;
23
+ onSelectAuthor?: (handle: string) => void;
22
24
  };
23
25
 
24
- function PostCard({ post, onSelectAuthor }: Readonly<PostCardProps>) {
25
- const handlePress = useCallback(() => {
26
- onSelectAuthor?.(post.author.handle);
27
- }, [onSelectAuthor, post.author.handle]);
28
-
29
- return (
30
- <View style={styles.card}>
31
- <View style={styles.metaRow}>
32
- {onSelectAuthor ? (
33
- <TouchableOpacity onPress={handlePress} accessibilityRole="button">
34
- <Text style={styles.handleLink}>@{post.author.handle}</Text>
35
- </TouchableOpacity>
36
- ) : (
37
- <Text style={styles.handle}>@{post.author.handle}</Text>
38
- )}
39
- <Text style={styles.metaSeparator}>•</Text>
40
- <Text style={styles.timestamp}>{formatRelativeFeedTimestamp(post.createdAt)}</Text>
41
- </View>
42
- <Text style={styles.body}>{post.body}</Text>
43
- </View>
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
- onSelectAuthor?: (handle: string) => void;
50
+ onSelectAuthor?: (handle: string) => void;
51
+ };
52
+
53
+ type Suggestion = {
54
+ handle: string;
55
+ hint?: string;
49
56
  };
50
57
 
51
- export function FeedScreen({ onSelectAuthor }: Readonly<FeedScreenProps>) {
52
- const { data, loading, error, refetch } = useFeedQuery();
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
- const posts = useMemo(() => selectFeedNodes(data), [data]);
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
- const handleRefresh = useCallback(() => {
57
- void refetch();
58
- }, [refetch]);
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
- <View style={styles.centered}>
63
- <Text style={styles.errorText}>Failed to load feed</Text>
64
- </View>
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 { color, spacing, radius, shadow } = tokens;
161
+ const {color, spacing, radius, shadow} = tokens;
93
162
  const feedCardRecipe = cardRecipes.elevated;
94
163
 
95
164
  const styles = StyleSheet.create({
96
- listContent: {
97
- padding: spacing.lg,
98
- gap: spacing.md
99
- },
100
- separator: {
101
- height: 0
102
- },
103
- card: {
104
- backgroundColor: color[feedCardRecipe.backgroundColor],
105
- borderRadius: radius[feedCardRecipe.radius],
106
- padding: spacing[feedCardRecipe.padding],
107
- ...shadow[feedCardRecipe.elevation]
108
- },
109
- metaRow: {
110
- flexDirection: 'row',
111
- alignItems: 'center',
112
- marginBottom: spacing.sm
113
- },
114
- handle: {
115
- fontWeight: '600',
116
- color: color.text
117
- },
118
- handleLink: {
119
- fontWeight: '600',
120
- color: color.primary
121
- },
122
- metaSeparator: {
123
- marginHorizontal: spacing.xs,
124
- color: color.textMuted
125
- },
126
- timestamp: {
127
- color: color.textMuted
128
- },
129
- body: {
130
- fontSize: 16,
131
- lineHeight: 22,
132
- color: color.text
133
- },
134
- centered: {
135
- flex: 1,
136
- padding: spacing.xxxl,
137
- alignItems: 'center',
138
- justifyContent: 'center'
139
- },
140
- errorText: {
141
- fontSize: 16,
142
- color: '#d22c2c'
143
- },
144
- emptyTitle: {
145
- fontSize: 18,
146
- fontWeight: '600',
147
- color: color.text,
148
- marginBottom: spacing.sm
149
- },
150
- emptyBody: {
151
- fontSize: 14,
152
- color: color.textMuted,
153
- textAlign: 'center'
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
  });