forked from Mirrors/gomuks
web/roomlist: add context menu for entries
This commit is contained in:
parent
2203c18e15
commit
bbce3df381
10 changed files with 135 additions and 18 deletions
|
@ -17,6 +17,7 @@ import (
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
"maunium.net/go/mautrix/pushrules"
|
||||||
|
|
||||||
"go.mau.fi/gomuks/pkg/hicli/database"
|
"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 unmarshalAndCall(req.Data, func(params *leaveRoomParams) (*mautrix.RespLeaveRoom, error) {
|
||||||
return h.Client.LeaveRoom(ctx, params.RoomID, &mautrix.ReqLeave{Reason: params.Reason})
|
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":
|
case "ensure_group_session_shared":
|
||||||
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
|
return unmarshalAndCall(req.Data, func(params *ensureGroupSessionSharedParams) (bool, error) {
|
||||||
return true, h.EnsureGroupSessionShared(ctx, params.RoomID)
|
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 unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
|
||||||
return true, h.DB.PushRegistration.Put(ctx, params)
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("unknown command %q", req.Command)
|
return nil, fmt.Errorf("unknown command %q", req.Command)
|
||||||
}
|
}
|
||||||
|
@ -393,3 +404,8 @@ type getReceiptsParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
EventIDs []id.EventID `json:"event_ids"`
|
EventIDs []id.EventID `json:"event_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type muteRoomParams struct {
|
||||||
|
RoomID id.RoomID `json:"room_id"`
|
||||||
|
Muted bool `json:"muted"`
|
||||||
|
}
|
||||||
|
|
|
@ -248,6 +248,14 @@ export default abstract class RPCClient {
|
||||||
return this.request("leave_room", { room_id, reason })
|
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> {
|
resolveAlias(alias: RoomAlias): Promise<ResolveAliasResponse> {
|
||||||
return this.request("resolve_alias", { alias })
|
return this.request("resolve_alias", { alias })
|
||||||
}
|
}
|
||||||
|
@ -279,8 +287,4 @@ export default abstract class RPCClient {
|
||||||
registerPush(reg: DBPushRegistration): Promise<boolean> {
|
registerPush(reg: DBPushRegistration): Promise<boolean> {
|
||||||
return this.request("register_push", reg)
|
return this.request("register_push", reg)
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoom(request: ReqCreateRoom): Promise<RespCreateRoom> {
|
|
||||||
return this.request("create_room", request)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -568,7 +568,7 @@ const MessageComposer = () => {
|
||||||
style.left = style.right
|
style.left = style.right
|
||||||
delete style.right
|
delete style.right
|
||||||
openModal({
|
openModal({
|
||||||
content: <div className="event-context-menu" style={style}>
|
content: <div className="context-menu event-context-menu" style={style}>
|
||||||
{makeAttachmentButtons(true)}
|
{makeAttachmentButtons(true)}
|
||||||
</div>,
|
</div>,
|
||||||
})
|
})
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// 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 { getRoomAvatarThumbnailURL } from "@/api/media.ts"
|
||||||
import type { RoomListEntry } from "@/api/statestore"
|
import type { RoomListEntry } from "@/api/statestore"
|
||||||
import type { MemDBEvent, MemberEventContent } from "@/api/types"
|
import type { MemDBEvent, MemberEventContent } from "@/api/types"
|
||||||
|
@ -21,6 +21,8 @@ import useContentVisibility from "@/util/contentvisibility.ts"
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname } from "@/util/validation.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
|
import { ModalContext } from "../modal"
|
||||||
|
import { RoomMenu, getModalStyleFromMouse } from "../timeline/menu"
|
||||||
import UnreadCount from "./UnreadCount.tsx"
|
import UnreadCount from "./UnreadCount.tsx"
|
||||||
|
|
||||||
export interface RoomListEntryProps {
|
export interface RoomListEntryProps {
|
||||||
|
@ -77,10 +79,29 @@ function renderEntry(room: RoomListEntry) {
|
||||||
|
|
||||||
const Entry = ({ room, isActive, hidden }: RoomListEntryProps) => {
|
const Entry = ({ room, isActive, hidden }: RoomListEntryProps) => {
|
||||||
const [isVisible, divRef] = useContentVisibility<HTMLDivElement>()
|
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
|
return <div
|
||||||
ref={divRef}
|
ref={divRef}
|
||||||
className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`}
|
className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`}
|
||||||
onClick={use(MainScreenContext).clickRoom}
|
onClick={mainScreen.clickRoom}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
data-room-id={room.room_id}
|
data-room-id={room.room_id}
|
||||||
>
|
>
|
||||||
{isVisible ? renderEntry(room) : null}
|
{isVisible ? renderEntry(room) : null}
|
||||||
|
|
|
@ -41,14 +41,14 @@ interface EventContextMenuProps extends BaseEventMenuProps {
|
||||||
|
|
||||||
export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
||||||
const elements = useSecondaryItems(use(ClientContext)!, roomCtx, evt)
|
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) => {
|
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
|
const primary = usePrimaryItems(client, roomCtx, evt, false, false, style, undefined)
|
||||||
const secondary = useSecondaryItems(client, roomCtx, evt)
|
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}
|
{primary}
|
||||||
<hr/>
|
<hr/>
|
||||||
{secondary}
|
{secondary}
|
||||||
|
|
3
web/src/ui/timeline/menu/RoomMenu.css
Normal file
3
web/src/ui/timeline/menu/RoomMenu.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
div.room-list-menu {
|
||||||
|
|
||||||
|
}
|
68
web/src/ui/timeline/menu/RoomMenu.tsx
Normal file
68
web/src/ui/timeline/menu/RoomMenu.tsx
Normal 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>
|
||||||
|
}
|
|
@ -43,7 +43,7 @@ div.event-fixed-menu {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.event-context-menu {
|
div.context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border-radius: .5rem;
|
border-radius: .5rem;
|
||||||
|
@ -80,6 +80,10 @@ div.event-context-menu {
|
||||||
color: var(--error-color);
|
color: var(--error-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.event-context-menu, &.room-list-menu {
|
||||||
|
width: 10rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.confirm-message-modal > form {
|
div.confirm-message-modal > form {
|
||||||
|
|
|
@ -14,4 +14,5 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
|
export { EventExtraMenu, EventFixedMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
|
||||||
|
export { RoomMenu } from "./RoomMenu.tsx"
|
||||||
export { getModalStyleFromMouse } from "./util.ts"
|
export { getModalStyleFromMouse } from "./util.ts"
|
||||||
|
|
|
@ -39,10 +39,10 @@ export const getEncryption = (room: RoomStateStore): boolean =>{
|
||||||
export function getModalStyleFromMouse(
|
export function getModalStyleFromMouse(
|
||||||
evt: React.MouseEvent, modalHeight: number, modalWidth = 10 * 16,
|
evt: React.MouseEvent, modalHeight: number, modalWidth = 10 * 16,
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const style: CSSProperties = { right: window.innerWidth - evt.clientX }
|
const style: CSSProperties = { left: evt.clientX }
|
||||||
if (evt.clientX - modalWidth < 4) {
|
if (evt.clientX + modalWidth > window.innerWidth) {
|
||||||
delete style.right
|
delete style.left
|
||||||
style.left = "4px"
|
style.right = "4px"
|
||||||
}
|
}
|
||||||
if (evt.clientY + modalHeight > window.innerHeight) {
|
if (evt.clientY + modalHeight > window.innerHeight) {
|
||||||
style.bottom = window.innerHeight - evt.clientY
|
style.bottom = window.innerHeight - evt.clientY
|
||||||
|
|
Loading…
Add table
Reference in a new issue