@mereb/app-feed 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ import type { FeedQuery } from '@mereb/shared-graphql';
2
+ export { FeedDocument } from '@mereb/shared-graphql';
3
+ export type FeedNode = FeedQuery['feedHome']['edges'][number]['node'];
4
+ export declare const FEED_PAGE_SIZE = 20;
5
+ export declare function parseFeedTimestamp(value?: string | null): Date | null;
6
+ export declare function formatRelativeFeedTimestamp(value?: string | null, now?: number): string;
7
+ export declare function selectFeedNodes(data?: FeedQuery | null): FeedNode[];
@@ -0,0 +1,5 @@
1
+ type FeedScreenProps = {
2
+ onSelectAuthor?: (handle: string) => void;
3
+ };
4
+ export declare function FeedScreen({ onSelectAuthor }: Readonly<FeedScreenProps>): import("react").JSX.Element;
5
+ export {};
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@mereb/app-feed",
3
+ "version": "0.0.1",
4
+ "description": "Feed experience building blocks for Mereb apps",
5
+ "type": "module",
6
+ "main": "src/index.tsx",
7
+ "types": "src/index.tsx",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.tsx",
11
+ "default": "./src/index.tsx"
12
+ },
13
+ "./headless": {
14
+ "types": "./src/headless.ts",
15
+ "default": "./src/headless.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "src",
20
+ "dist",
21
+ "package.json"
22
+ ],
23
+ "dependencies": {
24
+ "@mereb/shared-graphql": "^0.0.6",
25
+ "@mereb/ui-shared": "^0.0.1",
26
+ "@mereb/tokens": "^0.0.4"
27
+ },
28
+ "peerDependencies": {
29
+ "@apollo/client": ">=3.8.0",
30
+ "expo-router": ">=3.0.0",
31
+ "react": ">=18.2.0",
32
+ "react-native": ">=0.72.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "~18.2.79",
36
+ "typescript": ">=5.3.3"
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit --project tsconfig.json",
40
+ "build": "tsc --project tsconfig.json"
41
+ }
42
+ }
@@ -0,0 +1,56 @@
1
+ import type { FeedQuery } from '@mereb/shared-graphql';
2
+ export { FeedDocument } from '@mereb/shared-graphql';
3
+
4
+ export type FeedNode = FeedQuery['feedHome']['edges'][number]['node'];
5
+
6
+ export const FEED_PAGE_SIZE = 20;
7
+
8
+ const MINUTE = 60 * 1000;
9
+ const HOUR = 60 * MINUTE;
10
+ const DAY = 24 * HOUR;
11
+
12
+ export function parseFeedTimestamp(value?: string | null): Date | null {
13
+ if (!value) {
14
+ return null;
15
+ }
16
+ const asNumber = Number(value);
17
+ if (!Number.isNaN(asNumber) && asNumber > 0) {
18
+ const date = new Date(asNumber);
19
+ return Number.isNaN(date.getTime()) ? null : date;
20
+ }
21
+ const date = new Date(value);
22
+ return Number.isNaN(date.getTime()) ? null : date;
23
+ }
24
+
25
+ export function formatRelativeFeedTimestamp(value?: string | null, now: number = Date.now()): string {
26
+ const date = parseFeedTimestamp(value);
27
+ if (!date) {
28
+ return 'Just now';
29
+ }
30
+
31
+ const diff = now - date.getTime();
32
+ if (diff < MINUTE) {
33
+ return 'Just now';
34
+ }
35
+ if (diff < HOUR) {
36
+ const mins = Math.round(diff / MINUTE);
37
+ return `${mins} min${mins === 1 ? '' : 's'} ago`;
38
+ }
39
+ if (diff < DAY) {
40
+ const hours = Math.round(diff / HOUR);
41
+ return `${hours} hr${hours === 1 ? '' : 's'} ago`;
42
+ }
43
+
44
+ return date.toLocaleDateString(undefined, {
45
+ year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined,
46
+ month: 'short',
47
+ day: 'numeric'
48
+ });
49
+ }
50
+
51
+ export function selectFeedNodes(data?: FeedQuery | null): FeedNode[] {
52
+ if (!data?.feedHome?.edges?.length) {
53
+ return [];
54
+ }
55
+ return data.feedHome.edges.map((edge) => edge.node);
56
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,155 @@
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';
18
+
19
+ type PostCardProps = {
20
+ post: FeedNode;
21
+ onSelectAuthor?: (handle: string) => void;
22
+ };
23
+
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
+ );
45
+ }
46
+
47
+ type FeedScreenProps = {
48
+ onSelectAuthor?: (handle: string) => void;
49
+ };
50
+
51
+ export function FeedScreen({ onSelectAuthor }: Readonly<FeedScreenProps>) {
52
+ const { data, loading, error, refetch } = useFeedQuery();
53
+
54
+ const posts = useMemo(() => selectFeedNodes(data), [data]);
55
+
56
+ const handleRefresh = useCallback(() => {
57
+ void refetch();
58
+ }, [refetch]);
59
+
60
+ if (error) {
61
+ return (
62
+ <View style={styles.centered}>
63
+ <Text style={styles.errorText}>Failed to load feed</Text>
64
+ </View>
65
+ );
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
+ }
91
+
92
+ const { color, spacing, radius, shadow } = tokens;
93
+ const feedCardRecipe = cardRecipes.elevated;
94
+
95
+ 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
+ }
155
+ });