Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c82295b
Resstore notification feed in sample app
szuperaz Jul 24, 2025
f321abb
Implement actvity marked event handler
szuperaz Jul 25, 2025
c50a692
Handle notification status event
szuperaz Jul 25, 2025
e472120
Add integration test for notification feed
szuperaz Jul 25, 2025
32e85ad
Merge branch 'main' into notification-feeds
szuperaz Jul 30, 2025
b61af6b
Update to new structure
szuperaz Jul 30, 2025
da1e745
Only update lastSeenAt timestamp is menu is closed
szuperaz Jul 30, 2025
5826ef2
In progress
szuperaz Aug 1, 2025
338a374
Update to latest API spec
szuperaz Aug 4, 2025
8ce1c9b
Fix isSeen and isRead logic
szuperaz Aug 5, 2025
0c114e4
Add useNotificationStatus hook
arnautov-anton Aug 7, 2025
9514fbc
Merge branch 'main' of github.com:GetStream/stream-feeds-js into noti…
arnautov-anton Aug 13, 2025
deac5fc
Cleanup & updates
arnautov-anton Aug 13, 2025
5d509c8
Another set of changes
arnautov-anton Aug 15, 2025
8fefb74
Merge branch 'main' into notification-feeds
arnautov-anton Aug 18, 2025
0777874
Post-review changes
arnautov-anton Aug 19, 2025
a99c191
Merge branch 'main' of github.com:GetStream/stream-feeds-js into noti…
arnautov-anton Aug 19, 2025
c51a491
Post-merge changes
arnautov-anton Aug 19, 2025
38d72db
Merge branch 'main' into notification-feeds
arnautov-anton Aug 19, 2025
ad35abf
Post-merge changes
arnautov-anton Aug 19, 2025
9093b0b
Fix test
arnautov-anton Aug 19, 2025
fa5414c
Merge branch 'main' into notification-feeds
arnautov-anton Aug 20, 2025
6a3bec1
Merge branch 'main' into notification-feeds
arnautov-anton Aug 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ai-docs/ai-state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ State update code is located in the `state-updates` folder. These methods return

```ts
{
activities: [{/* activity */}],
data: { activities: [{/* activity */}] },
changed: true
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export * from './useFollowers';
export * from './useFollowing';
export * from './useFeedMetadata';
export * from './useOwnFollows';
export * from './useNotificationStatus';
export * from './useAggregatedActivities';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Feed, FeedState } from '../../../src/feed';
import { useStateStore } from '../useStateStore';

const selector = ({ aggregated_activities }: FeedState) => ({
aggregated_activities,
});

type UseAggregatedActivitiesReturnType = ReturnType<typeof selector>;

/**
* A React hook that returns a reactive object containing the current aggregated activities.
*/
export function useAggregatedActivities(
notificationFeed: Feed,
): UseAggregatedActivitiesReturnType;
export function useAggregatedActivities(
notificationFeed?: Feed,
): UseAggregatedActivitiesReturnType | undefined;
export function useAggregatedActivities(notificationFeed?: Feed) {
return useStateStore(notificationFeed?.state, selector);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Feed, FeedState } from '../../../src/feed';
import { useStateStore } from '../useStateStore';
import { NotificationStatusResponse } from '../../../src/gen/models';

const selector = ({ notification_status }: FeedState) =>
({
unread: notification_status?.unread ?? 0,
unseen: notification_status?.unseen ?? 0,
last_read_at: notification_status?.last_read_at,
last_seen_at: notification_status?.last_seen_at,
read_activities: notification_status?.read_activities,
seen_activities: notification_status?.seen_activities,
}) satisfies NotificationStatusResponse;

type UseNotificationStatusReturnType = ReturnType<typeof selector>;

export function useNotificationStatus(
feed: Feed,
): UseNotificationStatusReturnType;
export function useNotificationStatus(
feed?: Feed,
): UseNotificationStatusReturnType | undefined;
export function useNotificationStatus(feed?: Feed) {
return useStateStore(feed?.state, selector);

// TODO: add markRead and markAllRead functions?
// return useMemo(() => {
// if (!data) {
// return undefined;
// }

// return data;
// }, [data]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
createTestClient,
createTestTokenGenerator,
getTestUser,
waitForEvent,
} from './utils';

import { UserRequest } from '../src/gen/models';
import { FeedsClient } from '../src/feeds-client';
import { Feed } from '../src/feed';

describe('Notification Feed Test Setup', () => {
let client1: FeedsClient;
let client2: FeedsClient;

// Create two test users
const user1: UserRequest = getTestUser();
const user2: UserRequest = getTestUser();

// Feeds for user1
let user1UserFeed: Feed;
let user1NotificationFeed: Feed;
let user1TimelineFeed: Feed;

// Feeds for user2
let user2UserFeed: Feed;
let user2NotificationFeed: Feed;
let user2TimelineFeed: Feed;

beforeAll(async () => {
// Create and connect first client
client1 = createTestClient();
await client1.connectUser(user1, createTestTokenGenerator(user1));

// Create and connect second client
client2 = createTestClient();
await client2.connectUser(user2, createTestTokenGenerator(user2));

// Initialize feeds for user1
user1UserFeed = client1.feed('user', user1.id);
user1NotificationFeed = client1.feed('notification', user1.id);
user1TimelineFeed = client1.feed('timeline', user1.id);

// Initialize feeds for user2
user2UserFeed = client2.feed('user', user2.id);
user2NotificationFeed = client2.feed('notification', user2.id);
user2TimelineFeed = client2.feed('timeline', user2.id);

await user1UserFeed.getOrCreate({
watch: true,
data: { visibility: 'public' },
});
await user2UserFeed.getOrCreate({
watch: true,
data: { visibility: 'public' },
});

// Create notification feeds
await user1NotificationFeed.getOrCreate({ watch: true });
await user2NotificationFeed.getOrCreate({ watch: true });

// Create timeline feeds
await user1TimelineFeed.getOrCreate({ watch: true });
await user2TimelineFeed.getOrCreate({ watch: true });

await user1UserFeed.addActivity({
type: 'post',
text: 'Hello, world!',
});
});

it(`user 2 follows user 1 - user 1 receives notification`, async () => {
await Promise.all([
user2TimelineFeed.follow(user1UserFeed.feed, {
create_notification_activity: true,
}),
waitForEvent(user1NotificationFeed, 'feeds.notification_feed.updated'),
]);

expect(
user1NotificationFeed.state.getLatestValue().notification_status?.unseen,
).toBe(1);
expect(
user1NotificationFeed.state.getLatestValue().notification_status?.unread,
).toBe(1);
expect(
user1NotificationFeed.state.getLatestValue().aggregated_activities,
).toHaveLength(1);
});

it(`user 2 likes user 1's post - user 1 receives notification`, async () => {
await user2TimelineFeed.getOrCreate({ watch: true });

const activity = user2TimelineFeed.state.getLatestValue().activities?.[0]!;

await Promise.all([
client2.addReaction({
activity_id: activity.id,
type: 'like',
create_notification_activity: true,
}),
waitForEvent(user1NotificationFeed, 'feeds.notification_feed.updated'),
]);

expect(
user1NotificationFeed.state.getLatestValue().notification_status?.unseen,
).toBe(2);

expect(
user1NotificationFeed.state.getLatestValue().notification_status?.unread,
).toBe(2);

expect(
user1NotificationFeed.state.getLatestValue().aggregated_activities,
).toHaveLength(2);
});

it(`user 2 adds comment to user 1's post - user 1 receives notification`, async () => {
const activity = user2TimelineFeed.state.getLatestValue().activities?.[0]!;

await Promise.all([
client2.addComment({
object_id: activity.id,
object_type: 'activity',
comment: 'Nice post!',
create_notification_activity: true,
}),
waitForEvent(user1NotificationFeed, 'feeds.notification_feed.updated'),
]);

expect(
user1NotificationFeed.state.getLatestValue().notification_status?.unseen,
).toBe(3);

expect(
user1NotificationFeed.state.getLatestValue().notification_status?.unread,
).toBe(3);

expect(
user1NotificationFeed.state.getLatestValue().aggregated_activities,
).toHaveLength(3);
});

it(`user marks first notification as read and seen`, async () => {
const firstActivity =
user1NotificationFeed.state.getLatestValue().aggregated_activities?.[0]!;

await Promise.all([
user1NotificationFeed.markActivity({
mark_read: [firstActivity.group],
mark_seen: [firstActivity.group],
}),
waitForEvent(user1NotificationFeed, 'feeds.activity.marked'),
waitForEvent(user1NotificationFeed, 'feeds.notification_feed.updated'),
]);

const stateAfter = user1NotificationFeed.state.getLatestValue();

expect(stateAfter.notification_status?.unread).toBe(2);
expect(stateAfter.notification_status?.unseen).toBe(2);
expect(stateAfter.notification_status?.read_activities?.[0]).toBe(
firstActivity.group,
);
// TODO: check whether this was expected behavior, last_seen_at is currently updated only when mark_all_seen is true
// expect(stateAfter.notification_status?.last_seen_at).toBeDefined();
});

afterAll(async () => {
await user1UserFeed.delete({ hard_delete: true });
await user2UserFeed.delete({ hard_delete: true });
await user1NotificationFeed.delete({ hard_delete: true });
await user2NotificationFeed.delete({ hard_delete: true });
await user1TimelineFeed.delete({ hard_delete: true });
await user2TimelineFeed.delete({ hard_delete: true });

await client1.disconnectUser();
await client2.disconnectUser();
});
});
Loading