From d60dd9b64d4303b8cfa0e79bffa7cb4b2a54386f Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Mon, 10 Jun 2024 14:13:11 +0200 Subject: [PATCH] Unread badges and loadUnreadThreads button \w icon --- examples/vite/src/App.tsx | 36 ++++---- examples/vite/src/index.scss | 92 ++++--------------- package.json | 2 +- src/components/MessageList/MessageList.tsx | 9 +- src/components/Threads/ThreadContext.tsx | 2 +- .../Threads/ThreadList/ThreadList.tsx | 60 ++++++++---- .../Threads/ThreadList/ThreadListItem.tsx | 75 ++++++++++++--- src/components/Threads/icons.tsx | 37 ++++++++ yarn.lock | 7 +- 9 files changed, 184 insertions(+), 136 deletions(-) create mode 100644 src/components/Threads/icons.tsx diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index adfd3e738..4c37eed94 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -13,7 +13,7 @@ import { ThreadList, ThreadProvider, } from 'stream-chat-react'; -import 'stream-chat-react/css/v2/index.css'; +import '@stream-io/stream-chat-css/dist/v2/css/index.css'; const params = (new Proxy(new URLSearchParams(window.location.search), { get: (searchParams, property) => searchParams.get(property as string), @@ -65,20 +65,22 @@ const App = () => { return ( - {!threadOnly && ( - <> - - - - - - - - - - - )} - {threadOnly && } +
+ {!threadOnly && ( + <> + + + + + + + + + + + )} + {threadOnly && } +
); }; @@ -87,8 +89,8 @@ const Threads = () => { const [state, setState] = useState(undefined); return ( -
- setState(thread)} /> +
+ setState(t) }} /> diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index fee29480a..16d8526fc 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -16,70 +16,10 @@ body, display: flex; height: 100%; - // .str-chat__thread-list { - // width: 50%; - // height: 100%; - // } - - .str-chat__thread-list-item { - all: unset; - box-sizing: border-box; - padding-block: 14px; - padding-inline: 8px; - gap: 6px; + & > div.str-chat { + height: 100%; width: 100%; display: flex; - flex-direction: column; - cursor: pointer; - } - - .str-chat__thread-list-item__channel { - font-size: 14px; - font-weight: 400; - } - - .str-chat__thread-list-item__parent-message { - font-size: 12px; - font-weight: 400; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - .str-chat__thread-list-item__latest-reply-container { - display: flex; - align-items: center; - gap: 5px; - } - - .str-chat__thread-list-item__latest-reply-details { - display: flex; - flex-direction: column; - flex-grow: 1; - gap: 4px; - width: 0; - } - - .str-chat__thread-list-item__latest-reply-created-by { - font-weight: 500; - font-size: 16px; - } - - .str-chat__thread-list-item__latest-reply-text { - display: flex; - font-size: 14px; - font-weight: 400; - justify-content: space-between; - - & > div:first-child { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - & > div:last-child { - white-space: nowrap; - } } .str-chat__channel-list { @@ -122,18 +62,6 @@ body, } } - .str-chat.threads { - display: flex; - height: 100%; - width: 100%; - - .vml { - display: flex; - flex-direction: column; - width: 70%; - } - } - @media screen and (min-width: 768px) { //.str-chat__channel-list.thread-open { // &.menu-open { @@ -177,4 +105,18 @@ body, display: none; } } -} \ No newline at end of file +} + +.str-chat__threads { + display: flex; + width: 100%; + + .str-chat__thread { + width: 100%; + } + + .str-chat__thread-list { + height: 100%; + max-width: 420px; + } +} diff --git a/package.json b/package.json index 92868573f..97b6878e7 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,7 @@ "@semantic-release/changelog": "^6.0.2", "@semantic-release/git": "^10.0.1", "@stream-io/rollup-plugin-node-builtins": "^2.1.5", - "@stream-io/stream-chat-css": "^4.16.1", + "@stream-io/stream-chat-css": "link:../stream-chat-css/", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^13.1.1", "@testing-library/react-hooks": "^8.0.0", diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 0634560cb..080690da2 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -225,18 +225,13 @@ const MessageListWithContext = < )}
{showEmptyStateIndicator ? ( - + ) : ( { const thread = useContext(ThreadContext); const placeholder = useMemo( - () => new Thread({ client, registerEventHandlers: false, threadData: {} }), + () => new Thread({ client, registerSubscriptions: false, threadData: {} }), [client], ); diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx index 4b67d465a..d4b5ef6ba 100644 --- a/src/components/Threads/ThreadList/ThreadList.tsx +++ b/src/components/Threads/ThreadList/ThreadList.tsx @@ -1,10 +1,11 @@ import React, { useEffect } from 'react'; -import { ComputeItemKey, Virtuoso } from 'react-virtuoso'; +import { ComputeItemKey, Virtuoso, VirtuosoProps } from 'react-virtuoso'; import type { ComponentType, PointerEvent } from 'react'; import type { InferStoreValueType, Thread, ThreadManager } from 'stream-chat'; import { ThreadListItem } from './ThreadListItem'; +import { Icon } from '../icons'; import { useChatContext } from '../../../context'; import { useSimpleStateStore } from '../hooks/useSimpleStateStore'; @@ -23,40 +24,63 @@ import type { ThreadListItemProps } from './ThreadListItem'; * - probably good idea to move component context up to a Chat component */ -const selector = (nextValue: InferStoreValueType) => [nextValue.threads] as const; +const selector = (nextValue: InferStoreValueType) => + [nextValue.unreadThreads.newIds, nextValue.threads] as const; const computeItemKey: ComputeItemKey = (_, item) => item.id; type ThreadListProps = { - onItemPointerDown?: (event: PointerEvent, thread: Thread) => void; ThreadListItem?: ComponentType; - // threads?: Thread[] + threadListItemProps?: Omit & { + onPointerDown?: (event: PointerEvent, thread: Thread) => void; + }; + virtuosoProps?: VirtuosoProps; }; export const ThreadList = ({ ThreadListItem: PropsThreadListItem = ThreadListItem, - onItemPointerDown, + virtuosoProps, + threadListItemProps: { onPointerDown, ...restThreadListItemProps } = {}, }: ThreadListProps) => { const { client } = useChatContext(); - const [threads] = useSimpleStateStore(client.threads.state, selector); + const [unreadThreadIds, threads] = useSimpleStateStore(client.threads.state, selector); useEffect(() => { client.threads.loadNextPage(); }, [client]); return ( - atBottom && client.threads.loadNextPage()} - className='str-chat str-chat__thread-list' - computeItemKey={computeItemKey} - data={threads} - itemContent={(_, thread) => ( - onItemPointerDown?.(e, thread)} - thread={thread} - /> +
+ {/* TODO: create a replaceable banner component, wait for BE to support "in" keyword for query threads */} + {/* TODO: use query threads with limit (unreadThreadsId.length) - should be top of the list, and prepend + - this does not work when we reply to an non-loaded thread and then reply to a loaded thread + - querying afterwards will return only the latest, which was already in the list but not the one we need + */} + {unreadThreadIds.length > 0 && ( +
+ {unreadThreadIds.length} unread threads + +
)} - style={{ height: '100%', width: '50%' }} - /> + atBottom && client.threads.loadNextPage()} + className='str-chat__thread-list' + computeItemKey={computeItemKey} + data={threads} + itemContent={(_, thread) => ( + onPointerDown?.(e, thread)} + thread={thread} + {...restThreadListItemProps} + /> + )} + {...virtuosoProps} + /> +
); }; diff --git a/src/components/Threads/ThreadList/ThreadListItem.tsx b/src/components/Threads/ThreadList/ThreadListItem.tsx index 9713edbb9..95d41be43 100644 --- a/src/components/Threads/ThreadList/ThreadListItem.tsx +++ b/src/components/Threads/ThreadList/ThreadListItem.tsx @@ -1,15 +1,17 @@ import React from 'react'; -import type { ComponentProps, ComponentType } from 'react'; +import type { ComponentPropsWithoutRef, ComponentType } from 'react'; import type { InferStoreValueType, Thread } from 'stream-chat'; import { useSimpleStateStore } from '../hooks/useSimpleStateStore'; import { Avatar } from '../../Avatar'; +import { Icon } from '../icons'; +import { useChatContext } from '../../../context'; export type ThreadListItemProps = { thread: Thread; ThreadListItemUi?: ComponentType; -} & ComponentProps<'button'>; +} & ComponentPropsWithoutRef<'button'>; export type ThreadListItemUiProps = Omit; @@ -19,41 +21,65 @@ export type ThreadListItemUiProps = Omit) => - [nextValue.latestReplies.at(-1), nextValue.parentMessage, nextValue.channelData] as const; + [ + nextValue.latestReplies.at(-1), + nextValue.read, + nextValue.parentMessage, + nextValue.channelData, + ] as const; export const ThreadListItemUi = ({ thread, ...rest }: ThreadListItemUiProps) => { - const [latestReply, parentMessage, channelData] = useSimpleStateStore(thread.state, selector); + const { client } = useChatContext(); + const [latestReply, read, parentMessage, channelData] = useSimpleStateStore( + thread.state, + selector, + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const unreadMessagesCount = read[client.user!.id]?.unread_messages ?? 0; return (