Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b09e039
fix: provide feed context for reposting from activity details
szuperaz Jan 29, 2026
28504ce
example: improve AttachmentList UI with overlay navigation buttons
szuperaz Jan 29, 2026
5d06d2f
example: add image preloading to improve navigation UX
szuperaz Jan 29, 2026
7946dc8
example: story viewer better UX
szuperaz Jan 29, 2026
a6f829c
example: only show bookmark on own profile page, show toggle follow b…
szuperaz Jan 29, 2026
ced2696
example: add optimistic state updates to reaction and bookmark actions
szuperaz Jan 29, 2026
89b79b7
example: refactor optimistic UI updates to use React's useOptimistic
szuperaz Jan 29, 2026
a44d331
example: infinite scroll for activity list
szuperaz Jan 29, 2026
03466e7
example: add pull to refresh
szuperaz Jan 29, 2026
f8997bd
chore: update test generator readme
szuperaz Jan 29, 2026
faee87d
example: fix story viewer
szuperaz Jan 29, 2026
d6c66f0
example: fix story viewer
szuperaz Jan 29, 2026
b47a1da
example: fix preceived UI glitch with chat_bubble icon
szuperaz Jan 30, 2026
41007be
example: make bookmark link full-width
szuperaz Jan 30, 2026
2a71e7e
Update readme
szuperaz Jan 30, 2026
ea685d5
Review fixes
szuperaz Jan 30, 2026
516aae1
preload google material symbols
szuperaz Jan 30, 2026
cbce691
Add swiping to attachmentlist
szuperaz Jan 30, 2026
23e3a5e
fix: pull to refresh and story timeline issue
szuperaz Jan 30, 2026
bcbd3ad
fix own story viewer bugs
szuperaz Jan 30, 2026
eb9715b
allow empty text when reposting an activity
szuperaz Jan 30, 2026
ce2e89e
Review fixes
szuperaz Jan 30, 2026
2a2bab4
fix lint issues
szuperaz Jan 30, 2026
4c5e0a1
Add max width to page
szuperaz Jan 30, 2026
aa33323
Review fixes
szuperaz Jan 30, 2026
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@ API_URL=<Optional, Stream API URL>

2. Install dependencies: `yarn` (from the repository root)

### Typical use-case

Run these scripts in the following order:

```bash
yarn create-users
yarn create-follows
# Adjust what features you need
yarn create-posts --features link,attachment,mention,poll,reaction,comment,bookmark,repost
# Optional, only useful if you have story feeds
yarn create-stories
```

### Available Scripts

Run these commands from the `test-data-generator/` directory:
Expand Down
85 changes: 83 additions & 2 deletions sample-apps/react-demo/app/components/activity/ActivityList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,81 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useFeedActivities, useFeedContext } from '@stream-io/feeds-react-sdk';
import { Activity } from './Activity';
import { ErrorCard } from '../utility/ErrorCard';
import { LoadingIndicator } from '../utility/LoadingIndicator';

const findScrollContainer = (element: HTMLElement | null): HTMLElement | null => {
if (!element) return null;
let current: HTMLElement | null = element;
while (current && current !== document.body) {
const style = getComputedStyle(current);
if (
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
current.scrollHeight > current.clientHeight
) {
return current;
}
current = current.parentElement;
}
// Fall back to checking document
if (document.documentElement.scrollHeight > window.innerHeight) {
return document.documentElement;
}
return null;
};

const useInfiniteScroll = ({
loadNextPage,
hasNextPage,
isLoading,
}: {
loadNextPage: () => void;
hasNextPage: boolean;
isLoading: boolean;
}) => {
const sentinelRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const [canScroll, setCanScroll] = useState(false);

const checkCanScroll = useCallback(() => {
const scrollContainer = findScrollContainer(listRef.current);
setCanScroll(scrollContainer !== null);
}, []);

useEffect(() => {
checkCanScroll();
window.addEventListener('resize', checkCanScroll);
return () => window.removeEventListener('resize', checkCanScroll);
}, [checkCanScroll]);

useEffect(() => {
checkCanScroll();
});

useEffect(() => {
if (!canScroll || !hasNextPage || isLoading) return;

const sentinel = sentinelRef.current;
if (!sentinel) return;

const scrollContainer = findScrollContainer(listRef.current);

const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isLoading) {
loadNextPage();
}
},
{ root: scrollContainer === document.documentElement ? null : scrollContainer, rootMargin: '200px' }
);

observer.observe(sentinel);
return () => observer.disconnect();
}, [canScroll, hasNextPage, isLoading, loadNextPage]);

return { sentinelRef, listRef, canScroll };
};

export const ActivityList = ({
location,
error,
Expand All @@ -14,6 +87,12 @@ export const ActivityList = ({
const { activities, loadNextPage, has_next_page, is_loading } =
useFeedActivities();

const { sentinelRef, listRef, canScroll } = useInfiniteScroll({
loadNextPage,
hasNextPage: has_next_page ?? false,
isLoading: is_loading ?? false,
});

if (error) {
return <ErrorCard message="Failed to load feed" error={error} />;
}
Expand All @@ -35,7 +114,7 @@ export const ActivityList = ({
</div>
) : (
<>
<ul className="list w-full">
<ul ref={listRef} className="list w-full">
{activities?.map((activity) => (
<li className="list-row w-full px-0 flex flex-row justify-stretch items-stretch" key={activity.id}>
<Activity
Expand All @@ -45,11 +124,13 @@ export const ActivityList = ({
</li>
))}
</ul>
{has_next_page && (
{has_next_page && !canScroll && (
<button className="btn btn-soft btn-primary" onClick={loadNextPage}>
{is_loading ? <LoadingIndicator /> : 'Load more'}
</button>
)}
{has_next_page && canScroll && <div ref={sentinelRef} className="h-1" />}
{is_loading && canScroll && <LoadingIndicator />}
</>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ActivityResponse } from '@stream-io/feeds-react-sdk';
import { StreamFeed, type ActivityResponse } from '@stream-io/feeds-react-sdk';
import { useCallback, useRef, useState } from 'react';
import { ActivityComposer } from '../ActivityComposer';
import { useOwnFeedsContext } from '@/app/own-feeds-context';

export const ReplyToActivity = ({ activity }: { activity: ActivityResponse }) => {
const { ownFeed } = useOwnFeedsContext();
const [isOpen, setIsOpen] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);

Expand Down Expand Up @@ -41,7 +43,9 @@ export const ReplyToActivity = ({ activity }: { activity: ActivityResponse }) =>
<span className="material-symbols-outlined">close</span>
</button>
</div>
{isOpen && <ActivityComposer parent={activity} onSave={closeDialog} />}
{isOpen && ownFeed && <StreamFeed feed={ownFeed}>
<ActivityComposer parent={activity} onSave={closeDialog} />
</StreamFeed>}
</div>
<form method="dialog" className="modal-backdrop">
<button>close</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
type ActivityResponse,
useFeedsClient,
} from '@stream-io/feeds-react-sdk';
import { useCallback } from 'react';
import { startTransition, useCallback, useOptimistic, useState } from 'react';
import { ActionButton } from '../../utility/ActionButton';

export const ToggleBookmark = ({
Expand All @@ -11,25 +11,50 @@ export const ToggleBookmark = ({
activity: ActivityResponse;
}) => {
const client = useFeedsClient();
const [inProgress, setInProgress] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);

const toggleBookmark = useCallback(
() =>
activity.own_bookmarks?.length > 0
? client?.deleteBookmark({
const isBookmarked = (activity.own_bookmarks?.length ?? 0) > 0;
const bookmarkCount = activity.bookmark_count ?? 0;

const [state, setState] = useOptimistic(
{ isBookmarked, bookmarkCount },
(_, newState: { isBookmarked: boolean; bookmarkCount: number }) => newState,
);

const toggleBookmark = useCallback(() => {
setInProgress(true);
setError(undefined);

startTransition(async () => {
try {
if (isBookmarked) {
setState({ isBookmarked: false, bookmarkCount: bookmarkCount - 1 });
await client?.deleteBookmark({
activity_id: activity.id,
})
: client?.addBookmark({
});
} else {
setState({ isBookmarked: true, bookmarkCount: bookmarkCount + 1 });
await client?.addBookmark({
activity_id: activity.id,
}),
[client, activity.id, activity.own_bookmarks],
);
});
}
} catch (e) {
setError(e as Error);
} finally {
setInProgress(false);
}
});
}, [client, activity.id, isBookmarked, bookmarkCount, setState]);

return (
<ActionButton
onClick={toggleBookmark}
icon="bookmark"
label={activity.bookmark_count.toString()}
isActive={activity.own_bookmarks?.length > 0}
disabled={inProgress}
label={state.bookmarkCount.toString()}
isActive={state.isBookmarked}
error={error}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ActivityResponse } from '@stream-io/feeds-react-sdk';
import { useFeedsClient } from '@stream-io/feeds-react-sdk';
import { useCallback } from 'react';
import { startTransition, useCallback, useOptimistic, useState } from 'react';
import { ActionButton } from '../../utility/ActionButton';

export const ToggleReaction = ({
Expand All @@ -9,29 +9,54 @@ export const ToggleReaction = ({
activity: ActivityResponse;
}) => {
const client = useFeedsClient();
const [inProgress, setInProgress] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);

const toggleReaction = useCallback(
() =>
activity.own_reactions?.length > 0
? client?.deleteActivityReaction({
activity_id: activity.id,
type: 'like',
delete_notification_activity: true,
})
: client?.addActivityReaction({
activity_id: activity.id,
type: 'like',
create_notification_activity: true,
}),
[client, activity.id, activity.own_reactions],
const isLiked = activity.own_reactions?.length > 0;
const likeCount = activity.reaction_groups.like?.count ?? 0;

const [state, setState] = useOptimistic(
{ isLiked, likeCount },
(_, newState: { isLiked: boolean; likeCount: number }) => newState,
);

const toggleReaction = useCallback(() => {
setInProgress(true);
setError(undefined);

startTransition(async () => {
try {
if (isLiked) {
setState({ isLiked: false, likeCount: likeCount - 1 });
await client?.deleteActivityReaction({
activity_id: activity.id,
type: 'like',
delete_notification_activity: true,
});
} else {
setState({ isLiked: true, likeCount: likeCount + 1 });
await client?.addActivityReaction({
activity_id: activity.id,
type: 'like',
create_notification_activity: true,
});
}
} catch (e) {
setError(e as Error);
} finally {
setInProgress(false);
}
});
}, [client, activity.id, isLiked, likeCount, setState]);

return (
<ActionButton
onClick={toggleReaction}
icon="favorite"
label={(activity.reaction_groups.like?.count ?? 0).toString()}
isActive={activity.own_reactions?.length > 0}
disabled={inProgress}
label={state.likeCount.toString()}
isActive={state.isLiked}
error={error}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CommentResponse } from '@stream-io/feeds-react-sdk';
import { useFeedsClient } from '@stream-io/feeds-react-sdk';
import { useCallback } from 'react';
import { startTransition, useCallback, useOptimistic, useState } from 'react';
import { SecondaryActionButton } from '../../utility/ActionButton';

export const ToggleCommentReaction = ({
Expand All @@ -11,30 +11,55 @@ export const ToggleCommentReaction = ({
className?: string;
}) => {
const client = useFeedsClient();
const [inProgress, setInProgress] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);

const toggleReaction = useCallback(
() =>
comment.own_reactions?.length > 0
? client?.deleteCommentReaction({
id: comment.id,
type: 'like',
delete_notification_activity: true,
})
: client?.addCommentReaction({
id: comment.id,
type: 'like',
create_notification_activity: true,
}),
[client, comment.id, comment.own_reactions],
const isLiked = (comment.own_reactions?.length ?? 0) > 0;
const likeCount = comment.reaction_groups?.like?.count ?? 0;

const [state, setState] = useOptimistic(
{ isLiked, likeCount },
(_, newState: { isLiked: boolean; likeCount: number }) => newState,
);

const toggleReaction = useCallback(() => {
setInProgress(true);
setError(undefined);

startTransition(async () => {
try {
if (isLiked) {
setState({ isLiked: false, likeCount: likeCount - 1 });
await client?.deleteCommentReaction({
id: comment.id,
type: 'like',
delete_notification_activity: true,
});
} else {
setState({ isLiked: true, likeCount: likeCount + 1 });
await client?.addCommentReaction({
id: comment.id,
type: 'like',
create_notification_activity: true,
});
}
} catch (e) {
setError(e as Error);
} finally {
setInProgress(false);
}
});
}, [client, comment.id, isLiked, likeCount, setState]);

return (
<SecondaryActionButton
onClick={toggleReaction}
icon="favorite"
label={(comment.reaction_groups?.like?.count ?? 0).toString()}
isActive={comment.own_reactions?.length > 0}
disabled={inProgress}
label={state.likeCount.toString()}
isActive={state.isLiked}
className={className}
error={error}
/>
);
};
Loading
Loading