Loading packages/chat/lib/components/ChatLog/ChatBubble.tsx +68 −71 Original line number Diff line number Diff line import React, { useState, useEffect } from "react"; import { useState, useEffect } from "react"; import { useYap } from "@/context/utils"; import * as sdk from "matrix-js-sdk"; import { MessageBody } from "./MessageBody"; import { ReadReceipts } from "./ReadReceipts"; import { Reactions } from "./Reactions"; export const ChatBubble = React.memo( ({ export const ChatBubble = ({ room, event, isLatest, Loading @@ -17,17 +17,14 @@ export const ChatBubble = React.memo( const { client } = useYap<"Internal">(); const isRight = event.getSender() === client.client.getUserId(); const [edited, setEdited] = useState(!!event.replacingEvent()); const [content, setContent] = useState<{ body?: string; [key: string]: unknown; } | null>(event.getContent()); useEffect(() => { const update = () => { setContent(event.getContent()); setEdited(!!event.replacingEvent()); }; update(); event.on(sdk.MatrixEventEvent.Decrypted, update); event.on(sdk.MatrixEventEvent.Replaced, update); Loading @@ -35,7 +32,7 @@ export const ChatBubble = React.memo( event.off(sdk.MatrixEventEvent.Decrypted, update); event.off(sdk.MatrixEventEvent.Replaced, update); }; }, [event]); }, [event, client]); return ( <div Loading Loading @@ -76,10 +73,10 @@ export const ChatBubble = React.memo( {isLatest && ( <ReadReceipts client={client} room={room} event={event} /> )} <Reactions client={client} event={event} /> </div> </> {/* )} */} </div> ); }, ); }; packages/chat/lib/components/ChatLog/ChatTimeline.tsx +145 −35 Original line number Diff line number Diff line import { MXClient } from "@/lib/MXClient"; import type { MXRoomID } from "@/main"; import { useOnInView } from "react-intersection-observer"; import type { MXEventID, MXUserID } from "@/main"; import * as sdk from "matrix-js-sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { createContext, useEffect, useReducer, useRef } from "react"; import { ChatBubble } from "./ChatBubble"; import { useTimeline } from "@/hooks/useTimeline"; import { isVisibleEvent } from "@/lib/isVisibleEvent"; export const RoomContext = createContext<{ reactions: { [eventId: MXEventID]: { [emoji: string]: { [userId: MXUserID]: MXEventID }; }; }; }>({ reactions: {} }); // input is already known to be an event id, this is for types const isEventId = (input: string): input is MXEventID => { return input.startsWith("$"); }; // input is already known to be an user id, this is for types const isUserId = (input: string): input is MXUserID => { return input.startsWith("@"); }; export const ChatTimeline = ({ client, Loading @@ -12,24 +32,113 @@ export const ChatTimeline = ({ client: MXClient; room: sdk.Room; }) => { const top = useOnInView((inView) => { if (inView) { backward(); } }); const bottom = useRef<HTMLLIElement>(null); const { events, forward, backward, loading } = useTimeline(client, room); const scrollBottom = useCallback(() => { const { events, forward, backward, loading, more } = useTimeline( client, room, (ev) => { if (isVisibleEvent(ev)) { bottom.current?.scrollIntoView({ behavior: "smooth" }); }, []); } }, ); const [reactions, dReactions] = useReducer( (prev, action: [events: sdk.MatrixEvent[]]) => { const state = { ...prev }; useEffect(() => { scrollBottom(); }, [events, scrollBottom]); for (const event of action[0]) { if (event.getType() === sdk.EventType.RoomRedaction) { const content = event.getContent(); if (!("redacts" in content) || typeof content.redacts !== "string") continue; const eventToRedact = content.redacts as MXEventID; for (const event_id in state) { for (const emoji in state[event_id as MXEventID]) { Object.entries(state[event_id as MXEventID][emoji]).forEach( ([userId, reactionEventId]) => { if (reactionEventId === eventToRedact) { delete state[event_id as MXEventID][emoji][userId]; if ( Object.keys(state[event_id as MXEventID][emoji]) .length === 0 ) { delete state[event_id as MXEventID][emoji]; } } }, ); } } } else { const emoji = event.getRelation()?.key; const event_id = event.getRelation()?.event_id; if (!emoji || !event_id || !isEventId(event_id)) return state; if (!(event_id in state)) state[event_id] = {}; if (!(emoji in state[event_id])) state[event_id][emoji] = {}; const sender = event.getSender(); if (!sender || !isUserId(sender)) return state; state[event_id][emoji][sender] = event.getId()! as MXEventID; } } return state; }, {} as { [eventId: MXEventID]: { [emoji: string]: { [userId: MXUserID]: MXEventID }; }; }, ); useEffect(() => { bottom.current?.scrollIntoView({ behavior: "instant" }); }, []); useEffect(() => { const handleNew = (event: sdk.MatrixEvent) => { const relation = event.getRelation(); if (relation && relation.rel_type === sdk.RelationType.Annotation) { dReactions([[event]]); } }; const handleRedact = (event: sdk.MatrixEvent) => { dReactions([[event]]); }; const events = room .getUnfilteredTimelineSet() .getLiveTimeline() .getEvents(); for (const event of events) { handleNew(event); } room.on(sdk.RoomEvent.Timeline, handleNew); room.on(sdk.RoomEvent.Redaction, handleRedact); return () => { room.off(sdk.RoomEvent.Timeline, handleNew); room.off(sdk.RoomEvent.Redaction, handleRedact); }; }, []); return ( <RoomContext value={{ reactions }}> <ul> <li onClick={() => backward()}>top {loading.backward && "loading"}</li> <li ref={top} onClick={() => backward()}> top {loading.backward && "loading"} {more.backward && "has more"} </li> {events .filter( (ev) => Loading @@ -51,8 +160,9 @@ export const ChatTimeline = ({ </li> ))} <li onClick={() => forward()} ref={bottom}> bottom {loading.forward && "loading"} bottom {loading.forward && "loading"} {more.forward && "has more"} </li> </ul> </RoomContext> ); }; packages/chat/lib/components/ChatLog/Reactions.tsx 0 → 100644 +19 −0 Original line number Diff line number Diff line import type { MXClient } from "@/lib/MXClient"; import type { MXEventID } from "@/main"; import * as sdk from "matrix-js-sdk"; import { use } from "react"; import { RoomContext } from "./ChatTimeline"; export const Reactions = ({ client, event, }: { client: MXClient; event: sdk.MatrixEvent; }) => { const { reactions } = use(RoomContext); return ( <>{Object.keys(reactions[event.getId()! as MXEventID] || {}).join(",")}</> ); }; packages/chat/lib/components/ChatLog/ReadReceipts.tsx +1 −1 Original line number Diff line number Diff line Loading @@ -11,7 +11,7 @@ export const ReadReceipts = ({ room: sdk.Room; event: sdk.MatrixEvent; }) => { const { readBy } = useRead(client, room, event); const readBy = useRead(client, room, event); return <>Read ({readBy.length})</>; }; packages/chat/lib/hooks/useRead.ts +15 −21 Original line number Diff line number Diff line import { getReceiptsForEvent } from "@/lib/getReceiptsForEvent"; import type { MXClient } from "@/lib/MXClient"; import * as sdk from "matrix-js-sdk"; import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react"; export const useRead = ( client: MXClient, Loading @@ -9,33 +10,26 @@ export const useRead = ( ) => { const [readBy, setReadBy] = useState<sdk.RoomMember[]>([]); useEffect(() => { const update = () => { const receipts = room.getReceiptsForEvent(event) || []; const members = receipts .filter((r) => r.type === "m.read") .filter((r) => r.userId !== client.client.getUserId()) .map((r) => room.getMember(r.userId)) .filter((r): r is sdk.RoomMember => Boolean(r)); const update = useCallback(() => { const members = getReceiptsForEvent(room, event); setReadBy(members); }; }, [room, event]); useEffect(() => { update(); const handle = (revent: sdk.MatrixEvent, rroom: sdk.Room) => { if (rroom.roomId !== room.roomId) return; const handle = (_event: sdk.MatrixEvent, rroom?: sdk.Room) => { if (rroom?.roomId === room.roomId) { update(); } }; client.client.on(sdk.RoomEvent.Receipt, handle); room.client.on(sdk.RoomEvent.Receipt, handle); return () => { client.client.off(sdk.RoomEvent.Receipt, handle); room.client.off(sdk.RoomEvent.Receipt, handle); }; }, [client, room, event]); }, [room, event, update]); return { readBy }; return readBy; }; Loading
packages/chat/lib/components/ChatLog/ChatBubble.tsx +68 −71 Original line number Diff line number Diff line import React, { useState, useEffect } from "react"; import { useState, useEffect } from "react"; import { useYap } from "@/context/utils"; import * as sdk from "matrix-js-sdk"; import { MessageBody } from "./MessageBody"; import { ReadReceipts } from "./ReadReceipts"; import { Reactions } from "./Reactions"; export const ChatBubble = React.memo( ({ export const ChatBubble = ({ room, event, isLatest, Loading @@ -17,17 +17,14 @@ export const ChatBubble = React.memo( const { client } = useYap<"Internal">(); const isRight = event.getSender() === client.client.getUserId(); const [edited, setEdited] = useState(!!event.replacingEvent()); const [content, setContent] = useState<{ body?: string; [key: string]: unknown; } | null>(event.getContent()); useEffect(() => { const update = () => { setContent(event.getContent()); setEdited(!!event.replacingEvent()); }; update(); event.on(sdk.MatrixEventEvent.Decrypted, update); event.on(sdk.MatrixEventEvent.Replaced, update); Loading @@ -35,7 +32,7 @@ export const ChatBubble = React.memo( event.off(sdk.MatrixEventEvent.Decrypted, update); event.off(sdk.MatrixEventEvent.Replaced, update); }; }, [event]); }, [event, client]); return ( <div Loading Loading @@ -76,10 +73,10 @@ export const ChatBubble = React.memo( {isLatest && ( <ReadReceipts client={client} room={room} event={event} /> )} <Reactions client={client} event={event} /> </div> </> {/* )} */} </div> ); }, ); };
packages/chat/lib/components/ChatLog/ChatTimeline.tsx +145 −35 Original line number Diff line number Diff line import { MXClient } from "@/lib/MXClient"; import type { MXRoomID } from "@/main"; import { useOnInView } from "react-intersection-observer"; import type { MXEventID, MXUserID } from "@/main"; import * as sdk from "matrix-js-sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { createContext, useEffect, useReducer, useRef } from "react"; import { ChatBubble } from "./ChatBubble"; import { useTimeline } from "@/hooks/useTimeline"; import { isVisibleEvent } from "@/lib/isVisibleEvent"; export const RoomContext = createContext<{ reactions: { [eventId: MXEventID]: { [emoji: string]: { [userId: MXUserID]: MXEventID }; }; }; }>({ reactions: {} }); // input is already known to be an event id, this is for types const isEventId = (input: string): input is MXEventID => { return input.startsWith("$"); }; // input is already known to be an user id, this is for types const isUserId = (input: string): input is MXUserID => { return input.startsWith("@"); }; export const ChatTimeline = ({ client, Loading @@ -12,24 +32,113 @@ export const ChatTimeline = ({ client: MXClient; room: sdk.Room; }) => { const top = useOnInView((inView) => { if (inView) { backward(); } }); const bottom = useRef<HTMLLIElement>(null); const { events, forward, backward, loading } = useTimeline(client, room); const scrollBottom = useCallback(() => { const { events, forward, backward, loading, more } = useTimeline( client, room, (ev) => { if (isVisibleEvent(ev)) { bottom.current?.scrollIntoView({ behavior: "smooth" }); }, []); } }, ); const [reactions, dReactions] = useReducer( (prev, action: [events: sdk.MatrixEvent[]]) => { const state = { ...prev }; useEffect(() => { scrollBottom(); }, [events, scrollBottom]); for (const event of action[0]) { if (event.getType() === sdk.EventType.RoomRedaction) { const content = event.getContent(); if (!("redacts" in content) || typeof content.redacts !== "string") continue; const eventToRedact = content.redacts as MXEventID; for (const event_id in state) { for (const emoji in state[event_id as MXEventID]) { Object.entries(state[event_id as MXEventID][emoji]).forEach( ([userId, reactionEventId]) => { if (reactionEventId === eventToRedact) { delete state[event_id as MXEventID][emoji][userId]; if ( Object.keys(state[event_id as MXEventID][emoji]) .length === 0 ) { delete state[event_id as MXEventID][emoji]; } } }, ); } } } else { const emoji = event.getRelation()?.key; const event_id = event.getRelation()?.event_id; if (!emoji || !event_id || !isEventId(event_id)) return state; if (!(event_id in state)) state[event_id] = {}; if (!(emoji in state[event_id])) state[event_id][emoji] = {}; const sender = event.getSender(); if (!sender || !isUserId(sender)) return state; state[event_id][emoji][sender] = event.getId()! as MXEventID; } } return state; }, {} as { [eventId: MXEventID]: { [emoji: string]: { [userId: MXUserID]: MXEventID }; }; }, ); useEffect(() => { bottom.current?.scrollIntoView({ behavior: "instant" }); }, []); useEffect(() => { const handleNew = (event: sdk.MatrixEvent) => { const relation = event.getRelation(); if (relation && relation.rel_type === sdk.RelationType.Annotation) { dReactions([[event]]); } }; const handleRedact = (event: sdk.MatrixEvent) => { dReactions([[event]]); }; const events = room .getUnfilteredTimelineSet() .getLiveTimeline() .getEvents(); for (const event of events) { handleNew(event); } room.on(sdk.RoomEvent.Timeline, handleNew); room.on(sdk.RoomEvent.Redaction, handleRedact); return () => { room.off(sdk.RoomEvent.Timeline, handleNew); room.off(sdk.RoomEvent.Redaction, handleRedact); }; }, []); return ( <RoomContext value={{ reactions }}> <ul> <li onClick={() => backward()}>top {loading.backward && "loading"}</li> <li ref={top} onClick={() => backward()}> top {loading.backward && "loading"} {more.backward && "has more"} </li> {events .filter( (ev) => Loading @@ -51,8 +160,9 @@ export const ChatTimeline = ({ </li> ))} <li onClick={() => forward()} ref={bottom}> bottom {loading.forward && "loading"} bottom {loading.forward && "loading"} {more.forward && "has more"} </li> </ul> </RoomContext> ); };
packages/chat/lib/components/ChatLog/Reactions.tsx 0 → 100644 +19 −0 Original line number Diff line number Diff line import type { MXClient } from "@/lib/MXClient"; import type { MXEventID } from "@/main"; import * as sdk from "matrix-js-sdk"; import { use } from "react"; import { RoomContext } from "./ChatTimeline"; export const Reactions = ({ client, event, }: { client: MXClient; event: sdk.MatrixEvent; }) => { const { reactions } = use(RoomContext); return ( <>{Object.keys(reactions[event.getId()! as MXEventID] || {}).join(",")}</> ); };
packages/chat/lib/components/ChatLog/ReadReceipts.tsx +1 −1 Original line number Diff line number Diff line Loading @@ -11,7 +11,7 @@ export const ReadReceipts = ({ room: sdk.Room; event: sdk.MatrixEvent; }) => { const { readBy } = useRead(client, room, event); const readBy = useRead(client, room, event); return <>Read ({readBy.length})</>; };
packages/chat/lib/hooks/useRead.ts +15 −21 Original line number Diff line number Diff line import { getReceiptsForEvent } from "@/lib/getReceiptsForEvent"; import type { MXClient } from "@/lib/MXClient"; import * as sdk from "matrix-js-sdk"; import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react"; export const useRead = ( client: MXClient, Loading @@ -9,33 +10,26 @@ export const useRead = ( ) => { const [readBy, setReadBy] = useState<sdk.RoomMember[]>([]); useEffect(() => { const update = () => { const receipts = room.getReceiptsForEvent(event) || []; const members = receipts .filter((r) => r.type === "m.read") .filter((r) => r.userId !== client.client.getUserId()) .map((r) => room.getMember(r.userId)) .filter((r): r is sdk.RoomMember => Boolean(r)); const update = useCallback(() => { const members = getReceiptsForEvent(room, event); setReadBy(members); }; }, [room, event]); useEffect(() => { update(); const handle = (revent: sdk.MatrixEvent, rroom: sdk.Room) => { if (rroom.roomId !== room.roomId) return; const handle = (_event: sdk.MatrixEvent, rroom?: sdk.Room) => { if (rroom?.roomId === room.roomId) { update(); } }; client.client.on(sdk.RoomEvent.Receipt, handle); room.client.on(sdk.RoomEvent.Receipt, handle); return () => { client.client.off(sdk.RoomEvent.Receipt, handle); room.client.off(sdk.RoomEvent.Receipt, handle); }; }, [client, room, event]); }, [room, event, update]); return { readBy }; return readBy; };