cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

commit c406e60934da844ac587b96825f87fc7206049c0
parent 7da5e5cf54be3174122867ced89d9eb87ec9cb3c
Author: MTRNord <mtrnord1@gmail.com>
Date:   Tue,  2 May 2023 13:03:05 +0200

Fix SYNC, debug INSERT, add spaces list, add TODO for bug in shifting, use Sets, fix room avatar urls, handle somewhat spaces, render roomlist using actual data

Diffstat:
Msrc/app/sdk/api/apiTypes.ts | 28++++++++++++++++++++++++++--
Msrc/app/sdk/client.ts | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/app/sdk/room.ts | 34++++++++++++++++++++++++++++++++--
Msrc/components/roomList/roomList.tsx | 2+-
Msrc/pages/MainPage.tsx | 138+++++++++++++++++++++++++++++++++++++++++++------------------------------------
5 files changed, 276 insertions(+), 83 deletions(-)

diff --git a/src/app/sdk/api/apiTypes.ts b/src/app/sdk/api/apiTypes.ts @@ -228,7 +228,7 @@ export interface SYNC_OP { } export interface RoomJson { - name: string, + name?: string, // List of events timeline?: IRoomEvent[], required_state?: IRoomStateEvent[], @@ -250,7 +250,7 @@ export interface IRoomEvent<Content = any> { } export interface IRoomStateEvent<Content = any> extends IRoomEvent<Content> { - state_key?: string; + state_key: string; } export interface IRoomMemberContent { @@ -309,3 +309,26 @@ export interface IRoomAvatarEvent extends IRoomStateEvent<IRoomAvatarContent> { export function isRoomAvatarEvent(event: IRoomEvent): event is IRoomAvatarEvent { return event.type === "m.room.avatar"; } + +export interface ISpaceChildContent { + via: string[]; + order?: string; + suggested?: boolean; +} + +export interface ISpaceChildEvent extends IRoomStateEvent<ISpaceChildContent> { } + +export function isSpaceChildEvent(event: IRoomEvent): event is ISpaceChildEvent { + return event.type === "m.space.child"; +} + +export interface ISpaceParentContent { + via: string[]; + canonical?: boolean; +} + +export interface ISpaceParentEvent extends IRoomStateEvent<ISpaceParentContent> { } + +export function isSpaceParentEvent(event: IRoomEvent): event is ISpaceParentEvent { + return event.type === "m.space.parent"; +} +\ No newline at end of file diff --git a/src/app/sdk/client.ts b/src/app/sdk/client.ts @@ -6,7 +6,7 @@ import { DBSchema, IDBPDatabase, openDB } from "idb"; export interface MatrixClientEvents { // Used to notify about changes to the room list - 'rooms': (rooms: Room[]) => void; + 'rooms': (rooms: Set<Room>) => void; //'delete': (changedCount: number) => void; } @@ -149,7 +149,7 @@ export class MatrixClient extends EventEmitter { instance.roomToRoom.set(room.windowID, roomObj); } - instance.emit("rooms", [...instance.roomToRoom.values()]); + instance.emit("rooms", new Set(instance.roomToRoom.values())); } } } @@ -178,8 +178,12 @@ export class MatrixClient extends EventEmitter { // Basically "append" let missing = max + 1; + console.log("Max:", max) + console.log("Min:", min) + console.log("Keys:", keys) for (let i = min; i <= max; i++) { - if (!keys.includes(i)) { // Checking whether i(current value) present in num(argument) + if (!keys.includes(i)) { + console.log("Missing:", i) missing = i; break } @@ -322,6 +326,23 @@ export class MatrixClient extends EventEmitter { // Sliding Window API lists: { + // TODO: We need a list that fetches all spaces + "spaces": { + slow_get_all_rooms: true, + sort: ["by_name"], + required_state: [ + // needed to build sections + ["m.space.child", "*"], + ["m.space.parent", "*"], + ["m.room.create", ""], + // Room Avatar + ["m.room.avatar", "*"], + ], + timeline_limit: timeline_limit, + filters: { + room_types: ["m.space"] + } + }, "overview": { ranges: this.lastRanges["overview"], sort: ["by_notification_level", "by_recency", "by_name"], @@ -340,8 +361,8 @@ export class MatrixClient extends EventEmitter { bump_event_types: ["m.room.message", "m.room.encrypted"], // Room Subscriptions API - room_subscriptions: {}, - unsubscribe_rooms: [] + //room_subscriptions: {}, + //unsubscribe_rooms: [] }) }); if (!resp.ok) { @@ -374,7 +395,20 @@ export class MatrixClient extends EventEmitter { this.roomToRoom.delete(i); // We start to remember the Room now. - this.roomToRoom.set(i, new Room(op.room_ids[i], this.hostname!)); + const newRoom = new Room(op.room_ids[i], this.hostname!); + newRoom.setName("Unknown Room"); + this.roomToRoom.set(i, newRoom); + await tx?.store.put({ + windowID: i, + roomID: newRoom.roomID, + name: newRoom.getName(), + notification_count: newRoom.getNotificationCount(), + highlight_count: newRoom.getNotificationHighlightCount(), + joined_count: newRoom.getJoinedCount(), + invited_count: newRoom.getInvitedCount(), + avatarUrl: newRoom.getAvatarURL(), + isSpace: newRoom.isSpace(), + }); } await tx?.done; } else if (isInsertOp(op)) { @@ -395,10 +429,12 @@ export class MatrixClient extends EventEmitter { const min = Math.min(...this.roomToRoom.keys()); const tx = this.database?.transaction('rooms', 'readwrite'); // If the empty spot is to the right of the position we shift right - if (emptySpot > position) { + if (emptySpot > position && emptySpot <= max) { console.log("Shifting right"); // Make sure we never shift past 0 for (let i = 0; i < emptySpot && i >= min && i <= max; i++) { + console.log(i); + // TODO: This fails since we write to the new position but that still isn't moved. meaning we end up with overriding instead of shifting const room = this.roomToRoom.get(i); if (room !== undefined) { await tx?.store.put({ @@ -416,9 +452,10 @@ export class MatrixClient extends EventEmitter { this.roomToRoom.set(i + 1, room); } } + console.log("List", this.roomToRoom); } // If the empty spot is to the left of the position we shift left - else if (emptySpot < position) { + else if (emptySpot < position && emptySpot <= max) { console.log("Shifting left"); // Make sure we never shift past 0 for (let i = 0; i > emptySpot && i >= min && i <= max; i--) { @@ -439,6 +476,7 @@ export class MatrixClient extends EventEmitter { this.roomToRoom.set(i - 1, room); } } + console.log("List", this.roomToRoom); } console.log("Shifting done"); console.log("Empty spot", this.findNextFreeIndex()); @@ -449,7 +487,23 @@ export class MatrixClient extends EventEmitter { if (this.roomToRoom.get(position) !== undefined) { console.error("Shifting failed"); } - this.roomToRoom.set(position, new Room(op.room_id, this.hostname!)); + const newRoom = new Room(op.room_id, this.hostname!); + console.log(newRoom); + newRoom.setName("Unknown Room"); + this.roomToRoom.set(position, newRoom); + const tx = this.database?.transaction('rooms', 'readwrite'); + await tx?.store.put({ + windowID: position, + roomID: op.room_id, + name: newRoom.getName(), + notification_count: newRoom.getNotificationCount(), + highlight_count: newRoom.getNotificationHighlightCount(), + joined_count: newRoom.getJoinedCount(), + invited_count: newRoom.getInvitedCount(), + avatarUrl: newRoom.getAvatarURL(), + isSpace: newRoom.isSpace(), + }); + await tx?.done; } else if (isDeleteOp(op)) { console.log("Got DELETE OP", op); const tx = this.database?.transaction('rooms', 'readwrite'); @@ -490,7 +544,9 @@ export class MatrixClient extends EventEmitter { continue; } - roomObj.setName(name); + if (name) { + roomObj.setName(name); + } roomObj.setNotificationCount(notification_count); roomObj.setNotificationHighlightCount(notification_highlight_count); roomObj.setJoinedCount(joined_count); @@ -519,7 +575,7 @@ export class MatrixClient extends EventEmitter { } await tx?.done if (json.rooms && Object.keys(json.rooms).length > 0) { - this.emit("rooms", [...this.roomToRoom.values()]); + this.emit("rooms", new Set(this.roomToRoom.values())); } } @@ -541,10 +597,59 @@ export class MatrixClient extends EventEmitter { this.roomsInView = this.roomsInView.filter(room => room !== roomID); } - public getRooms(): Room[] { - return [...this.roomToRoom.values()]; + public getRooms(): Set<Room> { + return new Set(this.roomToRoom.values()); } + private getSpaces(): Room[] { + return [...this.roomToRoom.values()].filter(room => room.isSpace()); + } + + public getSpacesWithRooms(): Set<{ + spaceRoom: Room, children: Set<Room> + }> { + const spaces = this.getSpaces(); + const result: Set<{ + spaceRoom: Room, children: Set<Room> + }> = new Set(); + // Find children of spaces + for (const space of spaces) { + const childrenIDs = space.getSpaceChildrenIDs(); + + const children = new Set([...this.getRooms()].filter(room => childrenIDs.includes(room.roomID))); + + result.add({ + spaceRoom: space, + children: children, + }); + } + // Find spaces of parents + // Check parents of each room and if we have a parent make sure to add it to the result unless already added + for (const room of this.getRooms()) { + const parents = room.getSpaceParentIDs(); + for (const parent of parents) { + const parentObj = [...this.getRooms()].find(room => room.roomID === parent.roomID); + if (!parentObj) { + continue; + } + const alreadyAddedSpace = [...result].find(space => space.spaceRoom.roomID === parentObj.roomID); + if (alreadyAddedSpace) { + // Check if room in children + if (![...alreadyAddedSpace.children].find(child => child.roomID === room.roomID)) { + alreadyAddedSpace.children.add(room); + } + continue; + } + // If space not added yet, add it + result.add({ + spaceRoom: parentObj, + children: new Set([room]), + }); + } + } + + return result; + } private async getLoginFlows(): Promise<ILoginFlows> { if (!this.hostname) { @@ -723,11 +828,11 @@ export const MatrixContext = createContext<MatrixClient>(defaultMatrixClient); // List of rooms export function useRooms() { const client = useContext(MatrixContext); - const [rooms, setRooms] = useState<Room[]>(client.getRooms()); + const [rooms, setRooms] = useState<Set<Room>>(client.getRooms()); useEffect(() => { // Listen for room updates - const listenForRooms = (rooms: Room[]) => { + const listenForRooms = (rooms: Set<Room>) => { setRooms(rooms); }; client.on("rooms", listenForRooms); @@ -740,6 +845,28 @@ export function useRooms() { return rooms; } +export function useSpaces() { + const client = useContext(MatrixContext); + const [spacesWithRooms, setSpacesWithRooms] = useState<Set<{ + spaceRoom: Room, children: Set<Room> + }>>(client.getSpacesWithRooms()); + + useEffect(() => { + // Listen for room updates + const listenForRooms = (_rooms: Set<Room>) => { + setSpacesWithRooms(client.getSpacesWithRooms()); + }; + client.on("rooms", listenForRooms); + // This is a no-op if there is already a sync + client.startSync(); + return () => { + client.removeListener("rooms", listenForRooms); + } + }, []) + return spacesWithRooms; +} + + export function useProfile() { const client = useContext(MatrixContext); const [profile, setProfile] = useState<IProfileInfo>({ diff --git a/src/app/sdk/room.ts b/src/app/sdk/room.ts @@ -1,4 +1,4 @@ -import { IRoomEvent, IRoomStateEvent, isRoomAvatarEvent, isRoomCreateEvent } from "./api/apiTypes"; +import { IRoomEvent, IRoomStateEvent, isRoomAvatarEvent, isRoomCreateEvent, isSpaceChildEvent, isSpaceParentEvent } from "./api/apiTypes"; export class Room { private events: IRoomEvent[] = []; @@ -27,7 +27,7 @@ export class Room { if (isRoomAvatarEvent(event)) { const rawAvatarURL = event.content.url; if (rawAvatarURL?.startsWith("mxc://")) { - avatarURL = `https://${this.hostname}/_matrix/media/r0/download/${rawAvatarURL.substring(6)}`; + avatarURL = `${this.hostname}/_matrix/media/r0/download/${rawAvatarURL.substring(6)}`; } } }); @@ -81,4 +81,34 @@ export class Room { public getInvitedCount(): number { return this.invited_count; } + + public getSpaceChildrenIDs(): string[] { + const children: string[] = []; + this.stateEvents.forEach((event) => { + if (isSpaceChildEvent(event)) { + children.push(event.state_key); + } + }); + return children; + } + + public getSpaceParentIDs(): { roomID: string, canonical: boolean }[] { + const parents: { roomID: string, canonical: boolean }[] = []; + this.stateEvents.forEach((event) => { + if (isSpaceParentEvent(event)) { + parents.push({ roomID: event.state_key, canonical: event.content.canonical || false }); + } + }); + return parents; + } + + public isDM(): boolean { + // TODO: Implement this + return false; + } + + public isOnline(): boolean { + // TODO: Implement this + return false; + } } \ No newline at end of file diff --git a/src/components/roomList/roomList.tsx b/src/components/roomList/roomList.tsx @@ -26,7 +26,7 @@ type Room = { roomID: string }; -type Section = { +export type Section = { /** * Section Name. Can be a Space or a Tag */ diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx @@ -1,73 +1,85 @@ import { Settings } from 'lucide-react'; import Avatar from '../components/avatar/avatar'; import ChatInput from '../components/input/chat/input'; -import RoomList from '../components/roomList/roomList'; +import RoomList, { Section } from '../components/roomList/roomList'; import './MainPage.scss'; -import { useProfile, useRooms } from '../app/sdk/client'; +import { useProfile, useRooms, useSpaces } from '../app/sdk/client'; +import { Room } from '../app/sdk/room'; export default function MainPage() { const profile = useProfile(); - const matrixRooms = useRooms(); - console.log(matrixRooms.length); - const sections = [ - { - sectionName: "Test Section", - rooms: [ - { - roomID: "2", - displayname: "test1", - dm: false, - online: false, - }, - { - roomID: "3", - displayname: "test2", - dm: false, - online: false, - } - ], - roomID: "12", - subsections: [ - { - sectionName: "Test Subsection", - rooms: [ - { - roomID: "1", - displayname: "test", - avatarUrl: "https://randomuser.me/api/portraits/men/62.jpg", - dm: true, - online: true, - } - ], - roomID: "14", - subsections: [] + const spacesWithRooms = useSpaces(); + const rooms = useRooms(); + + // Generate a list of sections. + // Each section apart from special toplevel ones is a space. + // Each space has a list of rooms and subsections. + // Each subsection has a list of rooms and subsections. + // Subsections can nest infinitely. + // Rooms are always within a section. + // A section represents a space. + // If a room is not within a space it is in the toplevel section "Other" which is at the end of the list. + // The toplevel section "Other" is always present. + // The toplevel section "Other" is always the last section. + const sections = [...spacesWithRooms].map(space => { + const rooms = [...space.children].filter(room => !room.isSpace()).map(room => { + return { + roomID: room.roomID, + displayname: room.getName(), + avatarUrl: room.getAvatarURL(), + dm: room.isDM(), + online: room.isOnline(), + } + }); + + const generateSubsections = (subspace: Room): Section | undefined => { + const subspaceMeta = [...spacesWithRooms].find(space => space.spaceRoom.roomID === subspace.roomID); + if (subspaceMeta) { + const rooms = [...subspaceMeta?.children].map(room => { + return { + roomID: room.roomID, + displayname: room.getName(), + avatarUrl: room.getAvatarURL(), + dm: room.isDM(), + online: room.isOnline(), + } + }); + + return { + sectionName: subspace.getName(), + rooms: rooms, + roomID: subspace.roomID, + subsections: [...subspaceMeta?.children] + .filter(room => room.isSpace()).map(generateSubsections) + .filter(section => section !== undefined) as Section[], } - ] + } } - ]; - const rooms = [ - { - roomID: "1", - displayname: "test1234144222222", - avatarUrl: "https://randomuser.me/api/portraits/men/62.jpg", - dm: true, - online: true, - }, - { - roomID: "2", - displayname: "test1", - dm: false, - online: false, - }, - { - roomID: "3", - displayname: "test2", - dm: false, - online: false, - }, - ]; + // Its a little weird sicne there are no children attached to the room object. Only to spacesWithRooms. + // Each subsection can have further subsections and rooms. + return { + sectionName: space.spaceRoom.getName(), + rooms: rooms, + roomID: space.spaceRoom.roomID, + subsections: [...space.children] + .filter(room => room.isSpace()) + .map(generateSubsections), + } as Section; + }); + + // Add the toplevel section "Other" to the end of the list. + const otherRooms = [...rooms].filter(room => !room.isSpace()).map(room => { + return { + roomID: room.roomID, + displayname: room.getName(), + avatarUrl: room.getAvatarURL(), + dm: room.isDM(), + online: room.isOnline(), + } + }); + return <div className='flex flex-row w-full gap-2 min-h-screen'> - <div className='flex flex-col bg-gradient-to-br from-slate-100 via-gray-200 to-orange-200 border-r-[1px] border-slate-300'> + < div className='flex flex-col bg-gradient-to-br from-slate-100 via-gray-200 to-orange-200 border-r-[1px] border-slate-300' > <div className='flex flex-row gap-2 m-2 p-1 items-center border-b-2'> {profile?.avatar_url && <Avatar displayname='Test' avatarUrl={profile?.avatar_url} dm={false} online={false} />} <div className='flex flex-row justify-between items-center w-full'> @@ -75,12 +87,12 @@ export default function MainPage() { <Settings size={28} stroke='unset' className='stroke-slate-600 rounded-full hover:bg-slate-300 p-1 cursor-pointer' /> </div> </div> - <RoomList sections={sections} rooms={rooms} /> - </div> + <RoomList sections={sections} rooms={otherRooms} /> + </div > <div className='flex-1 flex flex-col'> <div className='flex-1'> </div> <ChatInput namespace='Editor' onChange={() => { }} onError={(e) => console.error(e)} /> </div> - </div> + </div > } \ No newline at end of file