diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index c19c8ce..096d28f 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -1,13 +1,22 @@ -import * as React from "react"; +import { + createContext, + useMemo, + useEffect, + useCallback, + useState, + useContext, + type ReactNode, +} from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useRouterState } from "@tanstack/react-router"; +import type { InfiniteData } from "@tanstack/react-query"; import type { LiveMessage, RawMessage, RawMessages } from "./values"; import { useCrypto } from "@tensamin/crypto/context"; import { useUser } from "@tensamin/user/context"; import { useStorage } from "@tensamin/storage/context"; import { useSocket } from "@tensamin/ttp/context"; -export const context = React.createContext(undefined); +export const context = createContext(undefined); const queryClient = new QueryClient(); @@ -16,27 +25,25 @@ const queryClient = new QueryClient(); * @param props Parameter props. * @returns unknown. */ -export default function Provider(props: { children: React.ReactNode }) { +export default function Provider(props: { children: ReactNode }) { const { getSharedSecret } = useCrypto(); const { get } = useUser(); const { load } = useStorage(); - const { send } = useSocket(); + const { send, subscribePush } = useSocket(); - const [liveMessagesState, setLiveMessagesState] = React.useState< - LiveMessage[] - >([]); - const [currentSharedSecret, setCurrentSharedSecret] = React.useState(""); + const [liveMessagesState, setLiveMessagesState] = useState([]); + const [currentSharedSecret, setCurrentSharedSecret] = useState(""); const locationSearch = useRouterState({ select: (state) => state.location.search, }); - const userIdValue = React.useMemo(() => { + const userIdValue = useMemo(() => { const rawId = (locationSearch as unknown as { id?: unknown })?.id; return Number(rawId ?? 0); }, [locationSearch]); - React.useEffect(() => { + useEffect(() => { const recipientId = userIdValue; if (!recipientId) { @@ -73,7 +80,7 @@ export default function Provider(props: { children: React.ReactNode }) { }; }, [get, getSharedSecret, load, userIdValue]); - const customGetMessages = React.useCallback( + const customGetMessages = useCallback( async (amount: number, offset: number) => { const messages = await send("messages_get", { amount, @@ -92,7 +99,7 @@ export default function Provider(props: { children: React.ReactNode }) { [send, userIdValue], ); - const addLiveMessage = React.useCallback((message: RawMessage) => { + const addLiveMessage = useCallback((message: RawMessage) => { const localId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -119,11 +126,91 @@ export default function Provider(props: { children: React.ReactNode }) { }; }, []); - const clearLiveMessages = React.useCallback(() => { + const clearLiveMessages = useCallback(() => { setLiveMessagesState([]); }, []); - const value = React.useMemo( + // Get live updates for message states + useEffect(() => { + return subscribePush((message) => { + if (message.type !== "message_state") { + return; + } + + const nextState = message.data as { + chat_partner_id: number; + timestamp: number; + message_state: RawMessage["message_state"]; + }; + + if (nextState.chat_partner_id !== userIdValue) { + return; + } + + setLiveMessagesState((prev) => { + let updated = false; + + const mapped = prev.map((liveMessage) => { + if (liveMessage.timestamp !== nextState.timestamp) { + return liveMessage; + } + + if (liveMessage.message_state === nextState.message_state) { + return liveMessage; + } + + updated = true; + return { + ...liveMessage, + message_state: nextState.message_state, + }; + }); + + return updated ? mapped : prev; + }); + + const queryKey = ["chat-messages", String(userIdValue)] as const; + queryClient.setQueryData>( + queryKey, + (current) => { + if (!current) { + return current; + } + + let updated = false; + + const pages = current.pages.map((page) => + page.map((historicalMessage) => { + if (historicalMessage.timestamp !== nextState.timestamp) { + return historicalMessage; + } + + if (historicalMessage.message_state === nextState.message_state) { + return historicalMessage; + } + + updated = true; + return { + ...historicalMessage, + message_state: nextState.message_state, + }; + }), + ); + + if (!updated) { + return current; + } + + return { + ...current, + pages, + }; + }, + ); + }); + }, [subscribePush, userIdValue]); + + const value = useMemo( () => ({ getMessages: customGetMessages, liveMessages: () => liveMessagesState, @@ -166,7 +253,7 @@ type contextType = { * @returns contextType. */ export function useChat(): contextType { - const ctx = React.useContext(context); + const ctx = useContext(context); if (!ctx) { throw new Error("useChat must be used within a ChatProvider"); } diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index a0a4791..8558329 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -188,7 +188,7 @@ export default function Screen() { return (
; export type Socket = typeof socket; diff --git a/packages/ttp/src/context.tsx b/packages/ttp/src/context.tsx index ce9d2f9..003c0a3 100644 --- a/packages/ttp/src/context.tsx +++ b/packages/ttp/src/context.tsx @@ -12,6 +12,7 @@ import { useCrypto } from "@tensamin/crypto/context"; import { log } from "@tensamin/shared/log"; import { useStorage } from "@tensamin/storage/context"; import { createTransportClient, READY_STATE, type BoundSendFn } from "./core"; +import type { PushHandler } from "./core"; import { PING_INTERVAL, RECONNECT_RESET, @@ -132,6 +133,7 @@ function getProtocolErrorDetails(error: unknown) { type ContextType = { send: BoundSendFn; + subscribePush: (handler: PushHandler) => () => void; readyState: () => number; ownPing: () => number; iotaPing: () => number; @@ -202,6 +204,21 @@ export default function Provider(props: { [], ); + /** + * Subscribes to unsolicited push events from the active transport client. + * @param handler Callback invoked for each push message. + * @returns Unsubscribe function. + */ + const subscribePush = useCallback((handler: PushHandler) => { + const client = clientRef.current; + + if (!client) { + return () => {}; + } + + return client.subscribePush(handler); + }, []); + useEffect(() => { if (!connected || !identified) { return; @@ -536,12 +553,13 @@ export default function Provider(props: { const contextValue = useMemo( () => ({ send, + subscribePush, readyState: () => readyState, ownPing: () => ownPing, iotaPing: () => iotaPing, identified: () => identified, }), - [identified, iotaPing, ownPing, readyState, send], + [identified, iotaPing, ownPing, readyState, send, subscribePush], ); if (error !== "" && errorDescription !== "") { diff --git a/packages/ui/src/cmp/sonner.tsx b/packages/ui/src/cmp/sonner.tsx index 7cbe96b..071e8f2 100644 --- a/packages/ui/src/cmp/sonner.tsx +++ b/packages/ui/src/cmp/sonner.tsx @@ -26,9 +26,9 @@ const Toaster = ({ ...props }: ToasterProps) => { }} style={ { - "--normal-bg": "var(--popover)", - "--normal-text": "var(--popover-foreground)", - "--normal-border": "var(--border)", + "--normal-bg": "var(--color-popover)", + "--normal-text": "var(--color-popover-foreground)", + "--normal-border": "var(--color-border)", "--border-radius": "var(--radius)", } as React.CSSProperties }