Loading packages/chat/lib/components/ChatLog/ChatBubble.tsx +28 −34 Original line number Diff line number Diff line import { MessageFlag, type Message } from "@/lib/const"; import { useYap } from "@/context/utils"; import type React from "react"; import type { MatrixEvent } from "matrix-js-sdk"; type MessageRole = "ME" | "OTHER" | "SYSTEM"; export const ChatBubble = ({ message }: { message: Message }) => { const { state } = useYap<"Internal">(); const isSystem = (_str: string) => false; const role: MessageRole = isSystem(message.sender) ? "SYSTEM" : message.sender === state.user ? "ME" : "OTHER"; const flags = message.flags; export const ChatBubble = ({ event }: { event: MatrixEvent }) => { const { client } = useYap<"Internal">(); const isRight = event.getSender() === client.client.getUserId(); return ( <div className="flex flex-col w-full" style={{ alignItems: role === "ME" ? "end" : role === "OTHER" ? "start" : "center", alignItems: isRight ? "end" : "start", }} > {role === "SYSTEM" ? ( <div className="self-center">{message.content}</div> ) : ( {/* {role === "SYSTEM" ? ( <div className="self-center">{event.content}</div> ) : ( */} <> <div className="flex flex-row gap-1 *:text-xs"> <span>{event.getSender()}</span> </div> <div className="rounded-xl p-1" style={{ backgroundColor: role === "ME" backgroundColor: isRight ? "var(--color-gray-500)" : "var(--color-gray-300)", }} > {message.content} {event.getContent().body} </div> <div className="flex flex-row gap-1 *:text-xs"> {flags & MessageFlag.SENDING ? <span>sending</span> : ""} {/* {flags & MessageFlag.SENDING ? <span>sending</span> : ""} {flags & MessageFlag.SEEN ? <span>seen</span> : ""} {flags & MessageFlag.EDITED ? <span>edited</span> : ""} {flags & MessageFlag.EDITED ? <span>edited</span> : ""} */} </div> </> )} {/* )} */} </div> ); }; packages/chat/lib/components/ChatLog/ChatLog.tsx +1 −1 Original line number Diff line number Diff line Loading @@ -26,7 +26,7 @@ export const ChatLog = ({ )} <React.Fragment> {messages.map((msg) => ( <ChatBubble key={msg.eventId} message={msg} /> <ChatBubble key={msg.eventId} event={msg} /> ))} </React.Fragment> <div ref={bottom} /> Loading packages/chat/lib/components/ChatLog/ChatTimeline.tsx 0 → 100644 +68 −0 Original line number Diff line number Diff line import { MXClient } from "@/lib/MXClient"; import type { MXRoomID } from "@/main"; import { ClientEvent, EventTimeline, MatrixEvent, RoomEvent, SyncState, TimelineWindow, } from "matrix-js-sdk"; import { useEffect, useRef, useState } from "react"; import { ChatBubble } from "./ChatBubble"; export const ChatTimeline = ({ roomId }: { roomId: MXRoomID }) => { const timelineRef = useRef<TimelineWindow>(null); const [events, setEvents] = useState<MatrixEvent[]>([]); function getTimeline() { if (timelineRef.current !== null) { return timelineRef.current; } const { client, rooms } = MXClient.get(); const room = rooms.getRoom(roomId); return (timelineRef.current = new TimelineWindow( client, room!.getUnfilteredTimelineSet()!, )); } useEffect(() => { const { client } = MXClient.get(); getTimeline() .load() .then(() => { setEvents(getTimeline().getEvents()); }) .catch((e) => { console.error(e); }); client.on(RoomEvent.Timeline, (event, room, toStartOfTimeline) => { if (!room || room.roomId !== roomId) return; if (toStartOfTimeline) { console.log("tostartoftimeline"); return; } getTimeline() .paginate(EventTimeline.FORWARDS, 1, false) .then(() => { setEvents(getTimeline().getEvents()); }); }); }, [roomId]); return ( <ul> {events.map((ev) => ( <li key={ev.getId()}> <ChatBubble event={ev} /> </li> ))} </ul> ); }; packages/chat/lib/components/Tab/Room.tsx +76 −14 Original line number Diff line number Diff line Loading @@ -4,8 +4,10 @@ import type { MXRoomID } from "@/lib/const"; import { useMessages } from "@/hooks/useMessages"; import { useYap } from "@/context/utils"; import { useRoom } from "@/hooks/useRoom"; import React, { useCallback, useState } from "react"; import React, { useCallback, useEffect, useState } from "react"; import { MXClient } from "@/lib/MXClient"; import { Room } from "matrix-js-sdk"; import { ChatTimeline } from "../ChatLog/ChatTimeline"; export const RoomTab = ({ roomId, Loading Loading @@ -57,13 +59,17 @@ export const RoomTab = ({ {/* <ChatBubble role="ME" flags={MessageFlag.SENDING ^ MessageFlag.SEEN} /> <ChatBubble role="SYSTEM" /> <ChatBubble role="OTHER" /> */} <ChatLog {/* <ChatLog messages={messages} loading={loading} atBeginning={atBeginning} loadMore={loadMore} /> /> */} <ChatTimeline roomId={roomId} /> </div> {room.replacedBy ? ( <ReplacedByBanner roomId={roomId} /> ) : ( <div className="p-2"> <form action="#" onSubmit={sendMessage}> <input Loading @@ -75,12 +81,68 @@ export const RoomTab = ({ <button type="submit">Send</button> </form> </div> )} </> )} </Tab> ); }; const ReplacedByBanner = ({ roomId }: { roomId: MXRoomID }) => { const { dispatch } = useYap<"Internal">(); const { replacedBy } = useRoom(roomId); const [room, setRoom] = useState<Room>(); useEffect(() => { const mxclient = MXClient.get(); const onDynamic = (room: Room) => { if (replacedBy && room.roomId === replacedBy) { setRoom(room); } }; if (replacedBy) { setRoom(mxclient.rooms.getRoom(replacedBy) ?? undefined); } mxclient.rooms.on("new", onDynamic); return () => { mxclient.rooms.off("new", onDynamic); }; }, [replacedBy]); const doJoin = useCallback( (oldRoomId: MXRoomID, newRoomId: MXRoomID) => { const mxclient = MXClient.get(); mxclient.rooms .joinRoom(newRoomId) .then(() => { dispatch(["openChat", newRoomId]); dispatch(["closeChat", oldRoomId]); }) .catch((e) => { console.error("failed to join replacement room", e); }); }, [dispatch], ); if (!replacedBy) return null; return ( <div className="bg-red-400/50 p-1 w-full overflow-x-clip relative"> room replaced by {room?.name ?? replacedBy} <button className="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" title="Join new room" onClick={() => doJoin(roomId, replacedBy)} ></button> </div> ); }; const ConfirmInvite = ({ roomId, onDecline, Loading packages/chat/lib/hooks/useMessages.ts +36 −44 Original line number Diff line number Diff line import { MessageFlag, type Message, type MXRoomID } from "@/lib/const"; import { useCallback, useState } from "react"; /** * sorted oldest to newest */ const TEST_MESSAGES: Message[] = [ { eventId: `$a`, sender: "@grant:aftermath.gg", content: "hello", flags: MessageFlag.SEEN, createdAt: "2026-03-30T17:40:46.659Z", }, { eventId: `$b`, sender: "@tnarg:aftermath.gg", content: "canvas!", flags: 0, createdAt: "2026-03-30T17:41:46.659Z", }, { eventId: "$c", sender: "@canvas:aftermath.gg", content: "canvas 2026 has started", flags: 0, createdAt: "2026-03-30T17:45:46.659Z", }, { eventId: "$d", sender: "@tnarg:aftermath.gg", content: "lets gooo", flags: 0, createdAt: "2026-03-30T17:46:46.659Z", }, { eventId: "$e", sender: "@grant:aftermath.gg", content: "very cool", flags: MessageFlag.SENDING, createdAt: "2026-03-30T17:47:46.659Z", }, ]; import { MessageFlag, type Message, type MXEventID, type MXRoomID, type MXUserID, } from "@/lib/const"; import { MXClient } from "@/lib/MXClient"; import { useCallback, useEffect, useState } from "react"; export const useMessages = ( _roomId: MXRoomID, roomId: MXRoomID, ): { messages: Message[]; loading: boolean; Loading @@ -55,6 +21,32 @@ export const useMessages = ( const [messages, setMessages] = useState<Message[]>([]); const [cursor, setCursor] = useState<string>(); useEffect(() => { const mxclient = MXClient.get(); const room = mxclient.rooms.getRoom(roomId); if (!room) { console.warn("useMessages: failed to get room " + roomId); return; } const events = room.getLiveTimeline().getEvents(); const messages = events.filter((e) => e.getType() === "m.room.message"); setMessages( messages.map( (mxevent): Message => ({ eventId: mxevent.getId()! as MXEventID, content: mxevent.getContent().body, createdAt: mxevent.getDate()!.toISOString(), sender: mxevent.getSender()! as MXUserID, event: mxevent, flags: 0, }), ), ); }, [roomId]); const loadMore = useCallback(() => { if (loading) return; Loading Loading
packages/chat/lib/components/ChatLog/ChatBubble.tsx +28 −34 Original line number Diff line number Diff line import { MessageFlag, type Message } from "@/lib/const"; import { useYap } from "@/context/utils"; import type React from "react"; import type { MatrixEvent } from "matrix-js-sdk"; type MessageRole = "ME" | "OTHER" | "SYSTEM"; export const ChatBubble = ({ message }: { message: Message }) => { const { state } = useYap<"Internal">(); const isSystem = (_str: string) => false; const role: MessageRole = isSystem(message.sender) ? "SYSTEM" : message.sender === state.user ? "ME" : "OTHER"; const flags = message.flags; export const ChatBubble = ({ event }: { event: MatrixEvent }) => { const { client } = useYap<"Internal">(); const isRight = event.getSender() === client.client.getUserId(); return ( <div className="flex flex-col w-full" style={{ alignItems: role === "ME" ? "end" : role === "OTHER" ? "start" : "center", alignItems: isRight ? "end" : "start", }} > {role === "SYSTEM" ? ( <div className="self-center">{message.content}</div> ) : ( {/* {role === "SYSTEM" ? ( <div className="self-center">{event.content}</div> ) : ( */} <> <div className="flex flex-row gap-1 *:text-xs"> <span>{event.getSender()}</span> </div> <div className="rounded-xl p-1" style={{ backgroundColor: role === "ME" backgroundColor: isRight ? "var(--color-gray-500)" : "var(--color-gray-300)", }} > {message.content} {event.getContent().body} </div> <div className="flex flex-row gap-1 *:text-xs"> {flags & MessageFlag.SENDING ? <span>sending</span> : ""} {/* {flags & MessageFlag.SENDING ? <span>sending</span> : ""} {flags & MessageFlag.SEEN ? <span>seen</span> : ""} {flags & MessageFlag.EDITED ? <span>edited</span> : ""} {flags & MessageFlag.EDITED ? <span>edited</span> : ""} */} </div> </> )} {/* )} */} </div> ); };
packages/chat/lib/components/ChatLog/ChatLog.tsx +1 −1 Original line number Diff line number Diff line Loading @@ -26,7 +26,7 @@ export const ChatLog = ({ )} <React.Fragment> {messages.map((msg) => ( <ChatBubble key={msg.eventId} message={msg} /> <ChatBubble key={msg.eventId} event={msg} /> ))} </React.Fragment> <div ref={bottom} /> Loading
packages/chat/lib/components/ChatLog/ChatTimeline.tsx 0 → 100644 +68 −0 Original line number Diff line number Diff line import { MXClient } from "@/lib/MXClient"; import type { MXRoomID } from "@/main"; import { ClientEvent, EventTimeline, MatrixEvent, RoomEvent, SyncState, TimelineWindow, } from "matrix-js-sdk"; import { useEffect, useRef, useState } from "react"; import { ChatBubble } from "./ChatBubble"; export const ChatTimeline = ({ roomId }: { roomId: MXRoomID }) => { const timelineRef = useRef<TimelineWindow>(null); const [events, setEvents] = useState<MatrixEvent[]>([]); function getTimeline() { if (timelineRef.current !== null) { return timelineRef.current; } const { client, rooms } = MXClient.get(); const room = rooms.getRoom(roomId); return (timelineRef.current = new TimelineWindow( client, room!.getUnfilteredTimelineSet()!, )); } useEffect(() => { const { client } = MXClient.get(); getTimeline() .load() .then(() => { setEvents(getTimeline().getEvents()); }) .catch((e) => { console.error(e); }); client.on(RoomEvent.Timeline, (event, room, toStartOfTimeline) => { if (!room || room.roomId !== roomId) return; if (toStartOfTimeline) { console.log("tostartoftimeline"); return; } getTimeline() .paginate(EventTimeline.FORWARDS, 1, false) .then(() => { setEvents(getTimeline().getEvents()); }); }); }, [roomId]); return ( <ul> {events.map((ev) => ( <li key={ev.getId()}> <ChatBubble event={ev} /> </li> ))} </ul> ); };
packages/chat/lib/components/Tab/Room.tsx +76 −14 Original line number Diff line number Diff line Loading @@ -4,8 +4,10 @@ import type { MXRoomID } from "@/lib/const"; import { useMessages } from "@/hooks/useMessages"; import { useYap } from "@/context/utils"; import { useRoom } from "@/hooks/useRoom"; import React, { useCallback, useState } from "react"; import React, { useCallback, useEffect, useState } from "react"; import { MXClient } from "@/lib/MXClient"; import { Room } from "matrix-js-sdk"; import { ChatTimeline } from "../ChatLog/ChatTimeline"; export const RoomTab = ({ roomId, Loading Loading @@ -57,13 +59,17 @@ export const RoomTab = ({ {/* <ChatBubble role="ME" flags={MessageFlag.SENDING ^ MessageFlag.SEEN} /> <ChatBubble role="SYSTEM" /> <ChatBubble role="OTHER" /> */} <ChatLog {/* <ChatLog messages={messages} loading={loading} atBeginning={atBeginning} loadMore={loadMore} /> /> */} <ChatTimeline roomId={roomId} /> </div> {room.replacedBy ? ( <ReplacedByBanner roomId={roomId} /> ) : ( <div className="p-2"> <form action="#" onSubmit={sendMessage}> <input Loading @@ -75,12 +81,68 @@ export const RoomTab = ({ <button type="submit">Send</button> </form> </div> )} </> )} </Tab> ); }; const ReplacedByBanner = ({ roomId }: { roomId: MXRoomID }) => { const { dispatch } = useYap<"Internal">(); const { replacedBy } = useRoom(roomId); const [room, setRoom] = useState<Room>(); useEffect(() => { const mxclient = MXClient.get(); const onDynamic = (room: Room) => { if (replacedBy && room.roomId === replacedBy) { setRoom(room); } }; if (replacedBy) { setRoom(mxclient.rooms.getRoom(replacedBy) ?? undefined); } mxclient.rooms.on("new", onDynamic); return () => { mxclient.rooms.off("new", onDynamic); }; }, [replacedBy]); const doJoin = useCallback( (oldRoomId: MXRoomID, newRoomId: MXRoomID) => { const mxclient = MXClient.get(); mxclient.rooms .joinRoom(newRoomId) .then(() => { dispatch(["openChat", newRoomId]); dispatch(["closeChat", oldRoomId]); }) .catch((e) => { console.error("failed to join replacement room", e); }); }, [dispatch], ); if (!replacedBy) return null; return ( <div className="bg-red-400/50 p-1 w-full overflow-x-clip relative"> room replaced by {room?.name ?? replacedBy} <button className="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" title="Join new room" onClick={() => doJoin(roomId, replacedBy)} ></button> </div> ); }; const ConfirmInvite = ({ roomId, onDecline, Loading
packages/chat/lib/hooks/useMessages.ts +36 −44 Original line number Diff line number Diff line import { MessageFlag, type Message, type MXRoomID } from "@/lib/const"; import { useCallback, useState } from "react"; /** * sorted oldest to newest */ const TEST_MESSAGES: Message[] = [ { eventId: `$a`, sender: "@grant:aftermath.gg", content: "hello", flags: MessageFlag.SEEN, createdAt: "2026-03-30T17:40:46.659Z", }, { eventId: `$b`, sender: "@tnarg:aftermath.gg", content: "canvas!", flags: 0, createdAt: "2026-03-30T17:41:46.659Z", }, { eventId: "$c", sender: "@canvas:aftermath.gg", content: "canvas 2026 has started", flags: 0, createdAt: "2026-03-30T17:45:46.659Z", }, { eventId: "$d", sender: "@tnarg:aftermath.gg", content: "lets gooo", flags: 0, createdAt: "2026-03-30T17:46:46.659Z", }, { eventId: "$e", sender: "@grant:aftermath.gg", content: "very cool", flags: MessageFlag.SENDING, createdAt: "2026-03-30T17:47:46.659Z", }, ]; import { MessageFlag, type Message, type MXEventID, type MXRoomID, type MXUserID, } from "@/lib/const"; import { MXClient } from "@/lib/MXClient"; import { useCallback, useEffect, useState } from "react"; export const useMessages = ( _roomId: MXRoomID, roomId: MXRoomID, ): { messages: Message[]; loading: boolean; Loading @@ -55,6 +21,32 @@ export const useMessages = ( const [messages, setMessages] = useState<Message[]>([]); const [cursor, setCursor] = useState<string>(); useEffect(() => { const mxclient = MXClient.get(); const room = mxclient.rooms.getRoom(roomId); if (!room) { console.warn("useMessages: failed to get room " + roomId); return; } const events = room.getLiveTimeline().getEvents(); const messages = events.filter((e) => e.getType() === "m.room.message"); setMessages( messages.map( (mxevent): Message => ({ eventId: mxevent.getId()! as MXEventID, content: mxevent.getContent().body, createdAt: mxevent.getDate()!.toISOString(), sender: mxevent.getSender()! as MXUserID, event: mxevent, flags: 0, }), ), ); }, [roomId]); const loadMore = useCallback(() => { if (loading) return; Loading