@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.
- package/dist/src/headless.d.ts +7 -0
- package/dist/src/index.d.ts +5 -0
- package/package.json +42 -0
- package/src/headless.ts +56 -0
- package/src/index.tsx +155 -0
|
@@ -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[];
|
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
|
+
}
|
package/src/headless.ts
ADDED
|
@@ -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
|
+
});
|