mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/rightpanel: add support for viewing pinned messages
This commit is contained in:
parent
8700626176
commit
7ccca19c5d
11 changed files with 226 additions and 33 deletions
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ const MainScreen = () => {
|
|||
const [activeRoomID, setActiveRoomID] = useState<RoomID | null>(null)
|
||||
const [rightPanel, setRightPanel] = useState<RightPanelProps | null>(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 <main className={`matrix-main ${activeRoom ? "room-selected" : ""}`} style={extraStyle}>
|
||||
const classNames = ["matrix-main"]
|
||||
if (activeRoom) {
|
||||
classNames.push("room-selected")
|
||||
}
|
||||
if (rightPanel) {
|
||||
classNames.push("right-panel-open")
|
||||
}
|
||||
return <main className={classNames.join(" ")} style={extraStyle}>
|
||||
<MainScreenContext value={context}>
|
||||
<RoomList activeRoomID={activeRoomID}/>
|
||||
{resizeHandle1}
|
||||
{activeRoom && <RoomView key={activeRoomID} room={activeRoom}/>}
|
||||
{resizeHandle2}
|
||||
{rightPanel && <RightPanel {...rightPanel}/>}
|
||||
{activeRoom
|
||||
? <RoomView
|
||||
key={activeRoomID}
|
||||
room={activeRoom}
|
||||
rightPanel={rightPanel}
|
||||
rightPanelResizeHandle={resizeHandle2}
|
||||
/>
|
||||
: rightPanel && <>
|
||||
{resizeHandle2}
|
||||
{rightPanel && <RightPanel {...rightPanel}/>}
|
||||
</>}
|
||||
</MainScreenContext>
|
||||
</main>
|
||||
}
|
||||
|
|
|
@ -23,6 +23,8 @@ export interface MainScreenContextFields {
|
|||
clearActiveRoom: () => void
|
||||
|
||||
setRightPanel: (props: RightPanelProps) => void
|
||||
closeRightPanel: () => void
|
||||
clickRightPanelOpener: (evt: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
const MainScreenContext = createContext<MainScreenContextFields>({
|
||||
|
@ -38,6 +40,12 @@ const MainScreenContext = createContext<MainScreenContextFields>({
|
|||
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
|
||||
|
|
|
@ -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<HTMLDivElement>) => {
|
||||
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 = () => {
|
||||
|
|
|
@ -13,16 +13,19 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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<RoomContextData | undefined>(undefined)
|
||||
if (roomContextDataRef.current === undefined) {
|
||||
roomContextDataRef.current = new RoomContextData(room)
|
||||
}
|
||||
return <div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}>
|
||||
<RoomContext value={roomContextDataRef.current}>
|
||||
return <RoomContext value={roomContextDataRef.current}>
|
||||
<div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}>
|
||||
<RoomViewHeader room={room}/>
|
||||
<TimelineView/>
|
||||
<MessageComposer/>
|
||||
</RoomContext>
|
||||
</div>
|
||||
</div>
|
||||
{rightPanelResizeHandle}
|
||||
{rightPanel && <RightPanel {...rightPanel}/>}
|
||||
</RoomContext>
|
||||
}
|
||||
|
||||
export default RoomView
|
||||
|
|
|
@ -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=""
|
||||
/>
|
||||
<span className="room-name">
|
||||
{roomMeta.name ?? roomMeta.room_id}
|
||||
</span>
|
||||
<div className="divider"/>
|
||||
{/*<div className="right-buttons">
|
||||
<button><PinIcon/></button>
|
||||
<button><PeopleIcon/></button>
|
||||
<button><SettingsIcon/></button>
|
||||
</div>*/}
|
||||
<div className="right-buttons">
|
||||
<button
|
||||
data-target-panel="pinned-messages"
|
||||
onClick={mainScreen.clickRightPanelOpener}
|
||||
title="Pinned Messages"
|
||||
><PinIcon/></button>
|
||||
<button
|
||||
data-target-panel="members"
|
||||
onClick={mainScreen.clickRightPanelOpener}
|
||||
title="Room Members"
|
||||
><PeopleIcon/></button>
|
||||
<button title="Room Settings"><SettingsIcon/></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
|
56
web/src/ui/rightpanel/PinnedMessages.tsx
Normal file
56
web/src/ui/rightpanel/PinnedMessages.tsx
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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 <TimelineEvent evt={evt} prevEvt={null} />
|
||||
}
|
||||
|
||||
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 <>
|
||||
<RoomContext value={roomCtx}>
|
||||
{reverseMap(pins.pinned, evtID => typeof evtID === "string" ? <div className="pinned-event" key={evtID}>
|
||||
<PinnedMessage evtID={evtID} room={roomCtx.store} />
|
||||
</div> : null)}
|
||||
</RoomContext>
|
||||
</>
|
||||
}
|
||||
|
||||
export default PinnedMessages
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,14 +13,45 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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 <PinnedMessages />
|
||||
case "members":
|
||||
return <>Member list is not yet implemented</>
|
||||
}
|
||||
}
|
||||
|
||||
const RightPanel = ({ type, ...rest }: RightPanelProps) => {
|
||||
return <div className="right-panel">
|
||||
{meow}
|
||||
<div className="right-panel-header">
|
||||
<div className="panel-name">{getTitle(type)}</div>
|
||||
<button onClick={use(MainScreenContext).closeRightPanel}><CloseButton/></button>
|
||||
</div>
|
||||
<div className={`right-panel-content ${type}`}>
|
||||
{renderRightPanelContent({ type, ...rest })}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
|
|
@ -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) => {
|
|||
</div>
|
||||
}
|
||||
|
||||
function reverseMap<T, O>(arg: T[], fn: (a: T) => O) {
|
||||
return arg.map((_, i, arr) => fn(arr[arr.length - i - 1]))
|
||||
}
|
||||
|
||||
export default RoomList
|
||||
|
|
19
web/src/util/reversemap.ts
Normal file
19
web/src/util/reversemap.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
export default function reverseMap<T, O>(arg: T[], fn: (a: T) => O) {
|
||||
return arg.map((_, i, arr) => fn(arr[arr.length - i - 1]))
|
||||
}
|
Loading…
Add table
Reference in a new issue