web/roomlist: add context menu for entries

This commit is contained in:
Tulir Asokan 2025-02-25 21:44:01 +02:00
parent 2203c18e15
commit bbce3df381
10 changed files with 135 additions and 18 deletions

View file

@ -17,6 +17,7 @@ import (
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
"go.mau.fi/gomuks/pkg/hicli/database"
)
@ -167,6 +168,20 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) {
return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason})
})
case "create_room":
return unmarshalAndCall(req.Data, func(params *mautrix.ReqCreateRoom) (*mautrix.RespCreateRoom, error) {
return h.Client.CreateRoom(ctx, params)
})
case "mute_room":
return unmarshalAndCall(req.Data, func(params *muteRoomParams) (bool, error) {
if params.Muted {
return true, h.Client.PutPushRule(ctx, "global", pushrules.RoomRule, string(params.RoomID), &mautrix.ReqPutPushRule{
Actions: []pushrules.PushActionType{},
})
} else {
return false, h.Client.DeletePushRule(ctx, "global", pushrules.RoomRule, string(params.RoomID))
}
})
case "ensure_group_session_shared":
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
return true, h.EnsureGroupSessionShared(ctx, params.RoomID)
@ -223,10 +238,6 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
return true, h.DB.PushRegistration.Put(ctx, params)
})
case "create_room":
return unmarshalAndCall(req.Data, func(params *mautrix.ReqCreateRoom) (*mautrix.RespCreateRoom, error) {
return h.Client.CreateRoom(ctx, params)
})
default:
return nil, fmt.Errorf("unknown command %q", req.Command)
}
@ -393,3 +404,8 @@ type getReceiptsParams struct {
RoomID id.RoomID `json:"room_id"`
EventIDs []id.EventID `json:"event_ids"`
}
type muteRoomParams struct {
RoomID id.RoomID `json:"room_id"`
Muted bool `json:"muted"`
}

View file

@ -248,6 +248,14 @@ export default abstract class RPCClient {
return this.request("leave_room", { room_id, reason })
}
createRoom(request: ReqCreateRoom): Promise<RespCreateRoom> {
return this.request("create_room", request)
}
muteRoom(room_id: RoomID, muted: boolean): Promise<boolean> {
return this.request("mute_room", { room_id, muted })
}
resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> {
return this.request("resolve_alias", { alias })
}
@ -279,8 +287,4 @@ export default abstract class RPCClient {
registerPush(reg: DBPushRegistration): Promise<boolean> {
return this.request("register_push", reg)
}
createRoom(request: ReqCreateRoom): Promise<RespCreateRoom> {
return this.request("create_room", request)
}
}

View file

@ -568,7 +568,7 @@ const MessageComposer = () => {
style.left = style.right
delete style.right
openModal({
content: <div className="event-context-menu" style={style}>
content: <div className="context-menu event-context-menu" style={style}>
{makeAttachmentButtons(true)}
</div>,
})

View file

@ -13,7 +13,7 @@
//
// 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, memo, use } from "react"
import React, { JSX, memo, use } from "react"
import { getRoomAvatarThumbnailURL } from "@/api/media.ts"
import type { RoomListEntry } from "@/api/statestore"
import type { MemDBEvent, MemberEventContent } from "@/api/types"
@ -21,6 +21,8 @@ import useContentVisibility from "@/util/contentvisibility.ts"
import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts"
import { ModalContext } from "../modal"
import { RoomMenu, getModalStyleFromMouse } from "../timeline/menu"
import UnreadCount from "./UnreadCount.tsx"
export interface RoomListEntryProps {
@ -77,10 +79,29 @@ function renderEntry(room: RoomListEntry) {
const Entry = ({ room, isActive, hidden }: RoomListEntryProps) => {
const [isVisible, divRef] = useContentVisibility<HTMLDivElement>()
const openModal = use(ModalContext)
const mainScreen = use(MainScreenContext)
const client = use(ClientContext)!
const onContextMenu = (evt: React.MouseEvent<HTMLDivElement>) => {
const realRoom = client.store.rooms.get(room.room_id)
if (!realRoom) {
console.error("Room state store not found for", room.room_id)
return
}
openModal({
content: <RoomMenu
room={realRoom}
entry={room}
style={getModalStyleFromMouse(evt, 6 * 40)}
/>,
})
evt.preventDefault()
}
return <div
ref={divRef}
className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`}
onClick={use(MainScreenContext).clickRoom}
onClick={mainScreen.clickRoom}
onContextMenu={onContextMenu}
data-room-id={room.room_id}
>
{isVisible ? renderEntry(room) : null}

View file

@ -41,14 +41,14 @@ interface EventContextMenuProps extends BaseEventMenuProps {
export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
const elements = useSecondaryItems(use(ClientContext)!, roomCtx, evt)
return <div style={style} className="event-context-menu extra">{elements}</div>
return <div style={style} className="context-menu event-context-menu extra">{elements}</div>
}
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
const client = use(ClientContext)!
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
const secondary = useSecondaryItems(client, roomCtx, evt)
return <div style={style} className="event-context-menu full">
return <div style={style} className="context-menu event-context-menu full">
{primary}
<hr/>
{secondary}

View file

@ -0,0 +1,3 @@
div.room-list-menu {
}

View file

@ -0,0 +1,68 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 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 { CSSProperties, use } from "react"
import { RoomListEntry, RoomStateStore, useAccountData } from "@/api/statestore"
import { RoomID } from "@/api/types"
import ClientContext from "../../ClientContext.ts"
import { ModalContext } from "../../modal"
import SettingsView from "../../settings/SettingsView.tsx"
import NotificationsOffIcon from "@/icons/notifications-off.svg?react"
import NotificationsIcon from "@/icons/notifications.svg?react"
import SettingsIcon from "@/icons/settings.svg?react"
import "./RoomMenu.css"
interface RoomMenuProps {
room: RoomStateStore
entry: RoomListEntry
style: CSSProperties
}
const hasNotifyingActions = (actions: unknown) => {
return Array.isArray(actions) && actions.length > 0 && actions.includes("notify")
}
const MuteButton = ({ roomID }: { roomID: RoomID }) => {
const client = use(ClientContext)!
const roomRules = useAccountData(client.store, "m.push_rules")?.global?.room
const pushRule = Array.isArray(roomRules) ? roomRules.find(rule => rule?.rule_id === roomID) : null
const muted = pushRule?.enabled === true && !hasNotifyingActions(pushRule.actions)
const toggleMute = () => {
client.rpc.muteRoom(roomID, !muted).catch(err => {
console.error("Failed to mute room", err)
window.alert(`Failed to ${muted ? "unmute" : "mute"} room: ${err}`)
})
}
return <button onClick={toggleMute}>
{muted ? <NotificationsIcon/> : <NotificationsOffIcon/>}
{muted ? "Unmute" : "Mute"}
</button>
}
export const RoomMenu = ({ room, style }: RoomMenuProps) => {
const openModal = use(ModalContext)
const openSettings = () => {
openModal({
dimmed: true,
boxed: true,
innerBoxClass: "settings-view",
content: <SettingsView room={room} />,
})
}
return <div className="context-menu room-list-menu" style={style}>
<MuteButton roomID={room.roomID}/>
<button onClick={openSettings}><SettingsIcon /> Settings</button>
</div>
}

View file

@ -43,7 +43,7 @@ div.event-fixed-menu {
}
}
div.event-context-menu {
div.context-menu {
position: fixed;
background-color: var(--background-color);
border-radius: .5rem;
@ -80,6 +80,10 @@ div.event-context-menu {
color: var(--error-color);
}
}
&.event-context-menu, &.room-list-menu {
width: 10rem;
}
}
div.confirm-message-modal > form {

View file

@ -14,4 +14,5 @@
// 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 { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
export { RoomMenu } from "./RoomMenu.tsx"
export { getModalStyleFromMouse } from "./util.ts"

View file

@ -39,10 +39,10 @@ export const getEncryption = (room: RoomStateStore): boolean =>{
export function getModalStyleFromMouse(
evt: React.MouseEvent, modalHeight: number, modalWidth = 10 * 16,
): CSSProperties {
const style: CSSProperties = { right: window.innerWidth - evt.clientX }
if (evt.clientX - modalWidth < 4) {
delete style.right
style.left = "4px"
const style: CSSProperties = { left: evt.clientX }
if (evt.clientX + modalWidth > window.innerWidth) {
delete style.left
style.right = "4px"
}
if (evt.clientY + modalHeight > window.innerHeight) {
style.bottom = window.innerHeight - evt.clientY