From 7ccca19c5d88b4eac883a7ae0d82e253d8d5b48e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 31 Oct 2024 00:09:51 +0200 Subject: [PATCH] web/rightpanel: add support for viewing pinned messages --- web/src/api/statestore/hooks.ts | 7 +-- web/src/ui/MainScreen.tsx | 36 ++++++++++++--- web/src/ui/MainScreenContext.ts | 8 ++++ web/src/ui/ResizeHandle.tsx | 9 +++- web/src/ui/RoomView.tsx | 17 ++++--- web/src/ui/RoomViewHeader.tsx | 26 +++++++---- web/src/ui/rightpanel/PinnedMessages.tsx | 56 ++++++++++++++++++++++++ web/src/ui/rightpanel/RightPanel.css | 39 +++++++++++++++++ web/src/ui/rightpanel/RightPanel.tsx | 37 ++++++++++++++-- web/src/ui/roomlist/RoomList.tsx | 5 +-- web/src/util/reversemap.ts | 19 ++++++++ 11 files changed, 226 insertions(+), 33 deletions(-) create mode 100644 web/src/ui/rightpanel/PinnedMessages.tsx create mode 100644 web/src/util/reversemap.ts diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 8b5f375..00db164 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -27,11 +27,12 @@ export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { } export function useRoomState( - room: RoomStateStore, type: EventType, stateKey: string | undefined = "", + room?: RoomStateStore, type?: EventType, stateKey: string | undefined = "", ): MemDBEvent | null { + const isNoop = !room || !type || stateKey === undefined return useSyncExternalStore( - stateKey === undefined ? noopSubscribe : room.stateSubs.getSubscriber(room.stateSubKey(type, stateKey)), - stateKey === undefined ? returnNull : (() => room.getStateEvent(type, stateKey) ?? null), + isNoop ? noopSubscribe : room.stateSubs.getSubscriber(room.stateSubKey(type, stateKey)), + isNoop ? returnNull : (() => room.getStateEvent(type, stateKey) ?? null), ) } diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 1eae557..029d20e 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -27,7 +27,7 @@ const MainScreen = () => { const [activeRoomID, setActiveRoomID] = useState(null) const [rightPanel, setRightPanel] = useState(null) const client = use(ClientContext)! - const activeRoom = activeRoomID && client.store.rooms.get(activeRoomID) + const activeRoom = activeRoomID ? client.store.rooms.get(activeRoomID) : undefined const setActiveRoom = useCallback((roomID: RoomID) => { console.log("Switching to room", roomID) setActiveRoomID(roomID) @@ -49,6 +49,15 @@ const MainScreen = () => { }, clearActiveRoom: () => setActiveRoomID(null), setRightPanel, + closeRightPanel: () => setRightPanel(null), + clickRightPanelOpener: (evt: React.MouseEvent) => { + const type = evt.currentTarget.getAttribute("data-target-panel") + if (type === "pinned-messages" || type === "members") { + setRightPanel({ type }) + } else { + throw new Error(`Invalid right panel type ${type}`) + } + }, }), [setRightPanel, setActiveRoom]) useLayoutEffect(() => { client.store.switchRoom = setActiveRoom @@ -57,19 +66,34 @@ const MainScreen = () => { 300, 48, 900, "roomListWidth", { className: "room-list-resizer" }, ) const [rightPanelWidth, resizeHandle2] = useResizeHandle( - 300, 100, 900, "rightPanelWidth", { className: "right-panel-resizer" }, + 300, 100, 900, "rightPanelWidth", { className: "right-panel-resizer", inverted: true }, ) const extraStyle = { ["--room-list-width" as string]: `${roomListWidth}px`, ["--right-panel-width" as string]: `${rightPanelWidth}px`, } - return
+ const classNames = ["matrix-main"] + if (activeRoom) { + classNames.push("room-selected") + } + if (rightPanel) { + classNames.push("right-panel-open") + } + return
{resizeHandle1} - {activeRoom && } - {resizeHandle2} - {rightPanel && } + {activeRoom + ? + : rightPanel && <> + {resizeHandle2} + {rightPanel && } + }
} diff --git a/web/src/ui/MainScreenContext.ts b/web/src/ui/MainScreenContext.ts index e4876d9..acde037 100644 --- a/web/src/ui/MainScreenContext.ts +++ b/web/src/ui/MainScreenContext.ts @@ -23,6 +23,8 @@ export interface MainScreenContextFields { clearActiveRoom: () => void setRightPanel: (props: RightPanelProps) => void + closeRightPanel: () => void + clickRightPanelOpener: (evt: React.MouseEvent) => void } const MainScreenContext = createContext({ @@ -38,6 +40,12 @@ const MainScreenContext = createContext({ get setRightPanel(): never { throw new Error("MainScreenContext used outside main screen") }, + get closeRightPanel(): never { + throw new Error("MainScreenContext used outside main screen") + }, + get clickRightPanelOpener(): never { + throw new Error("MainScreenContext used outside main screen") + }, }) export default MainScreenContext diff --git a/web/src/ui/ResizeHandle.tsx b/web/src/ui/ResizeHandle.tsx index 09fbe35..b066cd1 100644 --- a/web/src/ui/ResizeHandle.tsx +++ b/web/src/ui/ResizeHandle.tsx @@ -24,14 +24,19 @@ export interface ResizeHandleProps { setWidth: (width: number) => void className?: string style?: CSSProperties + inverted?: boolean } -const ResizeHandle = ({ width, minWidth, maxWidth, setWidth, style, className }: ResizeHandleProps) => { +const ResizeHandle = ({ width, minWidth, maxWidth, setWidth, style, className, inverted }: ResizeHandleProps) => { const onMouseDown = useEvent((evt: React.MouseEvent) => { const origWidth = width const startPos = evt.clientX const onMouseMove = (evt: MouseEvent) => { - setWidth(Math.max(minWidth, Math.min(maxWidth, origWidth + evt.clientX - startPos))) + let delta = evt.clientX - startPos + if (inverted) { + delta = -delta + } + setWidth(Math.max(minWidth, Math.min(maxWidth, origWidth + delta))) evt.preventDefault() } const onMouseUp = () => { diff --git a/web/src/ui/RoomView.tsx b/web/src/ui/RoomView.tsx index 4790e78..16567c2 100644 --- a/web/src/ui/RoomView.tsx +++ b/web/src/ui/RoomView.tsx @@ -13,16 +13,19 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { useRef } from "react" +import { JSX, useRef } from "react" import { RoomStateStore } from "@/api/statestore" import RoomViewHeader from "./RoomViewHeader.tsx" import MessageComposer from "./composer/MessageComposer.tsx" +import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx" import { RoomContext, RoomContextData } from "./roomcontext.ts" import TimelineView from "./timeline/TimelineView.tsx" import "./RoomView.css" interface RoomViewProps { room: RoomStateStore + rightPanel: RightPanelProps | null + rightPanelResizeHandle: JSX.Element } const onKeyDownRoomView = (evt: React.KeyboardEvent) => { @@ -31,18 +34,20 @@ const onKeyDownRoomView = (evt: React.KeyboardEvent) => { } } -const RoomView = ({ room }: RoomViewProps) => { +const RoomView = ({ room, rightPanelResizeHandle, rightPanel }: RoomViewProps) => { const roomContextDataRef = useRef(undefined) if (roomContextDataRef.current === undefined) { roomContextDataRef.current = new RoomContextData(room) } - return
- + return +
- -
+
+ {rightPanelResizeHandle} + {rightPanel && } + } export default RoomView diff --git a/web/src/ui/RoomViewHeader.tsx b/web/src/ui/RoomViewHeader.tsx index 5022929..94bcfe8 100644 --- a/web/src/ui/RoomViewHeader.tsx +++ b/web/src/ui/RoomViewHeader.tsx @@ -20,9 +20,9 @@ import { useEventAsState } from "@/util/eventdispatcher.ts" import MainScreenContext from "./MainScreenContext.ts" import { LightboxContext } from "./modal/Lightbox.tsx" import BackIcon from "@/icons/back.svg?react" -// import PeopleIcon from "@/icons/group.svg?react" -// import PinIcon from "@/icons/pin.svg?react" -// import SettingsIcon from "@/icons/settings.svg?react" +import PeopleIcon from "@/icons/group.svg?react" +import PinIcon from "@/icons/pin.svg?react" +import SettingsIcon from "@/icons/settings.svg?react" import "./RoomViewHeader.css" interface RoomViewHeaderProps { @@ -40,18 +40,26 @@ const RoomViewHeader = ({ room }: RoomViewHeaderProps) => { className="avatar" loading="lazy" src={getAvatarURL(avatarSourceID, { avatar_url: roomMeta.avatar, displayname: roomMeta.name })} - onClick={use(LightboxContext)!} + onClick={use(LightboxContext)} alt="" /> {roomMeta.name ?? roomMeta.room_id}
- {/*
- - - -
*/} +
+ + + +
} diff --git a/web/src/ui/rightpanel/PinnedMessages.tsx b/web/src/ui/rightpanel/PinnedMessages.tsx new file mode 100644 index 0000000..89f286c --- /dev/null +++ b/web/src/ui/rightpanel/PinnedMessages.tsx @@ -0,0 +1,56 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { use } from "react" +import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore" +import { EventID, PinnedEventsContent } from "@/api/types" +import reverseMap from "@/util/reversemap.ts" +import ClientContext from "../ClientContext.ts" +import { RoomContext } from "../roomcontext.ts" +import TimelineEvent from "../timeline/TimelineEvent.tsx" + +interface PinnedMessageProps { + evtID: EventID + room: RoomStateStore +} + +const PinnedMessage = ({ evtID, room }: PinnedMessageProps) => { + const evt = useRoomEvent(room, evtID) + if (!evt) { + // This caches whether the event is requested or not, so it doesn't need to be wrapped in an effect. + use(ClientContext)!.requestEvent(room, evtID) + return <>Event {evtID} not found + } + return +} + +const PinnedMessages = () => { + const roomCtx = use(RoomContext) + const pins = useRoomState(roomCtx?.store, "m.room.pinned_events", "")?.content as PinnedEventsContent | undefined + if (!roomCtx) { + return null + } else if (!Array.isArray(pins?.pinned) || pins.pinned.length === 0) { + return <>No pinned messages + } + return <> + + {reverseMap(pins.pinned, evtID => typeof evtID === "string" ?
+ +
: null)} +
+ +} + +export default PinnedMessages diff --git a/web/src/ui/rightpanel/RightPanel.css b/web/src/ui/rightpanel/RightPanel.css index 24fbb72..66431ee 100644 --- a/web/src/ui/rightpanel/RightPanel.css +++ b/web/src/ui/rightpanel/RightPanel.css @@ -1,3 +1,42 @@ div.right-panel { + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + overflow: hidden; + > div.right-panel-header { + height: 3rem; + border-bottom: 1px solid var(--border-color); + box-sizing: border-box; + display: flex; + align-items: center; + vertical-align: center; + padding: 0 .25rem 0 .5rem; + justify-content: space-between; + + > div.panel-name { + font-weight: bold; + } + + > button { + height: 2.5rem; + width: 2.5rem; + } + } + + > div.right-panel-content { + flex: 1; + overflow: auto; + } +} + +div.right-panel-content.pinned-messages { + padding: .5rem; + display: flex; + flex-direction: column-reverse; + + > div.pinned-event:not(:first-child) { + border-bottom: 1px solid var(--border-color); + padding-bottom: .5rem; + } } diff --git a/web/src/ui/rightpanel/RightPanel.tsx b/web/src/ui/rightpanel/RightPanel.tsx index 09920e5..8aca7fd 100644 --- a/web/src/ui/rightpanel/RightPanel.tsx +++ b/web/src/ui/rightpanel/RightPanel.tsx @@ -13,14 +13,45 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { JSX, use } from "react" +import MainScreenContext from "../MainScreenContext.ts" +import PinnedMessages from "./PinnedMessages.tsx" +import CloseButton from "@/icons/close.svg?react" +import "./RightPanel.css" + +export type RightPanelType = "pinned-messages" | "members" export interface RightPanelProps { - meow?: never + type: RightPanelType } -const RightPanel = ({ meow }: RightPanelProps) => { +function getTitle(type: RightPanelType): string { + switch (type) { + case "pinned-messages": + return "Pinned Messages" + case "members": + return "Room Members" + } +} + +function renderRightPanelContent({ type }: RightPanelProps): JSX.Element | null { + switch (type) { + case "pinned-messages": + return + case "members": + return <>Member list is not yet implemented + } +} + +const RightPanel = ({ type, ...rest }: RightPanelProps) => { return
- {meow} +
+
{getTitle(type)}
+ +
+
+ {renderRightPanelContent({ type, ...rest })} +
} diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index fc7841c..34716f9 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -16,6 +16,7 @@ import React, { use, useCallback, useRef, useState } from "react" import type { RoomID } from "@/api/types" import { useEventAsState } from "@/util/eventdispatcher.ts" +import reverseMap from "@/util/reversemap.ts" import toSearchableString from "@/util/searchablestring.ts" import ClientContext from "../ClientContext.ts" import Entry from "./Entry.tsx" @@ -58,8 +59,4 @@ const RoomList = ({ activeRoomID }: RoomListProps) => { } -function reverseMap(arg: T[], fn: (a: T) => O) { - return arg.map((_, i, arr) => fn(arr[arr.length - i - 1])) -} - export default RoomList diff --git a/web/src/util/reversemap.ts b/web/src/util/reversemap.ts new file mode 100644 index 0000000..088da1d --- /dev/null +++ b/web/src/util/reversemap.ts @@ -0,0 +1,19 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +export default function reverseMap(arg: T[], fn: (a: T) => O) { + return arg.map((_, i, arr) => fn(arr[arr.length - i - 1])) +}