This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

format files

+96 -152
+1 -7
src/modules/feeds/infrastructure/repositories/schema/feedActivity.sql.ts
··· 1 - import { 2 - pgTable, 3 - text, 4 - timestamp, 5 - jsonb, 6 - uuid, 7 - } from 'drizzle-orm/pg-core'; 1 + import { pgTable, text, timestamp, jsonb, uuid } from 'drizzle-orm/pg-core'; 8 2 9 3 export const feedActivities = pgTable('feed_activities', { 10 4 id: uuid('id').primaryKey(),
+5 -10
src/modules/feeds/tests/infrastructure/DrizzleFeedRepository.integration.test.ts
··· 62 62 63 63 it('should add and retrieve a card collected activity', async () => { 64 64 // Create a card collected activity 65 - const activityResult = FeedActivity.createCardCollected( 66 - curatorId, 67 - cardId, 68 - [collectionId], 69 - ); 65 + const activityResult = FeedActivity.createCardCollected(curatorId, cardId, [ 66 + collectionId, 67 + ]); 70 68 71 69 expect(activityResult.isOk()).toBe(true); 72 70 const activity = activityResult.unwrap(); ··· 94 92 95 93 it('should add a card collected activity without collections', async () => { 96 94 // Create a card collected activity without collections 97 - const activityResult = FeedActivity.createCardCollected( 98 - curatorId, 99 - cardId, 100 - ); 95 + const activityResult = FeedActivity.createCardCollected(curatorId, cardId); 101 96 102 97 expect(activityResult.isOk()).toBe(true); 103 98 const activity = activityResult.unwrap(); ··· 278 273 const feed = feedResult.unwrap(); 279 274 expect(feed.activities).toHaveLength(2); 280 275 281 - const actorIds = feed.activities.map(a => a.actorId.value); 276 + const actorIds = feed.activities.map((a) => a.actorId.value); 282 277 expect(actorIds).toContain(curatorId.value); 283 278 expect(actorIds).toContain(anotherCuratorId.value); 284 279 });
+15 -9
src/modules/feeds/tests/infrastructure/InMemoryFeedRepository.ts
··· 14 14 try { 15 15 this.activities.push(activity); 16 16 // Sort by creation time descending 17 - this.activities.sort((a, b) => 18 - b.createdAt.getTime() - a.createdAt.getTime() 17 + this.activities.sort( 18 + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), 19 19 ); 20 20 return ok(undefined); 21 21 } catch (error) { ··· 32 32 33 33 // Filter by cursor if provided 34 34 if (beforeActivityId) { 35 - const beforeIndex = filteredActivities.findIndex( 36 - (activity) => activity.activityId.equals(beforeActivityId) 35 + const beforeIndex = filteredActivities.findIndex((activity) => 36 + activity.activityId.equals(beforeActivityId), 37 37 ); 38 38 if (beforeIndex >= 0) { 39 39 filteredActivities = filteredActivities.slice(beforeIndex + 1); ··· 42 42 43 43 // Paginate 44 44 const offset = (page - 1) * limit; 45 - const paginatedActivities = filteredActivities.slice(offset, offset + limit); 46 - 45 + const paginatedActivities = filteredActivities.slice( 46 + offset, 47 + offset + limit, 48 + ); 49 + 47 50 const totalCount = this.activities.length; 48 51 const hasMore = offset + paginatedActivities.length < totalCount; 49 - 52 + 50 53 let nextCursor: ActivityId | undefined; 51 54 if (hasMore && paginatedActivities.length > 0) { 52 - nextCursor = paginatedActivities[paginatedActivities.length - 1]!.activityId; 55 + nextCursor = 56 + paginatedActivities[paginatedActivities.length - 1]!.activityId; 53 57 } 54 58 55 59 return ok({ ··· 65 69 66 70 async findById(activityId: ActivityId): Promise<Result<FeedActivity | null>> { 67 71 try { 68 - const activity = this.activities.find((a) => a.activityId.equals(activityId)); 72 + const activity = this.activities.find((a) => 73 + a.activityId.equals(activityId), 74 + ); 69 75 return ok(activity || null); 70 76 } catch (error) { 71 77 return err(error as Error);
+22 -65
src/shared/infrastructure/database/migrations/meta/0002_snapshot.json
··· 117 117 "name": "cards_parent_card_id_cards_id_fk", 118 118 "tableFrom": "cards", 119 119 "tableTo": "cards", 120 - "columnsFrom": [ 121 - "parent_card_id" 122 - ], 123 - "columnsTo": [ 124 - "id" 125 - ], 120 + "columnsFrom": ["parent_card_id"], 121 + "columnsTo": ["id"], 126 122 "onDelete": "no action", 127 123 "onUpdate": "no action" 128 124 }, ··· 130 126 "name": "cards_original_published_record_id_published_records_id_fk", 131 127 "tableFrom": "cards", 132 128 "tableTo": "published_records", 133 - "columnsFrom": [ 134 - "original_published_record_id" 135 - ], 136 - "columnsTo": [ 137 - "id" 138 - ], 129 + "columnsFrom": ["original_published_record_id"], 130 + "columnsTo": ["id"], 139 131 "onDelete": "no action", 140 132 "onUpdate": "no action" 141 133 } ··· 194 186 "name": "collection_cards_collection_id_collections_id_fk", 195 187 "tableFrom": "collection_cards", 196 188 "tableTo": "collections", 197 - "columnsFrom": [ 198 - "collection_id" 199 - ], 200 - "columnsTo": [ 201 - "id" 202 - ], 189 + "columnsFrom": ["collection_id"], 190 + "columnsTo": ["id"], 203 191 "onDelete": "cascade", 204 192 "onUpdate": "no action" 205 193 }, ··· 207 195 "name": "collection_cards_card_id_cards_id_fk", 208 196 "tableFrom": "collection_cards", 209 197 "tableTo": "cards", 210 - "columnsFrom": [ 211 - "card_id" 212 - ], 213 - "columnsTo": [ 214 - "id" 215 - ], 198 + "columnsFrom": ["card_id"], 199 + "columnsTo": ["id"], 216 200 "onDelete": "cascade", 217 201 "onUpdate": "no action" 218 202 }, ··· 220 204 "name": "collection_cards_published_record_id_published_records_id_fk", 221 205 "tableFrom": "collection_cards", 222 206 "tableTo": "published_records", 223 - "columnsFrom": [ 224 - "published_record_id" 225 - ], 226 - "columnsTo": [ 227 - "id" 228 - ], 207 + "columnsFrom": ["published_record_id"], 208 + "columnsTo": ["id"], 229 209 "onDelete": "no action", 230 210 "onUpdate": "no action" 231 211 } ··· 265 245 "name": "collection_collaborators_collection_id_collections_id_fk", 266 246 "tableFrom": "collection_collaborators", 267 247 "tableTo": "collections", 268 - "columnsFrom": [ 269 - "collection_id" 270 - ], 271 - "columnsTo": [ 272 - "id" 273 - ], 248 + "columnsFrom": ["collection_id"], 249 + "columnsTo": ["id"], 274 250 "onDelete": "cascade", 275 251 "onUpdate": "no action" 276 252 } ··· 349 325 "name": "collections_published_record_id_published_records_id_fk", 350 326 "tableFrom": "collections", 351 327 "tableTo": "published_records", 352 - "columnsFrom": [ 353 - "published_record_id" 354 - ], 355 - "columnsTo": [ 356 - "id" 357 - ], 328 + "columnsFrom": ["published_record_id"], 329 + "columnsTo": ["id"], 358 330 "onDelete": "no action", 359 331 "onUpdate": "no action" 360 332 } ··· 432 404 "name": "library_memberships_card_id_cards_id_fk", 433 405 "tableFrom": "library_memberships", 434 406 "tableTo": "cards", 435 - "columnsFrom": [ 436 - "card_id" 437 - ], 438 - "columnsTo": [ 439 - "id" 440 - ], 407 + "columnsFrom": ["card_id"], 408 + "columnsTo": ["id"], 441 409 "onDelete": "cascade", 442 410 "onUpdate": "no action" 443 411 }, ··· 445 413 "name": "library_memberships_published_record_id_published_records_id_fk", 446 414 "tableFrom": "library_memberships", 447 415 "tableTo": "published_records", 448 - "columnsFrom": [ 449 - "published_record_id" 450 - ], 451 - "columnsTo": [ 452 - "id" 453 - ], 416 + "columnsFrom": ["published_record_id"], 417 + "columnsTo": ["id"], 454 418 "onDelete": "no action", 455 419 "onUpdate": "no action" 456 420 } ··· 458 422 "compositePrimaryKeys": { 459 423 "library_memberships_card_id_user_id_pk": { 460 424 "name": "library_memberships_card_id_user_id_pk", 461 - "columns": [ 462 - "card_id", 463 - "user_id" 464 - ] 425 + "columns": ["card_id", "user_id"] 465 426 } 466 427 }, 467 428 "uniqueConstraints": {}, ··· 678 639 "name": "auth_refresh_tokens_user_did_users_id_fk", 679 640 "tableFrom": "auth_refresh_tokens", 680 641 "tableTo": "users", 681 - "columnsFrom": [ 682 - "user_did" 683 - ], 684 - "columnsTo": [ 685 - "id" 686 - ], 642 + "columnsFrom": ["user_did"], 643 + "columnsTo": ["id"], 687 644 "onDelete": "no action", 688 645 "onUpdate": "no action" 689 646 } ··· 743 700 "schemas": {}, 744 701 "tables": {} 745 702 } 746 - } 703 + }
+1 -1
src/shared/infrastructure/database/migrations/meta/_journal.json
··· 24 24 "breakpoints": true 25 25 } 26 26 ] 27 - } 27 + }
+1 -1
src/shared/infrastructure/http/factories/ServiceFactory.ts
··· 192 192 // Create saga for worker 193 193 const cardCollectionSaga = new CardCollectionSaga( 194 194 // We'll need to create this use case in the worker context 195 - null as any // Will be set properly in worker 195 + null as any, // Will be set properly in worker 196 196 ); 197 197 198 198 return {
+3 -5
src/webapp/api-client/clients/FeedClient.ts
··· 1 1 import { BaseClient } from './BaseClient'; 2 - import { 3 - GetGlobalFeedParams, 4 - GetGlobalFeedResponse, 5 - } from '../types'; 2 + import { GetGlobalFeedParams, GetGlobalFeedResponse } from '../types'; 6 3 7 4 export class FeedClient extends BaseClient { 8 5 async getGlobalFeed( ··· 11 8 const searchParams = new URLSearchParams(); 12 9 if (params?.page) searchParams.set('page', params.page.toString()); 13 10 if (params?.limit) searchParams.set('limit', params.limit.toString()); 14 - if (params?.beforeActivityId) searchParams.set('beforeActivityId', params.beforeActivityId); 11 + if (params?.beforeActivityId) 12 + searchParams.set('beforeActivityId', params.beforeActivityId); 15 13 16 14 const queryString = searchParams.toString(); 17 15 const endpoint = queryString
+36 -29
src/webapp/app/(authenticated)/explore/page.tsx
··· 16 16 } from '@mantine/core'; 17 17 18 18 export default function ExplorePage() { 19 - const [feedItems, setFeedItems] = useState<GetGlobalFeedResponse['activities']>([]); 19 + const [feedItems, setFeedItems] = useState< 20 + GetGlobalFeedResponse['activities'] 21 + >([]); 20 22 const [loading, setLoading] = useState(true); 21 23 const [loadingMore, setLoadingMore] = useState(false); 22 24 const [hasMore, setHasMore] = useState(true); ··· 33 35 ); 34 36 35 37 // Fetch initial feed data 36 - const fetchFeed = useCallback(async (reset = false) => { 37 - try { 38 - if (reset) { 39 - setLoading(true); 40 - setError(null); 41 - } else { 42 - setLoadingMore(true); 43 - } 38 + const fetchFeed = useCallback( 39 + async (reset = false) => { 40 + try { 41 + if (reset) { 42 + setLoading(true); 43 + setError(null); 44 + } else { 45 + setLoadingMore(true); 46 + } 47 + 48 + const response = await apiClient.getGlobalFeed({ 49 + limit: 20, 50 + beforeActivityId: reset 51 + ? undefined 52 + : feedItems[feedItems.length - 1]?.id, 53 + }); 44 54 45 - const response = await apiClient.getGlobalFeed({ 46 - limit: 20, 47 - beforeActivityId: reset ? undefined : feedItems[feedItems.length - 1]?.id, 48 - }); 55 + if (reset) { 56 + setFeedItems(response.activities); 57 + } else { 58 + setFeedItems((prev) => [...prev, ...response.activities]); 59 + } 49 60 50 - if (reset) { 51 - setFeedItems(response.activities); 52 - } else { 53 - setFeedItems(prev => [...prev, ...response.activities]); 61 + setHasMore(response.pagination.hasMore); 62 + } catch (error: any) { 63 + console.error('Error fetching feed:', error); 64 + setError(error.message || 'Failed to load feed'); 65 + } finally { 66 + setLoading(false); 67 + setLoadingMore(false); 54 68 } 55 - 56 - setHasMore(response.pagination.hasMore); 57 - } catch (error: any) { 58 - console.error('Error fetching feed:', error); 59 - setError(error.message || 'Failed to load feed'); 60 - } finally { 61 - setLoading(false); 62 - setLoadingMore(false); 63 - } 64 - }, [apiClient, feedItems]); 69 + }, 70 + [apiClient, feedItems], 71 + ); 65 72 66 73 useEffect(() => { 67 74 fetchFeed(true); ··· 101 108 return ( 102 109 <Stack> 103 110 <Title order={2}>Explore</Title> 104 - 111 + 105 112 {feedItems.length === 0 ? ( 106 113 <Center h={200}> 107 114 <Text c="dimmed">No activity to show yet</Text> ··· 111 118 {feedItems.map((item) => ( 112 119 <FeedItem key={item.id} item={item} /> 113 120 ))} 114 - 121 + 115 122 {hasMore && ( 116 123 <Center> 117 124 <Button
+4 -11
src/webapp/app/(authenticated)/profile/[handle]/page.tsx
··· 1 1 'use client'; 2 2 3 3 import { useParams } from 'next/navigation'; 4 - import { 5 - Stack, 6 - Title, 7 - Text, 8 - Card, 9 - Center, 10 - } from '@mantine/core'; 4 + import { Stack, Title, Text, Card, Center } from '@mantine/core'; 11 5 12 6 export default function ProfilePage() { 13 7 const params = useParams(); ··· 18 12 <Card withBorder p="xl"> 19 13 <Stack align="center"> 20 14 <Title order={2}>Profile Page</Title> 21 - <Text c="dimmed"> 22 - Profile for @{handle} - Coming Soon! 23 - </Text> 15 + <Text c="dimmed">Profile for @{handle} - Coming Soon!</Text> 24 16 <Text size="sm" c="dimmed"> 25 - This page will show user profile information and their public activity. 17 + This page will show user profile information and their public 18 + activity. 26 19 </Text> 27 20 </Stack> 28 21 </Card>
+8 -14
src/webapp/components/FeedItem.tsx
··· 3 3 import { useRouter } from 'next/navigation'; 4 4 import { UrlCard } from './UrlCard'; 5 5 import type { FeedItem as FeedItemType } from '@/api-client/types'; 6 - import { 7 - Stack, 8 - Text, 9 - Group, 10 - Anchor, 11 - Box, 12 - } from '@mantine/core'; 6 + import { Stack, Text, Group, Anchor, Box } from '@mantine/core'; 13 7 14 8 interface FeedItemProps { 15 9 item: FeedItemType; ··· 28 22 29 23 const renderActivityText = () => { 30 24 const { user, collections } = item; 31 - 25 + 32 26 if (collections.length === 0) { 33 27 return ( 34 28 <Text size="sm" c="dimmed"> ··· 38 32 c="blue" 39 33 > 40 34 @{user.handle} 41 - </Anchor> 42 - {' '}added to library 35 + </Anchor>{' '} 36 + added to library 43 37 </Text> 44 38 ); 45 39 } ··· 53 47 c="blue" 54 48 > 55 49 @{user.handle} 56 - </Anchor> 57 - {' '}added to{' '} 50 + </Anchor>{' '} 51 + added to{' '} 58 52 <Anchor 59 53 component="button" 60 54 onClick={() => handleCollectionClick(collections[0].id)} ··· 74 68 c="blue" 75 69 > 76 70 @{user.handle} 77 - </Anchor> 78 - {' '}added to{' '} 71 + </Anchor>{' '} 72 + added to{' '} 79 73 {collections.map((collection, index) => ( 80 74 <span key={collection.id}> 81 75 <Anchor