Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 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
25 changes: 13 additions & 12 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 from `trst-data-generator` folder:

```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 Expand Up @@ -142,18 +155,6 @@ yarn create-posts --features link,attachment,mention,poll,reaction,comment,bookm

> Note: Each feature has a probability of being included (not every post will have every enabled feature). Link and attachment are mutually exclusive per post.

### Usage

Typical order of operations:

```bash
cd test-data-generator
yarn create-users
yarn create-follows
yarn create-posts --features link,attachment,mention,poll,reaction,comment,bookmark,repost
yarn create-stories
```

## Local Setup

### Prerequisites
Expand Down
50 changes: 26 additions & 24 deletions sample-apps/react-demo/app/AppSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,35 @@ export const AppSkeleton = ({ children }: PropsWithChildren) => {
const unreadCount = notificationStatus?.unread ?? 0;

return (
<div className="drawer h-full max-h-full lg:drawer-open">
<input id="my-drawer" type="checkbox" className="drawer-toggle" />
<div className="drawer-content max-h-full min-h-0 h-full max-h-full flex flex-col gap-1 items-center justify-center">
<nav className="hidden md:flex lg:hidden navbar w-full bg-base-100">
<div className="flex-none lg:hidden">
<label
htmlFor="my-drawer"
className="drawer-button btn btn-square btn-ghost"
>
<span className="material-symbols-outlined">menu</span>
</label>
</div>
</nav>
<div className="h-full max-h-full overflow-y-auto w-full md:p-10 p-4 flex flex-row gap-10 items-start justify-center">
<div className="h-full max-h-full lg:w-[70%] w-full flex flex-col items-center justify-start">
<div className="w-full h-full max-h-full">
{children}
<div className="h-full max-h-full w-full max-w-7xl mx-auto">
<div className="drawer h-full max-h-full lg:drawer-open">
<input id="my-drawer" type="checkbox" className="drawer-toggle" />
<div className="drawer-content max-h-full min-h-0 h-full max-h-full flex flex-col gap-1 items-center justify-center">
<nav className="hidden md:flex lg:hidden navbar w-full bg-base-100">
<div className="flex-none lg:hidden">
<label
htmlFor="my-drawer"
className="drawer-button btn btn-square btn-ghost"
>
<span className="material-symbols-outlined">menu</span>
</label>
</div>
</nav>
<div className="h-full max-h-full overflow-y-auto w-full md:p-10 p-4 flex flex-row gap-10 items-start justify-center">
<div className="h-full max-h-full lg:w-[70%] w-full flex flex-col items-center justify-start">
<div className="w-full h-full max-h-full">
{children}
</div>
</div>
<div className="lg:flex hidden w-[30%] flex-col items-stretch justify-start gap-4">
<SearchInput />
<FollowSuggestions />
</div>
</div>
<div className="lg:flex hidden w-[30%] flex-col items-stretch justify-start gap-4">
<SearchInput />
<FollowSuggestions />
</div>
<Dock hasUnreadNotifications={unreadCount > 0} />
</div>
<Dock hasUnreadNotifications={unreadCount > 0} />
<DrawerSide unreadCount={unreadCount} />
</div>
<DrawerSide unreadCount={unreadCount} />
</div>
);
};
Expand All @@ -52,7 +54,7 @@ const DrawerSide = ({ unreadCount }: { unreadCount: number }) => {
aria-label="close sidebar"
className="drawer-overlay"
></label>
<ul className="menu bg-base-200 min-h-full w-60 p-4">
<ul className="menu min-h-full w-60 p-4">
<li>
<HomeLink />
</li>
Expand Down
4 changes: 2 additions & 2 deletions sample-apps/react-demo/app/ClientApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { generateUsername } from 'unique-username-generator';
import { useEffect, useMemo, type PropsWithChildren } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { LoadingIndicator } from './components/utility/LoadingIndicator';
import { userIdToUserName } from './utility/user-id-to-name';
import { userIdToName } from './utility/userIdToName';

export const ClientApp = ({ children }: PropsWithChildren) => {
const searchParams = useSearchParams();
Expand All @@ -40,7 +40,7 @@ export const ClientApp = ({ children }: PropsWithChildren) => {
const CURRENT_USER = useMemo(
() => ({
id: USER_ID,
name: process.env.NEXT_PUBLIC_USER_NAME ?? userIdToUserName(USER_ID),
name: process.env.NEXT_PUBLIC_USER_NAME ?? userIdToName(USER_ID),
token: process.env.NEXT_PUBLIC_USER_TOKEN
? process.env.NEXT_PUBLIC_USER_TOKEN
: process.env.NEXT_PUBLIC_TOKEN_URL
Expand Down
2 changes: 1 addition & 1 deletion sample-apps/react-demo/app/bookmarks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export default function Bookmarks() {
<>
<ul className="w-full list">
{bookmarks.map((bookmark) => (
<li className="list-row" key={bookmark.activity.id}>
<li className="list-row w-full flex flex-row justify-stretch items-stretch" key={bookmark.activity.id}>
<ActivityPreview
activity={bookmark.activity}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const ActivityComposer = ({
initialMentionedUsers={initialMentionedUsers}
onSubmit={handleSubmit}
textareaBorder={textareaBorder}
allowEmptyText={!!parent}
/>
</div>
);
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
Expand Up @@ -8,7 +8,7 @@ export const ActivityPreview = ({
activity: ActivityResponse;
}) => {
return (
<NavLink href={`/activity/${activity.id}`}>
<NavLink className="w-full" href={`/activity/${activity.id}`}>
<Activity activity={activity} location="preview" />
</NavLink>
);
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}
/>
);
};
Loading
Loading