Added live message_state updates, fixed sonner theme

This commit is contained in:
Alois 2026-04-03 00:03:24 +02:00
commit 7ebe028629
5 changed files with 138 additions and 21 deletions

View file

@ -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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRouterState } from "@tanstack/react-router"; import { useRouterState } from "@tanstack/react-router";
import type { InfiniteData } from "@tanstack/react-query";
import type { LiveMessage, RawMessage, RawMessages } from "./values"; import type { LiveMessage, RawMessage, RawMessages } from "./values";
import { useCrypto } from "@tensamin/crypto/context"; import { useCrypto } from "@tensamin/crypto/context";
import { useUser } from "@tensamin/user/context"; import { useUser } from "@tensamin/user/context";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useSocket } from "@tensamin/ttp/context"; import { useSocket } from "@tensamin/ttp/context";
export const context = React.createContext<contextType | undefined>(undefined); export const context = createContext<contextType | undefined>(undefined);
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -16,27 +25,25 @@ const queryClient = new QueryClient();
* @param props Parameter props. * @param props Parameter props.
* @returns unknown. * @returns unknown.
*/ */
export default function Provider(props: { children: React.ReactNode }) { export default function Provider(props: { children: ReactNode }) {
const { getSharedSecret } = useCrypto(); const { getSharedSecret } = useCrypto();
const { get } = useUser(); const { get } = useUser();
const { load } = useStorage(); const { load } = useStorage();
const { send } = useSocket(); const { send, subscribePush } = useSocket();
const [liveMessagesState, setLiveMessagesState] = React.useState< const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
LiveMessage[] const [currentSharedSecret, setCurrentSharedSecret] = useState("");
>([]);
const [currentSharedSecret, setCurrentSharedSecret] = React.useState("");
const locationSearch = useRouterState({ const locationSearch = useRouterState({
select: (state) => state.location.search, select: (state) => state.location.search,
}); });
const userIdValue = React.useMemo(() => { const userIdValue = useMemo(() => {
const rawId = (locationSearch as unknown as { id?: unknown })?.id; const rawId = (locationSearch as unknown as { id?: unknown })?.id;
return Number(rawId ?? 0); return Number(rawId ?? 0);
}, [locationSearch]); }, [locationSearch]);
React.useEffect(() => { useEffect(() => {
const recipientId = userIdValue; const recipientId = userIdValue;
if (!recipientId) { if (!recipientId) {
@ -73,7 +80,7 @@ export default function Provider(props: { children: React.ReactNode }) {
}; };
}, [get, getSharedSecret, load, userIdValue]); }, [get, getSharedSecret, load, userIdValue]);
const customGetMessages = React.useCallback( const customGetMessages = useCallback(
async (amount: number, offset: number) => { async (amount: number, offset: number) => {
const messages = await send("messages_get", { const messages = await send("messages_get", {
amount, amount,
@ -92,7 +99,7 @@ export default function Provider(props: { children: React.ReactNode }) {
[send, userIdValue], [send, userIdValue],
); );
const addLiveMessage = React.useCallback((message: RawMessage) => { const addLiveMessage = useCallback((message: RawMessage) => {
const localId = const localId =
globalThis.crypto?.randomUUID?.() ?? globalThis.crypto?.randomUUID?.() ??
`${Date.now()}-${Math.random().toString(36).slice(2)}`; `${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([]); setLiveMessagesState([]);
}, []); }, []);
const value = React.useMemo<contextType>( // 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<InfiniteData<RawMessages>>(
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<contextType>(
() => ({ () => ({
getMessages: customGetMessages, getMessages: customGetMessages,
liveMessages: () => liveMessagesState, liveMessages: () => liveMessagesState,
@ -166,7 +253,7 @@ type contextType = {
* @returns contextType. * @returns contextType.
*/ */
export function useChat(): contextType { export function useChat(): contextType {
const ctx = React.useContext(context); const ctx = useContext(context);
if (!ctx) { if (!ctx) {
throw new Error("useChat must be used within a ChatProvider"); throw new Error("useChat must be used within a ChatProvider");
} }

View file

@ -188,7 +188,7 @@ export default function Screen() {
return ( return (
<div <div
key={virtualRow.key} key={message.timestamp}
data-index={virtualRow.index} data-index={virtualRow.index}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
style={{ style={{

View file

@ -19,7 +19,7 @@ const message = z.object({
tint: z.string().length(7).startsWith("#").optional(), tint: z.string().length(7).startsWith("#").optional(),
avatar: z.boolean().optional(), avatar: z.boolean().optional(),
display: z.boolean().optional(), display: z.boolean().optional(),
message_state: z.enum(["read", "received", "sent", "sending", "awaiting"]), message_state: z.enum(["read", "received", "sent", "sending", "awaiting"]), // awaiting for 'internal' use
}); });
export const failedUser = { export const failedUser = {
@ -148,6 +148,18 @@ export const socket = {
}), }),
response: z.object({}), response: z.object({}),
}, },
message_state: {
request: z.object({
chat_partner_id: z.number(),
timestamp: z.number(),
message_state: message.shape.message_state,
}),
response: z.object({
chat_partner_id: z.number(),
message_state: message.shape.message_state,
timestamp: z.number(),
}),
},
} satisfies Record<string, { request: z.ZodType; response: z.ZodType }>; } satisfies Record<string, { request: z.ZodType; response: z.ZodType }>;
export type Socket = typeof socket; export type Socket = typeof socket;

View file

@ -12,6 +12,7 @@ import { useCrypto } from "@tensamin/crypto/context";
import { log } from "@tensamin/shared/log"; import { log } from "@tensamin/shared/log";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { createTransportClient, READY_STATE, type BoundSendFn } from "./core"; import { createTransportClient, READY_STATE, type BoundSendFn } from "./core";
import type { PushHandler } from "./core";
import { import {
PING_INTERVAL, PING_INTERVAL,
RECONNECT_RESET, RECONNECT_RESET,
@ -132,6 +133,7 @@ function getProtocolErrorDetails(error: unknown) {
type ContextType = { type ContextType = {
send: BoundSendFn<Schemas>; send: BoundSendFn<Schemas>;
subscribePush: (handler: PushHandler) => () => void;
readyState: () => number; readyState: () => number;
ownPing: () => number; ownPing: () => number;
iotaPing: () => 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(() => { useEffect(() => {
if (!connected || !identified) { if (!connected || !identified) {
return; return;
@ -536,12 +553,13 @@ export default function Provider(props: {
const contextValue = useMemo<ContextType>( const contextValue = useMemo<ContextType>(
() => ({ () => ({
send, send,
subscribePush,
readyState: () => readyState, readyState: () => readyState,
ownPing: () => ownPing, ownPing: () => ownPing,
iotaPing: () => iotaPing, iotaPing: () => iotaPing,
identified: () => identified, identified: () => identified,
}), }),
[identified, iotaPing, ownPing, readyState, send], [identified, iotaPing, ownPing, readyState, send, subscribePush],
); );
if (error !== "" && errorDescription !== "") { if (error !== "" && errorDescription !== "") {

View file

@ -26,9 +26,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
}} }}
style={ style={
{ {
"--normal-bg": "var(--popover)", "--normal-bg": "var(--color-popover)",
"--normal-text": "var(--popover-foreground)", "--normal-text": "var(--color-popover-foreground)",
"--normal-border": "var(--border)", "--normal-border": "var(--color-border)",
"--border-radius": "var(--radius)", "--border-radius": "var(--radius)",
} as React.CSSProperties } as React.CSSProperties
} }