diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index aff301bc3..5f343bd34 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat'; import { Channel, @@ -11,7 +11,8 @@ import { Window, useCreateChatClient, ThreadList, - Views, + ChatView, + useChannelStateContext, } from 'stream-chat-react'; import '@stream-io/stream-chat-css/dist/v2/css/index.css'; @@ -27,7 +28,7 @@ const filters: ChannelFilters = { members: { $in: [userId] }, type: 'messaging', }; -const options: ChannelOptions = { limit: 10, presence: true, state: true }; +const options: ChannelOptions = { limit: 4, presence: true, state: true }; const sort: ChannelSort = { last_message_at: -1, updated_at: -1 }; type LocalAttachmentType = Record; @@ -52,6 +53,12 @@ type StreamChatGenerics = { userType: LocalUserType; }; +const C = () => { + const { channel } = useChannelStateContext(); + + return ; +}; + const App = () => { const chatClient = useCreateChatClient({ apiKey, @@ -59,15 +66,27 @@ const App = () => { userData: { id: userId }, }); + // const channel = useMemo(() => { + // if (!chatClient) return; + + // const c = chatClient.channel('messaging', 'random-channel-2', { + // members: ['john', 'marco', 'mark'], + // name: 'Random 1', + // }); + // c.updatePartial({ set: { name: 'Random 2' } }); + // return c + // }, [chatClient]); + if (!chatClient) return <>Loading...; return ( - - - + + + + @@ -75,14 +94,14 @@ const App = () => { - - + + - + - - - + + + ); }; diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 027d52f8d..e0cd213fd 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -106,7 +106,7 @@ body, } } -.str-chat__views { +.str-chat__chat-view { display: flex; width: 100%; height: 100%; @@ -115,9 +115,18 @@ body, display: flex; flex-direction: column; list-style: none; + margin: unset; + padding-inline: 8px; + padding-block: 16px; + gap: 20px; + + & button { + display: flex; + padding-inline: 19px; + } } - &__channel { + &__channels { display: flex; flex-grow: 1; } diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx new file mode 100644 index 000000000..c17c50007 --- /dev/null +++ b/src/components/ChatView/ChatView.tsx @@ -0,0 +1,140 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { ThreadProvider } from '../Threads'; + +import type { PropsWithChildren } from 'react'; +import type { Thread } from 'stream-chat'; + +const availableChatViews = ['channels', 'threads'] as const; + +type ChatViewContextValue = { + activeChatView: typeof availableChatViews[number]; + setActiveChatView: (cv: ChatViewContextValue['activeChatView']) => void; +}; + +const ChatViewContext = createContext({ + activeChatView: 'channels', + setActiveChatView: () => undefined, +}); + +export const ChatView = ({ children }: PropsWithChildren) => { + const [activeChatView, setActiveChatView] = useState( + 'channels', + ); + + const value = useMemo(() => ({ activeChatView, setActiveChatView }), [activeChatView]); + + return ( + +
{children}
+
+ ); +}; + +const ChannelsView = ({ children }: PropsWithChildren) => { + const { activeChatView } = useContext(ChatViewContext); + + if (activeChatView !== 'channels') return null; + + return
{children}
; +}; + +export type ThreadsViewContextValue = { + activeThread: Thread | undefined; + setActiveThread: (cv: ThreadsViewContextValue['activeThread']) => void; +}; + +const ThreadsViewContext = createContext({ + activeThread: undefined, + setActiveThread: () => undefined, +}); + +export const useThreadsViewContext = () => useContext(ThreadsViewContext); + +const ThreadsView = ({ children }: PropsWithChildren) => { + const { activeChatView } = useContext(ChatViewContext); + const [activeThread, setActiveThread] = useState( + undefined, + ); + + const value = useMemo(() => ({ activeThread, setActiveThread }), [activeThread]); + + if (activeChatView !== 'threads') return null; + + return ( + +
{children}
+
+ ); +}; + +// thread business logic that's impossible to keep within client but encapsulated for ease of use +const useThreadBl = ({ thread }: { thread?: Thread }) => { + useEffect(() => { + if (!thread) return; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && document.hasFocus()) { + thread.activate(); + thread.markAsRead(); + } + if (document.visibilityState === 'hidden' || !document.hasFocus()) { + thread.deactivate(); + } + }; + + handleVisibilityChange(); + + window.addEventListener('focus', handleVisibilityChange); + window.addEventListener('blur', handleVisibilityChange); + return () => { + thread.deactivate(); + window.addEventListener('blur', handleVisibilityChange); + window.removeEventListener('focus', handleVisibilityChange); + }; + }, [thread]); +}; +// ThreadList under View.Threads context, will access setting function and on item click will set activeThread +// which can be accessed for the ease of use by ThreadAdapter which forwards it to required ThreadProvider +// ThreadList can easily live without this context and click handler can be overriden, ThreadAdapter is then no longer needed +/** + * // this setup still works + * const MyCustomComponent = () => { + * const [activeThread, setActiveThread] = useState(); + * + * return <> + * // simplified + * + * + * + * + * + * } + * + */ +const ThreadAdapter = ({ children }: PropsWithChildren) => { + const { activeThread } = useThreadsViewContext(); + + useThreadBl({ thread: activeThread }); + + return {children}; +}; + +const ChatViewSelector = () => { + const { setActiveChatView } = useContext(ChatViewContext); + + return ( +
    +
  • + +
  • +
  • + +
  • +
+ ); +}; + +ChatView.Channels = ChannelsView; +ChatView.Threads = ThreadsView; +ChatView.ThreadAdapter = ThreadAdapter; +ChatView.Selector = ChatViewSelector; diff --git a/src/components/ChatView/index.tsx b/src/components/ChatView/index.tsx new file mode 100644 index 000000000..c9582c20c --- /dev/null +++ b/src/components/ChatView/index.tsx @@ -0,0 +1 @@ +export * from './ChatView'; diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index a21df4169..bb4ac93db 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -73,7 +73,12 @@ export const Thread = < }; const selector = (nextValue: InferStoreValueType) => - [nextValue.latestReplies, nextValue.loadingPreviousPage, nextValue.parentMessage] as const; + [ + nextValue.latestReplies, + nextValue.loadingPreviousPage, + nextValue.parentMessage, + nextValue.read, + ] as const; const ThreadInner = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, @@ -133,9 +138,33 @@ const ThreadInner = < loadMoreThread(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (!thread && !parentMessage) return null; + }, [thread, loadMoreThread]); + + const threadProps: Pick< + VirtualizedMessageListProps, + | 'hasMoreNewer' + | 'loadMoreNewer' + | 'loadingMoreNewer' + | 'hasMore' + | 'loadMore' + | 'loadingMore' + | 'messages' + > = threadInstance.channel + ? { + loadingMore: loadingPreviousPage, + loadMore: threadInstance.loadPreviousPage, + messages: latestReplies, + } + : { + hasMore: threadHasMore, + loadingMore: threadLoadingMore, + loadMore: loadMoreThread, + messages: threadMessages, + }; + + const messageAsThread = thread ?? parentMessage; + + if (!messageAsThread) return null; const threadClass = customClasses?.thread || @@ -146,8 +175,8 @@ const ThreadInner = < const head = ( @@ -155,18 +184,17 @@ const ThreadInner = < return (
- + void; -}; - -const ViewsContext = createContext({ - activeView: 'channel', - setActiveView: () => undefined, -}); - -export const Views = ({ children }: PropsWithChildren) => { - const [activeView, setActiveView] = useState('channel'); - - const value = useMemo(() => ({ activeView, setActiveView }), [activeView]); - - return ( - -
{children}
-
- ); -}; - -const ChannelView = ({ children }: PropsWithChildren) => { - const { activeView } = useContext(ViewsContext); - - if (activeView !== 'channel') return null; - - return
{children}
; -}; - -export type ThreadsViewContextValue = { - activeThread: Thread | undefined; - setActiveThread: (cv: ThreadsViewContextValue['activeThread']) => void; -}; - -const ThreadsViewContext = createContext({ - activeThread: undefined, - setActiveThread: () => undefined, -}); - -export const useThreadsViewContext = () => useContext(ThreadsViewContext); - -const ThreadsView = ({ children }: PropsWithChildren) => { - const { activeView } = useContext(ViewsContext); - const [activeThread, setActiveThread] = useState( - undefined, - ); - - const value = useMemo(() => ({ activeThread, setActiveThread }), [activeThread]); - - if (activeView !== 'threads') return null; - - return ( - -
{children}
-
- ); -}; - -const ThreadAdapter = ({ children }: PropsWithChildren) => { - const { activeThread } = useThreadsViewContext(); - - useEffect(() => { - activeThread?.markAsRead(); - }, [activeThread]); - - return {children}; -}; - -const ViewSelector = () => { - const { setActiveView } = useContext(ViewsContext); - - return ( -
    -
  • - -
  • -
  • - -
  • -
- ); -}; - -Views.Channel = ChannelView; -Views.Threads = ThreadsView; -Views.Selector = ViewSelector; -Views.ThreadAdapter = ThreadAdapter; diff --git a/src/components/Views/index.tsx b/src/components/Views/index.tsx deleted file mode 100644 index 0de4db43e..000000000 --- a/src/components/Views/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './Views'; diff --git a/src/components/index.ts b/src/components/index.ts index 60bbe59fb..c6bef9f87 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -32,7 +32,7 @@ export * from './TypingIndicator'; export * from './UserItem'; export * from './Window'; export * from './Threads'; -export * from './Views'; +export * from './ChatView'; export { UploadButton } from './ReactFileUtilities'; export type { UploadButtonProps } from './ReactFileUtilities';