diff --git a/README.md b/README.md index 0e0fdc42..a17842d2 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,19 @@ 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: @@ -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 diff --git a/sample-apps/react-demo/app/AppSkeleton.tsx b/sample-apps/react-demo/app/AppSkeleton.tsx index eb81db35..74e31a5e 100644 --- a/sample-apps/react-demo/app/AppSkeleton.tsx +++ b/sample-apps/react-demo/app/AppSkeleton.tsx @@ -13,33 +13,35 @@ export const AppSkeleton = ({ children }: PropsWithChildren) => { const unreadCount = notificationStatus?.unread ?? 0; return ( -
- -
- -
-
-
- {children} +
+
+ +
+ +
+
+
+ {children} +
+
+
+ +
-
- - -
+ 0} />
- 0} /> +
-
); }; @@ -52,7 +54,7 @@ const DrawerSide = ({ unreadCount }: { unreadCount: number }) => { aria-label="close sidebar" className="drawer-overlay" > -
    +
    • @@ -62,7 +64,7 @@ const DrawerSide = ({ unreadCount }: { unreadCount: number }) => {
    • {unreadCount > 0 && ( -
      +
      {unreadCount}
      )} @@ -86,29 +88,16 @@ const Dock = ({ }) => { return (
      - - - - - - - - - + + + + + + {hasUnreadNotifications && ( +
      + )} + +
      ); }; diff --git a/sample-apps/react-demo/app/ClientApp.tsx b/sample-apps/react-demo/app/ClientApp.tsx index a9bb10f4..b67875d3 100644 --- a/sample-apps/react-demo/app/ClientApp.tsx +++ b/sample-apps/react-demo/app/ClientApp.tsx @@ -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(); @@ -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 diff --git a/sample-apps/react-demo/app/bookmarks/page.tsx b/sample-apps/react-demo/app/bookmarks/page.tsx index 66f0bd31..5a47a8ca 100644 --- a/sample-apps/react-demo/app/bookmarks/page.tsx +++ b/sample-apps/react-demo/app/bookmarks/page.tsx @@ -66,7 +66,7 @@ export default function Bookmarks() { <>
        {bookmarks.map((bookmark) => ( -
      • +
      • diff --git a/sample-apps/react-demo/app/components/activity/ActivityComposer.tsx b/sample-apps/react-demo/app/components/activity/ActivityComposer.tsx index ee783775..888b79d8 100644 --- a/sample-apps/react-demo/app/components/activity/ActivityComposer.tsx +++ b/sample-apps/react-demo/app/components/activity/ActivityComposer.tsx @@ -76,6 +76,7 @@ export const ActivityComposer = ({ initialMentionedUsers={initialMentionedUsers} onSubmit={handleSubmit} textareaBorder={textareaBorder} + allowEmptyText={!!parent} />
      ); diff --git a/sample-apps/react-demo/app/components/activity/ActivityList.tsx b/sample-apps/react-demo/app/components/activity/ActivityList.tsx index 28bc4484..1ce1cd49 100644 --- a/sample-apps/react-demo/app/components/activity/ActivityList.tsx +++ b/sample-apps/react-demo/app/components/activity/ActivityList.tsx @@ -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(null); + const listRef = useRef(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, @@ -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 ; } @@ -35,7 +114,7 @@ export const ActivityList = ({
      ) : ( <> -
        +
          {activities?.map((activity) => (
        • ))}
        - {has_next_page && ( + {has_next_page && !canScroll && ( )} + {has_next_page && canScroll &&
        } + {is_loading && canScroll && } )}
        diff --git a/sample-apps/react-demo/app/components/activity/ActivityPreview.tsx b/sample-apps/react-demo/app/components/activity/ActivityPreview.tsx index 3b3dd3cb..22357a11 100644 --- a/sample-apps/react-demo/app/components/activity/ActivityPreview.tsx +++ b/sample-apps/react-demo/app/components/activity/ActivityPreview.tsx @@ -8,7 +8,7 @@ export const ActivityPreview = ({ activity: ActivityResponse; }) => { return ( - + ); diff --git a/sample-apps/react-demo/app/components/activity/activity-interactions/ReplyToActivity.tsx b/sample-apps/react-demo/app/components/activity/activity-interactions/ReplyToActivity.tsx index 8b2a3d9a..fc8817ae 100644 --- a/sample-apps/react-demo/app/components/activity/activity-interactions/ReplyToActivity.tsx +++ b/sample-apps/react-demo/app/components/activity/activity-interactions/ReplyToActivity.tsx @@ -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(null); @@ -41,7 +43,9 @@ export const ReplyToActivity = ({ activity }: { activity: ActivityResponse }) => close
- {isOpen && } + {isOpen && ownFeed && + + }
diff --git a/sample-apps/react-demo/app/components/activity/activity-interactions/ToggleBookmark.tsx b/sample-apps/react-demo/app/components/activity/activity-interactions/ToggleBookmark.tsx index 0145afed..7f4e35d5 100644 --- a/sample-apps/react-demo/app/components/activity/activity-interactions/ToggleBookmark.tsx +++ b/sample-apps/react-demo/app/components/activity/activity-interactions/ToggleBookmark.tsx @@ -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 = ({ @@ -11,25 +11,50 @@ export const ToggleBookmark = ({ activity: ActivityResponse; }) => { const client = useFeedsClient(); + const [inProgress, setInProgress] = useState(false); + const [error, setError] = useState(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 ( 0} + disabled={inProgress} + label={state.bookmarkCount.toString()} + isActive={state.isBookmarked} + error={error} /> ); }; diff --git a/sample-apps/react-demo/app/components/activity/activity-interactions/ToggleReaction.tsx b/sample-apps/react-demo/app/components/activity/activity-interactions/ToggleReaction.tsx index d9dfa14e..c86dfcdf 100644 --- a/sample-apps/react-demo/app/components/activity/activity-interactions/ToggleReaction.tsx +++ b/sample-apps/react-demo/app/components/activity/activity-interactions/ToggleReaction.tsx @@ -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 = ({ @@ -9,29 +9,54 @@ export const ToggleReaction = ({ activity: ActivityResponse; }) => { const client = useFeedsClient(); + const [inProgress, setInProgress] = useState(false); + const [error, setError] = useState(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 ( 0} + disabled={inProgress} + label={state.likeCount.toString()} + isActive={state.isLiked} + error={error} /> ); }; diff --git a/sample-apps/react-demo/app/components/comments/comment-interactions/ToggleCommentReaction.tsx b/sample-apps/react-demo/app/components/comments/comment-interactions/ToggleCommentReaction.tsx index 3adbe99f..33fd8535 100644 --- a/sample-apps/react-demo/app/components/comments/comment-interactions/ToggleCommentReaction.tsx +++ b/sample-apps/react-demo/app/components/comments/comment-interactions/ToggleCommentReaction.tsx @@ -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 = ({ @@ -11,30 +11,55 @@ export const ToggleCommentReaction = ({ className?: string; }) => { const client = useFeedsClient(); + const [inProgress, setInProgress] = useState(false); + const [error, setError] = useState(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 ( 0} + disabled={inProgress} + label={state.likeCount.toString()} + isActive={state.isLiked} className={className} + error={error} /> ); }; diff --git a/sample-apps/react-demo/app/components/common/Content.tsx b/sample-apps/react-demo/app/components/common/Content.tsx index 1cf22fc6..548ee0ad 100644 --- a/sample-apps/react-demo/app/components/common/Content.tsx +++ b/sample-apps/react-demo/app/components/common/Content.tsx @@ -204,7 +204,7 @@ export const Content = ({ text, attachments, moderation, location, mentioned_use return ( <> -

{renderedText}

+ {renderedText &&

{renderedText}

} {mediaAttachments.length > 0 && (
diff --git a/sample-apps/react-demo/app/components/common/attachments/Attachment.tsx b/sample-apps/react-demo/app/components/common/attachments/Attachment.tsx index 390345e8..42b10887 100644 --- a/sample-apps/react-demo/app/components/common/attachments/Attachment.tsx +++ b/sample-apps/react-demo/app/components/common/attachments/Attachment.tsx @@ -1,6 +1,7 @@ import type { Attachment as AttachmentType } from '@stream-io/feeds-react-sdk'; import { useMemo } from 'react'; import { SIZE_CLASSES, SIZE_DIMENSIONS } from './sizes'; +import { buildImageUrl } from '@/app/utility/useImagePreloader'; export type AttachmentProps = { attachment: AttachmentType; @@ -21,7 +22,7 @@ export const Attachment = ({ return attachment.asset_url; } - return attachment.image_url + `&w=${SIZE_DIMENSIONS[size].width}&h=${SIZE_DIMENSIONS[size].height}`; + return buildImageUrl(attachment.image_url, SIZE_DIMENSIONS[size].width, SIZE_DIMENSIONS[size].height); }, [attachment?.image_url, attachment?.asset_url, isVideo, size]); if (!url) { diff --git a/sample-apps/react-demo/app/components/common/attachments/AttachmentList.tsx b/sample-apps/react-demo/app/components/common/attachments/AttachmentList.tsx index 5c46d6b0..4eb67d8f 100644 --- a/sample-apps/react-demo/app/components/common/attachments/AttachmentList.tsx +++ b/sample-apps/react-demo/app/components/common/attachments/AttachmentList.tsx @@ -1,13 +1,20 @@ import type { Attachment as AttachmentType } from '@stream-io/feeds-react-sdk'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo, useRef } from 'react'; import { Attachment } from './Attachment'; import { ImageViewer } from './ImageViewer'; +import { SIZE_DIMENSIONS } from './sizes'; +import { + buildImageUrl, + useImagePreloader, +} from '../../../utility/useImagePreloader'; export type AttachmentListProps = { attachments: AttachmentType[]; size?: 'small' | 'medium' | 'large'; }; +const SWIPE_THRESHOLD = 50; + export const AttachmentList = ({ attachments, size = 'medium', @@ -15,9 +22,27 @@ export const AttachmentList = ({ const [currentIndex, setCurrentIndex] = useState(0); const [isViewerOpen, setIsViewerOpen] = useState(false); const [viewerInitialIndex, setViewerInitialIndex] = useState(0); + const touchStartX = useRef(null); + const touchEndX = useRef(null); const hasMultiple = attachments.length > 1; + const urlsToPreload = useMemo(() => { + if (attachments.length <= 1) return []; + const prevIdx = + currentIndex === 0 ? attachments.length - 1 : currentIndex - 1; + const nextIdx = + currentIndex === attachments.length - 1 ? 0 : currentIndex + 1; + + const { width, height } = SIZE_DIMENSIONS[size]; + return [prevIdx, nextIdx] + .map((idx) => attachments[idx]) + .filter((a) => a.type !== 'video' && a.image_url) + .map((a) => buildImageUrl(a.image_url, width, height)); + }, [attachments, currentIndex, size]); + + useImagePreloader(urlsToPreload); + const goToPrevious = useCallback(() => { setCurrentIndex((prev) => (prev === 0 ? attachments.length - 1 : prev - 1)); }, [attachments.length]); @@ -26,6 +51,42 @@ export const AttachmentList = ({ setCurrentIndex((prev) => (prev === attachments.length - 1 ? 0 : prev + 1)); }, [attachments.length]); + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + if (!hasMultiple) return; + touchStartX.current = e.touches[0].clientX; + touchEndX.current = null; + }, + [hasMultiple] + ); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + if (!hasMultiple) return; + touchEndX.current = e.touches[0].clientX; + }, + [hasMultiple] + ); + + const handleTouchEnd = useCallback(() => { + if (!hasMultiple || touchStartX.current === null || touchEndX.current === null) { + return; + } + + const diff = touchStartX.current - touchEndX.current; + + if (Math.abs(diff) > SWIPE_THRESHOLD) { + if (diff > 0) { + goToNext(); + } else { + goToPrevious(); + } + } + + touchStartX.current = null; + touchEndX.current = null; + }, [hasMultiple, goToNext, goToPrevious]); + const handleImageClick = useCallback(() => { const currentAttachment = attachments[currentIndex]; if (currentAttachment.type !== 'video') { @@ -42,48 +103,55 @@ export const AttachmentList = ({ const currentAttachment = attachments[currentIndex]; return ( -
-
+
+
{hasMultiple && ( )} -
- -
+ {hasMultiple && ( )} -
- {hasMultiple && ( -
- {attachments.map((_, i) => ( - - ))} -
- )} + {hasMultiple && ( +
+ {attachments.map((_, i) => ( + + ))} +
+ )} +
{ const dialogRef = useRef(null); const [currentIndex, setCurrentIndex] = useState(initialIndex); + const touchStartX = useRef(null); + const touchEndX = useRef(null); - const imageAttachments = attachments.filter((a) => a.type !== 'video'); + const imageAttachments = useMemo(() => attachments.filter((a) => a.type !== 'video'), [attachments]); const hasMultiple = imageAttachments.length > 1; const currentAttachment = imageAttachments[currentIndex]; + const urlsToPreload = useMemo(() => { + if (imageAttachments.length <= 1) return []; + const prevIdx = + currentIndex === 0 ? imageAttachments.length - 1 : currentIndex - 1; + const nextIdx = + currentIndex === imageAttachments.length - 1 ? 0 : currentIndex + 1; + + return [prevIdx, nextIdx] + .map((idx) => imageAttachments[idx]) + .map((a) => buildImageUrl(a.image_url, VIEWER_SIZE.width, VIEWER_SIZE.height)); + }, [imageAttachments, currentIndex]); + + useImagePreloader(urlsToPreload); + useEffect(() => { setCurrentIndex(initialIndex); }, [initialIndex]); @@ -41,6 +64,42 @@ export const ImageViewer = ({ setCurrentIndex((prev) => (prev === imageAttachments.length - 1 ? 0 : prev + 1)); }, [imageAttachments.length]); + const handleTouchStart = useCallback( + (e: TouchEvent) => { + if (!hasMultiple) return; + touchStartX.current = e.touches[0].clientX; + touchEndX.current = null; + }, + [hasMultiple] + ); + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (!hasMultiple) return; + touchEndX.current = e.touches[0].clientX; + }, + [hasMultiple] + ); + + const handleTouchEnd = useCallback(() => { + if (!hasMultiple || touchStartX.current === null || touchEndX.current === null) { + return; + } + + const diff = touchStartX.current - touchEndX.current; + + if (Math.abs(diff) > SWIPE_THRESHOLD) { + if (diff > 0) { + goToNext(); + } else { + goToPrevious(); + } + } + + touchStartX.current = null; + touchEndX.current = null; + }, [hasMultiple, goToNext, goToPrevious]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) return; @@ -57,7 +116,7 @@ export const ImageViewer = ({ return (
-
+
{hasMultiple && ( diff --git a/sample-apps/react-demo/app/components/notifications/Notification.tsx b/sample-apps/react-demo/app/components/notifications/Notification.tsx index d11ac036..71814dc5 100644 --- a/sample-apps/react-demo/app/components/notifications/Notification.tsx +++ b/sample-apps/react-demo/app/components/notifications/Notification.tsx @@ -4,8 +4,7 @@ import { useMemo } from 'react'; import { NavLink } from '../utility/NavLink'; import { Avatar } from '../utility/Avatar'; -const truncateText = (text?: string, maxLength = 20) => { - if (!text) return undefined; +const truncateText = (text: string, maxLength = 20) => { return text.length > maxLength ? text.slice(0, maxLength) + '...' : text; }; @@ -23,7 +22,7 @@ export const Notification = ({ const targetActivity = notification.group.includes('follow') ? undefined : notification.activities[0].notification_context?.target; - const activityText = targetActivity ? truncateText(targetActivity.text) : ''; + const activityText = targetActivity ? (targetActivity.text ? `"${truncateText(targetActivity.text)}"` : 'repost') : ''; let notificationContent: React.ReactNode; switch (action) { @@ -129,7 +128,7 @@ const NotificationContent = ({ icon, postLink, notification, activityText, actio
{actionText} {activityText && postLink && ( - "{activityText}" + {activityText} )}
diff --git a/sample-apps/react-demo/app/components/stories/OwnStories.tsx b/sample-apps/react-demo/app/components/stories/OwnStories.tsx index bbeeeac0..c663033a 100644 --- a/sample-apps/react-demo/app/components/stories/OwnStories.tsx +++ b/sample-apps/react-demo/app/components/stories/OwnStories.tsx @@ -84,9 +84,10 @@ export const OwnStories = () => {
- {isStoryViewerOpen && activities && ( + {isStoryViewerOpen && activities && activities.length > 0 && ( setIsStoryViewerOpen(false)} /> )} diff --git a/sample-apps/react-demo/app/components/stories/StoryTimeline.tsx b/sample-apps/react-demo/app/components/stories/StoryTimeline.tsx index eff79abf..a550ff7e 100644 --- a/sample-apps/react-demo/app/components/stories/StoryTimeline.tsx +++ b/sample-apps/react-demo/app/components/stories/StoryTimeline.tsx @@ -24,7 +24,7 @@ export const StoryTimeline = () => { }, []); return ( -
+
{stories.map((storyGroup) => { return ( { )} diff --git a/sample-apps/react-demo/app/components/stories/StoryViewer.tsx b/sample-apps/react-demo/app/components/stories/StoryViewer.tsx index 3870e222..7c84ba83 100644 --- a/sample-apps/react-demo/app/components/stories/StoryViewer.tsx +++ b/sample-apps/react-demo/app/components/stories/StoryViewer.tsx @@ -1,19 +1,36 @@ +'use client'; + import type { ActivityResponse } from '@stream-io/feeds-react-sdk'; import { useFeedContext } from '@stream-io/feeds-react-sdk'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Avatar } from '../utility/Avatar'; import { formatDistanceToNow } from 'date-fns'; +import { + buildImageUrl, + useImagePreloader, +} from '../../utility/useImagePreloader'; const IMAGE_DURATION = 5000; // 5 seconds per image story +const getStoryImageUrl = (activity: ActivityResponse | undefined) => { + const attachment = activity?.attachments?.[0]; + if (!attachment || attachment.type === 'video') return null; + return buildImageUrl(attachment.image_url, screen.width * 2, screen.height * 2); +}; + +export type StoryViewerProps = { + activities: ActivityResponse[]; + isOpen: boolean; + onClose: () => void; +}; + export const StoryViewer = ({ activities, + isOpen, onClose, -}: { - activities: ActivityResponse[]; - onClose: () => void; -}) => { +}: StoryViewerProps) => { const feed = useFeedContext(); + const dialogRef = useRef(null); const [currentIndex, setCurrentIndex] = useState( Math.max( activities.findIndex((a) => !a.is_watched), @@ -22,6 +39,23 @@ export const StoryViewer = ({ ); const [duration, setDuration] = useState(IMAGE_DURATION); const videoRef = useRef(null); + const initializedRef = useRef(false); + + useEffect(() => { + if (isOpen) { + if (!initializedRef.current) { + dialogRef.current?.showModal(); + setCurrentIndex(Math.max( + activities.findIndex((a) => !a.is_watched), + 0, + )); + initializedRef.current = true; + } + } else { + dialogRef.current?.close(); + initializedRef.current = false; + } + }, [isOpen, activities]); const currentStory = activities[currentIndex]; const totalStories = activities.length; @@ -29,7 +63,14 @@ export const StoryViewer = ({ const isVideo = currentStory?.attachments?.[0]?.type === 'video'; const mediaUrl = isVideo ? currentStory?.attachments?.[0]?.asset_url - : currentStory?.attachments?.[0]?.image_url; + : (getStoryImageUrl(currentStory) ?? undefined); + + const nextStoryImageUrl = useMemo(() => { + if (currentIndex >= activities.length - 1) return null; + return getStoryImageUrl(activities[currentIndex + 1]); + }, [activities, currentIndex]); + + useImagePreloader([nextStoryImageUrl]); useEffect(() => { const activity = activities[currentIndex]; @@ -59,7 +100,7 @@ export const StoryViewer = ({ // Auto-advance to next story (only for images) useEffect(() => { - if (isVideo) return; + if (!isOpen || isVideo) return; setDuration(IMAGE_DURATION); const timeout = setTimeout(() => { @@ -67,84 +108,89 @@ export const StoryViewer = ({ }, IMAGE_DURATION); return () => clearTimeout(timeout); - }, [currentIndex, isVideo, goToNext]); + }, [currentIndex, isVideo, goToNext, isOpen, activities]); return ( -
-
- {activities.map((_, index) => ( + +
+
+ {activities.map((_, index) => ( +
+ {index === currentIndex ? ( +
+ ) : ( +
+ )} +
+ ))} +
+ +
+ +
+
+ {currentStory?.user?.name} +
+
+ {formatDistanceToNow(currentStory?.created_at, { addSuffix: true })} +
+
+
+ + + +
+
- {index === currentIndex ? ( -
+ +
+ {isVideo ? ( +
- -
- -
-
- {currentStory?.user?.name} -
-
- {formatDistanceToNow(currentStory?.created_at, { addSuffix: true })} -
-
-
- - - -
-
-
- -
- {isVideo ? ( -
-
+ + + +
); }; diff --git a/sample-apps/react-demo/app/components/utility/ActionButton.tsx b/sample-apps/react-demo/app/components/utility/ActionButton.tsx index 81d885c6..bf5db71e 100644 --- a/sample-apps/react-demo/app/components/utility/ActionButton.tsx +++ b/sample-apps/react-demo/app/components/utility/ActionButton.tsx @@ -1,4 +1,3 @@ -import { useCallback, useState } from 'react'; import { ErrorToast } from './ErrorToast'; import { NavLink } from './NavLink'; @@ -8,31 +7,25 @@ export const ActionButton = ({ icon, label, isActive, + disabled, + error, }: { onClick?: () => Promise | undefined | void; href?: string; icon: string; label: string; isActive: boolean; + disabled?: boolean; + error?: Error; }) => { const content = ; - const [error, setError] = useState(undefined); - - const handleClick = useCallback(async () => { - try { - setError(undefined); - await onClick?.(); - } catch (e) { - setError(e as Error); - throw e; - } - }, [onClick]); return <> {href ?
{content}
: @@ -47,34 +40,27 @@ export const SecondaryActionButton = ({ icon, label, isActive, + disabled, className, + error, }: { onClick?: () => Promise | undefined | void; href?: string; icon: string; label: string; isActive: boolean; + disabled?: boolean; className?: string; + error?: Error; }) => { - const [error, setError] = useState(undefined); - - const handleClick = useCallback(async () => { - try { - setError(undefined); - await onClick?.(); - } catch (e) { - setError(e as Error); - throw e; - } - }, [onClick]); - const content = ; return <> {href ?
{content}
: @@ -103,8 +89,9 @@ const Content = ({ isActive: boolean | undefined; }) => ( <> + { /* Manually adjust the chat_bubble icon, the shape causes a visual illusion of misalgment */} {icon} diff --git a/sample-apps/react-demo/app/components/utility/PullToRefresh.tsx b/sample-apps/react-demo/app/components/utility/PullToRefresh.tsx new file mode 100644 index 00000000..f90c9e34 --- /dev/null +++ b/sample-apps/react-demo/app/components/utility/PullToRefresh.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { + type PropsWithChildren, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +const PULL_THRESHOLD = 80; +const RESISTANCE_FACTOR = 0.4; + +type PullToRefreshProps = PropsWithChildren<{ + onRefresh: () => Promise; + disabled?: boolean; +}>; + +export const PullToRefresh = ({ + children, + onRefresh, + disabled, +}: PullToRefreshProps) => { + const containerRef = useRef(null); + const [pullDistance, setPullDistance] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); + const startYRef = useRef(null); + const startXRef = useRef(null); + const isPullingRef = useRef(false); + const isHorizontalSwipeRef = useRef(false); + + const isAtTop = useCallback(() => { + const container = containerRef.current; + if (!container) return false; + + // Check if we're at the top of the scroll container + let scrollContainer: HTMLElement | null = container; + while (scrollContainer && scrollContainer !== document.body) { + const style = getComputedStyle(scrollContainer); + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { + return scrollContainer.scrollTop <= 0; + } + scrollContainer = scrollContainer.parentElement; + } + // Check document scroll + return window.scrollY <= 0; + }, []); + + const handleTouchStart = useCallback( + (e: TouchEvent) => { + if (disabled || isRefreshing || !isAtTop()) return; + startYRef.current = e.touches[0].clientY; + startXRef.current = e.touches[0].clientX; + isPullingRef.current = false; + isHorizontalSwipeRef.current = false; + }, + [disabled, isRefreshing, isAtTop], + ); + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (disabled || isRefreshing || startYRef.current === null || startXRef.current === null) return; + + // If we already detected a horizontal swipe, don't interfere + if (isHorizontalSwipeRef.current) return; + + const currentY = e.touches[0].clientY; + const currentX = e.touches[0].clientX; + const diffY = currentY - startYRef.current; + const diffX = currentX - startXRef.current; + + // Detect if this is a horizontal swipe (more horizontal than vertical movement) + if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 10) { + isHorizontalSwipeRef.current = true; + return; + } + + if (diffY > 0 && isAtTop()) { + isPullingRef.current = true; + // Apply resistance to make pulling feel natural + const distance = Math.min(diffY * RESISTANCE_FACTOR, PULL_THRESHOLD * 1.5); + setPullDistance(distance); + + // Prevent default scrolling when pulling down at top + if (distance > 0) { + e.preventDefault(); + } + } + }, + [disabled, isRefreshing, isAtTop], + ); + + const handleTouchEnd = useCallback(async () => { + if (disabled || !isPullingRef.current) { + startYRef.current = null; + startXRef.current = null; + isHorizontalSwipeRef.current = false; + setPullDistance(0); + return; + } + + startYRef.current = null; + startXRef.current = null; + isPullingRef.current = false; + isHorizontalSwipeRef.current = false; + + if (pullDistance >= PULL_THRESHOLD) { + setIsRefreshing(true); + setPullDistance(PULL_THRESHOLD * 0.5); // Show spinner at half threshold + try { + await onRefresh(); + } finally { + setIsRefreshing(false); + setPullDistance(0); + } + } else { + setPullDistance(0); + } + }, [disabled, pullDistance, onRefresh]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('touchstart', handleTouchStart, { passive: true }); + container.addEventListener('touchmove', handleTouchMove, { passive: false }); + container.addEventListener('touchend', handleTouchEnd); + + return () => { + container.removeEventListener('touchstart', handleTouchStart); + container.removeEventListener('touchmove', handleTouchMove); + container.removeEventListener('touchend', handleTouchEnd); + }; + }, [handleTouchStart, handleTouchMove, handleTouchEnd]); + + const progress = Math.min(pullDistance / PULL_THRESHOLD, 1); + const showIndicator = pullDistance > 10 || isRefreshing; + + return ( +
+
+
+ {!isRefreshing && = 1 ? 180 : 0}deg)`, + opacity: progress, + }} + > + arrow_downward + } +
+
+
+ {children} +
+
+ ); +}; diff --git a/sample-apps/react-demo/app/explore/page.tsx b/sample-apps/react-demo/app/explore/page.tsx index cbc4ef4d..6c9fa6a0 100644 --- a/sample-apps/react-demo/app/explore/page.tsx +++ b/sample-apps/react-demo/app/explore/page.tsx @@ -4,24 +4,27 @@ import { StreamFeed } from '@stream-io/feeds-react-sdk'; import { ActivityList } from '../components/activity/ActivityList'; import { FollowSuggestions } from '../components/FollowSuggestions'; import { SearchInput } from '../components/utility/SearchInput'; +import { PullToRefresh } from '../components/utility/PullToRefresh'; import { useOwnFeedsContext } from '../own-feeds-context'; export default function Explore() { - const { ownForyouFeed, errors } = useOwnFeedsContext(); + const { ownForyouFeed, errors, reloadForyouFeed } = useOwnFeedsContext(); return ( -
-
- -
Follow suggestions
- + +
+
+ +
Follow suggestions
+ +
+ {ownForyouFeed && ( + +
Popular posts
+ +
+ )}
- {ownForyouFeed && ( - -
Popular posts
- -
- )} -
+ ); } diff --git a/sample-apps/react-demo/app/globals.css b/sample-apps/react-demo/app/globals.css index cee5b180..99d9f3e8 100644 --- a/sample-apps/react-demo/app/globals.css +++ b/sample-apps/react-demo/app/globals.css @@ -1,6 +1,24 @@ @import 'tailwindcss'; @plugin "daisyui"; -@import 'material-symbols'; + +/* Material Symbols - font loaded via next/font in layout.tsx */ +.material-symbols-outlined { + font-family: var(--font-material-symbols); + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-feature-settings: "liga"; +} @plugin "daisyui/theme" { name: 'corporate'; diff --git a/sample-apps/react-demo/app/home/page.tsx b/sample-apps/react-demo/app/home/page.tsx index 0c868595..ca68bfe3 100644 --- a/sample-apps/react-demo/app/home/page.tsx +++ b/sample-apps/react-demo/app/home/page.tsx @@ -7,6 +7,7 @@ import { ActivityList } from '../components/activity/ActivityList'; import { OwnStories } from '../components/stories/OwnStories'; import { StoryTimeline } from '../components/stories/StoryTimeline'; import { Avatar } from '../components/utility/Avatar'; +import { PullToRefresh } from '../components/utility/PullToRefresh'; const HomeActivityComposer = () => { const currentUser = useClientConnectedUser(); @@ -22,34 +23,42 @@ const HomeActivityComposer = () => { }; export default function Home() { - const { ownTimeline, ownFeed, ownStoryTimeline, ownStoryFeed, errors } = - useOwnFeedsContext(); + const { + ownTimeline, + ownFeed, + ownStoryTimeline, + ownStoryFeed, + errors, + reloadTimelines, + } = useOwnFeedsContext(); if (!ownTimeline || !ownFeed || !ownStoryTimeline || !ownStoryFeed) { return null; } return ( -
-
- - - - - - -
-
- - + +
+
+ + + + + + +
+
+ + + +
+ +
+
Latest posts
+ +
- -
-
Latest posts
- -
-
-
+ ); } diff --git a/sample-apps/react-demo/app/layout.tsx b/sample-apps/react-demo/app/layout.tsx index af2f29bf..7c2923de 100644 --- a/sample-apps/react-demo/app/layout.tsx +++ b/sample-apps/react-demo/app/layout.tsx @@ -3,6 +3,13 @@ import './globals.css'; import { ErrorBoundary } from './components/utility/ErrorBoundary'; import { ClientApp } from './ClientApp'; import { Suspense } from 'react'; +import localFont from 'next/font/local'; + +const materialSymbols = localFont({ + src: '../../../node_modules/material-symbols/material-symbols-outlined.woff2', + variable: '--font-material-symbols', + display: 'block', +}); export const metadata: Metadata = { title: 'Stream Feeds React Demo', @@ -15,7 +22,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + Something went wrong. Try reloading the page.
} diff --git a/sample-apps/react-demo/app/own-feeds-context.tsx b/sample-apps/react-demo/app/own-feeds-context.tsx index 82b71fa6..762d9125 100644 --- a/sample-apps/react-demo/app/own-feeds-context.tsx +++ b/sample-apps/react-demo/app/own-feeds-context.tsx @@ -32,6 +32,8 @@ type OwnFeedsContextValue = { ownForyouFeed: Feed | undefined; errors: OwnFeedsErrors; reloadTimelines: () => Promise; + reloadForyouFeed: () => Promise; + reloadOwnFeed: () => Promise; }; const defaultErrors: OwnFeedsErrors = { @@ -52,6 +54,8 @@ const OwnFeedsContext = createContext({ ownForyouFeed: undefined, errors: defaultErrors, reloadTimelines: () => Promise.resolve(), + reloadForyouFeed: () => Promise.resolve(), + reloadOwnFeed: () => Promise.resolve(), }); export const OwnFeedsContextProvider = ({ children }: PropsWithChildren) => { @@ -157,6 +161,30 @@ export const OwnFeedsContextProvider = ({ children }: PropsWithChildren) => { ]); }, [ownTimeline, ownStoryTimeline]); + const reloadForyouFeed = useCallback(async () => { + // Reset error before reloading + setErrors((prev) => ({ + ...prev, + ownForyouFeed: undefined, + })); + + await ownForyouFeed?.getOrCreate({ limit: 10 }).catch((error: Error) => { + setErrors((prev) => ({ ...prev, ownForyouFeed: error })); + }); + }, [ownForyouFeed]); + + const reloadOwnFeed = useCallback(async () => { + // Reset error before reloading + setErrors((prev) => ({ + ...prev, + ownFeed: undefined, + })); + + await ownFeed?.getOrCreate({ watch: true }).catch((error: Error) => { + setErrors((prev) => ({ ...prev, ownFeed: error })); + }); + }, [ownFeed]); + return ( { ownForyouFeed, errors, reloadTimelines, + reloadForyouFeed, + reloadOwnFeed, }} > {children} diff --git a/sample-apps/react-demo/app/profile/[id]/page.tsx b/sample-apps/react-demo/app/profile/[id]/page.tsx index aefae938..2e9df2dd 100644 --- a/sample-apps/react-demo/app/profile/[id]/page.tsx +++ b/sample-apps/react-demo/app/profile/[id]/page.tsx @@ -11,8 +11,10 @@ import { useOwnFeedsContext } from '../../own-feeds-context'; import { Avatar } from '../../components/utility/Avatar'; import { NavLink } from '../../components/utility/NavLink'; import { ActivityList } from '../../components/activity/ActivityList'; +import { PullToRefresh } from '../../components/utility/PullToRefresh'; import { useParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { ToggleFollowButton } from '@/app/components/ToggleFollowButton'; const userFeedSelector = (state: FeedState) => ({ // Don't count your own timeline in following feeds @@ -30,10 +32,11 @@ export default function Profile() { const userId = useParams<{ id: string }>().id; const currentUser = useClientConnectedUser(); const client = useFeedsClient(); - const { ownFeed, ownTimeline, errors } = useOwnFeedsContext(); + const { ownFeed, ownTimeline, errors, reloadOwnFeed } = useOwnFeedsContext(); const [feed, setFeed] = useState(); const [timeline, setTimeline] = useState(); const [error, setError] = useState(undefined); + const isOwnProfile = userId === currentUser?.id; useEffect(() => { if (!userId || !client || !currentUser?.id) { @@ -42,7 +45,7 @@ export default function Profile() { setError(undefined); return; } - if (userId === currentUser?.id) { + if (isOwnProfile) { setFeed(ownFeed); setError(errors?.ownFeed); setTimeline(ownTimeline); @@ -57,7 +60,27 @@ export default function Profile() { setFeed(_feed); setTimeline(_timeline); } - }, [userId, currentUser?.id, client, ownFeed, ownTimeline, errors.ownFeed]); + }, [userId, currentUser?.id, client, ownFeed, ownTimeline, errors.ownFeed, isOwnProfile]); + + const reloadOtherUserFeed = useCallback(async () => { + if (!feed) return; + setError(undefined); + await feed.getOrCreate().catch((e) => { + setError(e); + }); + }, [feed]); + + const handleRefresh = useCallback(async () => { + if (isOwnProfile) { + await reloadOwnFeed(); + } else { + await reloadOtherUserFeed(); + } + }, [isOwnProfile, reloadOwnFeed, reloadOtherUserFeed]); + + const shouldShowBookmarks = currentUser?.id === userId; + + const shouldShowToggleFollow = currentUser?.id !== userId; const { followerCount, activityCount, user } = useStateStore( feed?.state, @@ -74,36 +97,44 @@ export default function Profile() { }; return ( -
-
- -
{user?.name}
-
-
-
-
Posts
-
{activityCount}
+ +
+
+ +
{user?.name}
-
-
Followers
-
{followerCount}
+
+
+
Posts
+
{activityCount}
+
+
+
Followers
+
{followerCount}
+
+
+
Following
+
{followingCount}
+
-
-
Following
-
{followingCount}
+
+ {shouldShowBookmarks && ( + + bookmark + Bookmarks + + )}
+ {shouldShowToggleFollow && } + {feed && ( + + + + )}
-
- - bookmark - Bookmarks - -
- {feed && ( - - - - )} -
+ ); } diff --git a/sample-apps/react-demo/app/utility/useImagePreloader.ts b/sample-apps/react-demo/app/utility/useImagePreloader.ts new file mode 100644 index 00000000..733b67ed --- /dev/null +++ b/sample-apps/react-demo/app/utility/useImagePreloader.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from 'react'; + +export const buildImageUrl = ( + baseUrl: string | undefined | null, + width: number, + height: number, +): string | null => { + if (!baseUrl) return null; + const separator = baseUrl.includes('?') ? '&' : '?'; + return `${baseUrl}${separator}w=${width}&h=${height}`; +}; + +export const useImagePreloader = (urls: Array) => { + const preloadedRef = useRef>(new Set()); + + useEffect(() => { + urls.forEach((url) => { + if (url && !preloadedRef.current.has(url)) { + const img = new Image(); + img.src = url; + preloadedRef.current.add(url); + } + }); + }, [urls]); +}; diff --git a/sample-apps/react-demo/app/utility/user-id-to-name.ts b/sample-apps/react-demo/app/utility/userIdToName.ts similarity index 58% rename from sample-apps/react-demo/app/utility/user-id-to-name.ts rename to sample-apps/react-demo/app/utility/userIdToName.ts index 4578c198..e4e81792 100644 --- a/sample-apps/react-demo/app/utility/user-id-to-name.ts +++ b/sample-apps/react-demo/app/utility/userIdToName.ts @@ -1,3 +1,3 @@ -export const userIdToUserName = (str: string) => { +export const userIdToName = (str: string) => { return str.replaceAll('-', ' ').charAt(0).toUpperCase() + str.slice(1); -}; \ No newline at end of file +}; diff --git a/test-data-generator/src/create-follows.ts b/test-data-generator/src/create-follows.ts index 3a52eb32..801133f1 100644 --- a/test-data-generator/src/create-follows.ts +++ b/test-data-generator/src/create-follows.ts @@ -1,4 +1,4 @@ -import { StreamClient } from '@stream-io/node-sdk'; +import { type FollowRequest, StreamClient } from '@stream-io/node-sdk'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -44,7 +44,9 @@ async function main(): Promise { const client = new StreamClient(key, secret, { basePath: url }); console.log(`Creating follow relationships for ${users.length} users...`); - console.log(`Each user will follow ${MIN_FOLLOW_RATIO * 100}-${MAX_FOLLOW_RATIO * 100}% of other users`); + console.log( + `Each user will follow ${MIN_FOLLOW_RATIO * 100}-${MAX_FOLLOW_RATIO * 100}% of other users`, + ); let totalFollows = 0; @@ -61,13 +63,14 @@ async function main(): Promise { const usersToFollow = shuffledUsers.slice(0, followCount); // Create follows for both user and story feeds - const follows: Array<{ source: string; target: string }> = []; + const follows: FollowRequest[] = []; for (const target of usersToFollow) { // Timeline follows user feed (for posts) follows.push({ source: `timeline:${follower.id}`, target: `user:${target.id}`, + create_notification_activity: true, }); // Stories feed follows story feed (for stories) diff --git a/test-data-generator/src/create-posts.ts b/test-data-generator/src/create-posts.ts index d2e588a8..037ee88b 100644 --- a/test-data-generator/src/create-posts.ts +++ b/test-data-generator/src/create-posts.ts @@ -1,4 +1,8 @@ -import { StreamClient, type Attachment, type ActivityRequest } from '@stream-io/node-sdk'; +import { + StreamClient, + type Attachment, + type ActivityRequest, +} from '@stream-io/node-sdk'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -274,6 +278,7 @@ async function main(): Promise { type: reactionType, activity_id: createdActivity.activity.id, user_id: reactingUser.id, + create_notification_activity: true, }); } } @@ -290,6 +295,7 @@ async function main(): Promise { object_id: createdActivity.activity.id, object_type: 'activity', user_id: commentingUser.id, + create_notification_activity: true, }); } } diff --git a/test-data-generator/users.json b/test-data-generator/users.json index 9be06127..8ca1e295 100644 --- a/test-data-generator/users.json +++ b/test-data-generator/users.json @@ -58,5 +58,17 @@ "name": "David", "image": "https://randomuser.me/api/portraits/men/62.jpg", "visibility_level": "visible" + }, + { + "id": "nina", + "name": "Nina", + "image": "https://randomuser.me/api/portraits/women/33.jpg", + "visibility_level": "visible" + }, + { + "id": "james", + "name": "James", + "image": "https://randomuser.me/api/portraits/men/32.jpg", + "visibility_level": "visible" } ]