mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/timeline: add view source, report and redact event buttons to menu
This commit is contained in:
parent
89a638a850
commit
e8f2029dbb
13 changed files with 457 additions and 148 deletions
|
@ -47,6 +47,16 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
|
||||||
return h.Send(ctx, params.RoomID, params.EventType, params.Content)
|
return h.Send(ctx, params.RoomID, params.EventType, params.Content)
|
||||||
})
|
})
|
||||||
|
case "report_event":
|
||||||
|
return unmarshalAndCall(req.Data, func(params *reportEventParams) (bool, error) {
|
||||||
|
return true, h.Client.ReportEvent(ctx, params.RoomID, params.EventID, params.Reason)
|
||||||
|
})
|
||||||
|
case "redact_event":
|
||||||
|
return unmarshalAndCall(req.Data, func(params *redactEventParams) (*mautrix.RespSendEvent, error) {
|
||||||
|
return h.Client.RedactEvent(ctx, params.RoomID, params.EventID, mautrix.ReqRedact{
|
||||||
|
Reason: params.Reason,
|
||||||
|
})
|
||||||
|
})
|
||||||
case "set_state":
|
case "set_state":
|
||||||
return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
|
return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
|
||||||
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content)
|
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content)
|
||||||
|
@ -148,6 +158,18 @@ type sendEventParams struct {
|
||||||
Content json.RawMessage `json:"content"`
|
Content json.RawMessage `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reportEventParams struct {
|
||||||
|
RoomID id.RoomID `json:"room_id"`
|
||||||
|
EventID id.EventID `json:"event_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type redactEventParams struct {
|
||||||
|
RoomID id.RoomID `json:"room_id"`
|
||||||
|
EventID id.EventID `json:"event_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
type sendStateEventParams struct {
|
type sendStateEventParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
EventType event.Type `json:"type"`
|
EventType event.Type `json:"type"`
|
||||||
|
|
|
@ -133,6 +133,14 @@ export default abstract class RPCClient {
|
||||||
return this.request("send_event", { room_id, type, content })
|
return this.request("send_event", { room_id, type, content })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportEvent(room_id: RoomID, event_id: EventID, reason: string): Promise<boolean> {
|
||||||
|
return this.request("report_event", { room_id, event_id, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
redactEvent(room_id: RoomID, event_id: EventID, reason: string): Promise<boolean> {
|
||||||
|
return this.request("redact_event", { room_id, event_id, reason })
|
||||||
|
}
|
||||||
|
|
||||||
setState(
|
setState(
|
||||||
room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>,
|
room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>,
|
||||||
): Promise<EventID> {
|
): Promise<EventID> {
|
||||||
|
|
1
web/src/icons/code.svg
Normal file
1
web/src/icons/code.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M320-240 80-480l240-240 57 57-184 184 183 183-56 56Zm320 0-57-57 184-184-183-183 56-56 240 240-240 240Z"/></svg>
|
After Width: | Height: | Size: 229 B |
1
web/src/icons/report.svg
Normal file
1
web/src/icons/report.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240ZM330-120 120-330v-300l210-210h300l210 210v300L630-120H330Zm34-80h232l164-164v-232L596-760H364L200-596v232l164 164Zm116-280Z"/></svg>
|
After Width: | Height: | Size: 376 B |
|
@ -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 React, { JSX, createContext, useCallback, useState } from "react"
|
import React, { JSX, createContext, useCallback, useReducer } from "react"
|
||||||
|
|
||||||
export interface ModalState {
|
export interface ModalState {
|
||||||
content: JSX.Element
|
content: JSX.Element
|
||||||
|
@ -30,25 +30,27 @@ export const ModalContext = createContext<openModal>(() =>
|
||||||
export const ModalCloseContext = createContext<() => void>(() => {})
|
export const ModalCloseContext = createContext<() => void>(() => {})
|
||||||
|
|
||||||
export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
const [state, setState] = useState<ModalState | null>(null)
|
const [state, setState] = useReducer((prevState: ModalState | null, newState: ModalState | null) => {
|
||||||
|
prevState?.onClose?.()
|
||||||
|
return newState
|
||||||
|
}, null)
|
||||||
const onClickWrapper = useCallback((evt?: React.MouseEvent) => {
|
const onClickWrapper = useCallback((evt?: React.MouseEvent) => {
|
||||||
if (evt && evt.target !== evt.currentTarget) {
|
if (evt && evt.target !== evt.currentTarget) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setState(null)
|
setState(null)
|
||||||
state?.onClose?.()
|
}, [])
|
||||||
}, [state])
|
|
||||||
return <>
|
return <>
|
||||||
<ModalContext value={setState}>
|
<ModalContext value={setState}>
|
||||||
{children}
|
{children}
|
||||||
|
{state && <div
|
||||||
|
className={`overlay ${state.wrapperClass ?? "modal"} ${state.dimmed ? "dimmed" : ""}`}
|
||||||
|
onClick={onClickWrapper}
|
||||||
|
>
|
||||||
|
<ModalCloseContext value={onClickWrapper}>
|
||||||
|
{state.content}
|
||||||
|
</ModalCloseContext>
|
||||||
|
</div>}
|
||||||
</ModalContext>
|
</ModalContext>
|
||||||
{state && <div
|
|
||||||
className={`overlay ${state.wrapperClass ?? "modal"} ${state.dimmed ? "dimmed" : ""}`}
|
|
||||||
onClick={onClickWrapper}
|
|
||||||
>
|
|
||||||
<ModalCloseContext value={onClickWrapper}>
|
|
||||||
{state.content}
|
|
||||||
</ModalCloseContext>
|
|
||||||
</div>}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
div.context-menu {
|
|
||||||
position: absolute;
|
|
||||||
right: .5rem;
|
|
||||||
top: -1.5rem;
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: .25rem;
|
|
||||||
display: flex;
|
|
||||||
gap: .25rem;
|
|
||||||
padding: .125rem;
|
|
||||||
|
|
||||||
> button {
|
|
||||||
padding: 0;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,112 +0,0 @@
|
||||||
// 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 { CSSProperties, use, useCallback, useRef } from "react"
|
|
||||||
import { useRoomState } from "@/api/statestore"
|
|
||||||
import { MemDBEvent, PowerLevelEventContent } from "@/api/types"
|
|
||||||
import { emojiToReactionContent } from "@/util/emoji"
|
|
||||||
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
|
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
|
||||||
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
|
|
||||||
import { ModalContext } from "../modal/Modal.tsx"
|
|
||||||
import { useRoomContext } from "../roomcontext.ts"
|
|
||||||
import EditIcon from "../../icons/edit.svg?react"
|
|
||||||
import MoreIcon from "../../icons/more.svg?react"
|
|
||||||
import PinIcon from "../../icons/pin.svg?react"
|
|
||||||
import ReactIcon from "../../icons/react.svg?react"
|
|
||||||
import ReplyIcon from "../../icons/reply.svg?react"
|
|
||||||
import UnpinIcon from "../../icons/unpin.svg?react"
|
|
||||||
import "./EventMenu.css"
|
|
||||||
|
|
||||||
interface EventHoverMenuProps {
|
|
||||||
evt: MemDBEvent
|
|
||||||
setForceOpen: (forceOpen: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
|
|
||||||
const client = use(ClientContext)!
|
|
||||||
const userID = client.userID
|
|
||||||
const roomCtx = useRoomContext()
|
|
||||||
const openModal = use(ModalContext)
|
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
|
||||||
const onClickReply = useCallback(() => roomCtx.setReplyTo(evt.event_id), [roomCtx, evt.event_id])
|
|
||||||
const onClickPin = useCallback(() => {
|
|
||||||
client.pinMessage(roomCtx.store, evt.event_id, true)
|
|
||||||
.catch(err => window.alert(`Failed to pin message: ${err}`))
|
|
||||||
}, [client, roomCtx, evt.event_id])
|
|
||||||
const onClickUnpin = useCallback(() => {
|
|
||||||
client.pinMessage(roomCtx.store, evt.event_id, false)
|
|
||||||
.catch(err => window.alert(`Failed to unpin message: ${err}`))
|
|
||||||
}, [client, roomCtx, evt.event_id])
|
|
||||||
const onClickReact = useCallback(() => {
|
|
||||||
const reactionButton = contextMenuRef.current?.getElementsByClassName("reaction-button")?.[0]
|
|
||||||
if (!reactionButton) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const rect = reactionButton.getBoundingClientRect()
|
|
||||||
const style: CSSProperties = { right: window.innerWidth - rect.right }
|
|
||||||
const emojiPickerHeight = 30 * 16
|
|
||||||
if (rect.bottom + emojiPickerHeight > window.innerHeight) {
|
|
||||||
style.bottom = window.innerHeight - rect.top
|
|
||||||
} else {
|
|
||||||
style.top = rect.bottom
|
|
||||||
}
|
|
||||||
setForceOpen(true)
|
|
||||||
openModal({
|
|
||||||
content: <EmojiPicker
|
|
||||||
style={style}
|
|
||||||
onSelect={emoji => {
|
|
||||||
client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id))
|
|
||||||
.catch(err => window.alert(`Failed to send reaction: ${err}`))
|
|
||||||
}}
|
|
||||||
room={roomCtx.store}
|
|
||||||
closeOnSelect={true}
|
|
||||||
allowFreeform={true}
|
|
||||||
/>,
|
|
||||||
onClose: () => setForceOpen(false),
|
|
||||||
})
|
|
||||||
}, [client, roomCtx, evt, setForceOpen, openModal])
|
|
||||||
const onClickEdit = useCallback(() => {
|
|
||||||
roomCtx.setEditing(evt)
|
|
||||||
}, [roomCtx, evt])
|
|
||||||
const onClickMore = useCallback(() => {
|
|
||||||
window.alert("Nothing here yet :(")
|
|
||||||
}, [])
|
|
||||||
const isEditing = useNonNullEventAsState(roomCtx.isEditing)
|
|
||||||
const plEvent = useRoomState(roomCtx.store, "m.room.power_levels", "")
|
|
||||||
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
|
|
||||||
useRoomState(roomCtx.store, "m.room.pinned_events", "")
|
|
||||||
const pls = (plEvent?.content ?? {}) as PowerLevelEventContent
|
|
||||||
const pins = roomCtx.store.getPinnedEvents()
|
|
||||||
const ownPL = pls.users?.[userID] ?? pls.users_default ?? 0
|
|
||||||
const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50
|
|
||||||
return <div className="context-menu" ref={contextMenuRef}>
|
|
||||||
<button className="reaction-button" onClick={onClickReact}><ReactIcon/></button>
|
|
||||||
<button
|
|
||||||
className="reply-button"
|
|
||||||
disabled={isEditing}
|
|
||||||
title={isEditing ? "Can't reply to messages while editing a message" : undefined}
|
|
||||||
onClick={onClickReply}
|
|
||||||
><ReplyIcon/></button>
|
|
||||||
{evt.sender === userID && evt.type === "m.room.message" && evt.relation_type !== "m.replace"
|
|
||||||
&& <button className="edit-button" onClick={onClickEdit}><EditIcon/></button>}
|
|
||||||
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
|
||||||
? <button className="unpin-button" onClick={onClickUnpin}><UnpinIcon/></button>
|
|
||||||
: <button className="pin-button" onClick={onClickPin}><PinIcon/></button>)}
|
|
||||||
<button className="more-button" onClick={onClickMore}><MoreIcon/></button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EventMenu
|
|
|
@ -21,9 +21,9 @@ import { isEventID } from "@/util/validation.ts"
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
import { ClientContext } from "../ClientContext.ts"
|
||||||
import { LightboxContext } from "../modal/Lightbox.tsx"
|
import { LightboxContext } from "../modal/Lightbox.tsx"
|
||||||
import { useRoomContext } from "../roomcontext.ts"
|
import { useRoomContext } from "../roomcontext.ts"
|
||||||
import EventMenu from "./EventMenu.tsx"
|
|
||||||
import { ReplyIDBody } from "./ReplyBody.tsx"
|
import { ReplyIDBody } from "./ReplyBody.tsx"
|
||||||
import getBodyType, { ContentErrorBoundary, EventContentProps, HiddenEvent, MemberBody } from "./content"
|
import getBodyType, { ContentErrorBoundary, EventContentProps, HiddenEvent, MemberBody } from "./content"
|
||||||
|
import EventMenu from "./menu/EventMenu.tsx"
|
||||||
import ErrorIcon from "../../icons/error.svg?react"
|
import ErrorIcon from "../../icons/error.svg?react"
|
||||||
import PendingIcon from "../../icons/pending.svg?react"
|
import PendingIcon from "../../icons/pending.svg?react"
|
||||||
import SentIcon from "../../icons/sent.svg?react"
|
import SentIcon from "../../icons/sent.svg?react"
|
||||||
|
@ -32,6 +32,7 @@ import "./TimelineEvent.css"
|
||||||
export interface TimelineEventProps {
|
export interface TimelineEventProps {
|
||||||
evt: MemDBEvent
|
evt: MemDBEvent
|
||||||
prevEvt: MemDBEvent | null
|
prevEvt: MemDBEvent | null
|
||||||
|
disableMenu?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
|
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
|
||||||
|
@ -67,7 +68,7 @@ function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps>): boo
|
||||||
return bodyType === HiddenEvent || bodyType === MemberBody
|
return bodyType === HiddenEvent || bodyType === MemberBody
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => {
|
const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
|
||||||
const roomCtx = useRoomContext()
|
const roomCtx = useRoomContext()
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
|
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
|
||||||
|
@ -96,9 +97,9 @@ const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => {
|
||||||
const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"]
|
const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"]
|
||||||
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
|
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
|
||||||
const mainEvent = <div data-event-id={evt.event_id} className={wrapperClassNames.join(" ")}>
|
const mainEvent = <div data-event-id={evt.event_id} className={wrapperClassNames.join(" ")}>
|
||||||
<div className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}>
|
{!disableMenu && <div className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}>
|
||||||
<EventMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
|
<EventMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
|
||||||
</div>
|
</div>}
|
||||||
<div className="sender-avatar" title={evt.sender}>
|
<div className="sender-avatar" title={evt.sender}>
|
||||||
<img
|
<img
|
||||||
className={`${smallAvatar ? "small" : ""} avatar`}
|
className={`${smallAvatar ? "small" : ""} avatar`}
|
||||||
|
|
59
web/src/ui/timeline/menu/ConfirmWithMessageModal.tsx
Normal file
59
web/src/ui/timeline/menu/ConfirmWithMessageModal.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
// 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, useCallback, useState } from "react"
|
||||||
|
import { MemDBEvent } from "@/api/types"
|
||||||
|
import useEvent from "@/util/useEvent.ts"
|
||||||
|
import { ModalCloseContext } from "../../modal/Modal.tsx"
|
||||||
|
import TimelineEvent from "../TimelineEvent.tsx"
|
||||||
|
|
||||||
|
interface ConfirmWithMessageProps {
|
||||||
|
evt: MemDBEvent
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
placeholder: string
|
||||||
|
confirmButton: string
|
||||||
|
onConfirm: (reason: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmWithMessageProps = ({
|
||||||
|
evt, title, description, placeholder, confirmButton, onConfirm,
|
||||||
|
}: ConfirmWithMessageProps) => {
|
||||||
|
const [reason, setReason] = useState("")
|
||||||
|
const closeModal = use(ModalCloseContext)
|
||||||
|
const onConfirmWrapped = useEvent(() => {
|
||||||
|
closeModal()
|
||||||
|
onConfirm(reason)
|
||||||
|
})
|
||||||
|
const onChangeReason = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setReason(evt.target.value)
|
||||||
|
}, [])
|
||||||
|
return <div className="confirm-message-modal">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<div className="timeline-event-container">
|
||||||
|
<TimelineEvent evt={evt} prevEvt={null} disableMenu={true} />
|
||||||
|
</div>
|
||||||
|
<div className="confirm-description">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
<input value={reason} type="text" placeholder={placeholder} onChange={onChangeReason} />
|
||||||
|
<div className="confirm-buttons">
|
||||||
|
<button onClick={closeModal}>Cancel</button>
|
||||||
|
<button onClick={onConfirmWrapped}>{confirmButton}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmWithMessageProps
|
112
web/src/ui/timeline/menu/EventExtraMenu.tsx
Normal file
112
web/src/ui/timeline/menu/EventExtraMenu.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
// 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 { CSSProperties, use, useCallback } from "react"
|
||||||
|
import { RoomStateStore, useRoomState } from "@/api/statestore"
|
||||||
|
import { MemDBEvent, PowerLevelEventContent } from "@/api/types"
|
||||||
|
import { ClientContext } from "../../ClientContext.ts"
|
||||||
|
import { ModalCloseContext, ModalContext } from "../../modal/Modal.tsx"
|
||||||
|
import { RoomContext, RoomContextData } from "../../roomcontext.ts"
|
||||||
|
import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx"
|
||||||
|
import ViewSourceModal from "./ViewSourceModal.tsx"
|
||||||
|
import ViewSourceIcon from "@/icons/code.svg?react"
|
||||||
|
import DeleteIcon from "@/icons/delete.svg?react"
|
||||||
|
import PinIcon from "@/icons/pin.svg?react"
|
||||||
|
import ReportIcon from "@/icons/report.svg?react"
|
||||||
|
import UnpinIcon from "@/icons/unpin.svg?react"
|
||||||
|
|
||||||
|
interface EventExtraMenuProps {
|
||||||
|
evt: MemDBEvent
|
||||||
|
room: RoomStateStore
|
||||||
|
style: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => {
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
const userID = client.userID
|
||||||
|
const closeModal = use(ModalCloseContext)
|
||||||
|
const openModal = use(ModalContext)
|
||||||
|
const onClickViewSource = useCallback(() => {
|
||||||
|
openModal({ dimmed: true, content: <ViewSourceModal evt={evt}/> })
|
||||||
|
}, [evt, openModal])
|
||||||
|
const onClickReport = useCallback(() => {
|
||||||
|
openModal({
|
||||||
|
dimmed: true,
|
||||||
|
content: <RoomContext value={new RoomContextData(room)}>
|
||||||
|
<ConfirmWithMessageModal
|
||||||
|
evt={evt}
|
||||||
|
title="Report Message"
|
||||||
|
description="Report this message to your homeserver administrator?"
|
||||||
|
placeholder="Reason for report"
|
||||||
|
confirmButton="Send report"
|
||||||
|
onConfirm={reason => {
|
||||||
|
client.rpc.reportEvent(evt.room_id, evt.event_id, reason)
|
||||||
|
.catch(err => window.alert(`Failed to report message: ${err}`))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</RoomContext>,
|
||||||
|
})
|
||||||
|
}, [evt, room, openModal, client])
|
||||||
|
const onClickRedact = useCallback(() => {
|
||||||
|
openModal({
|
||||||
|
dimmed: true,
|
||||||
|
content: <RoomContext value={new RoomContextData(room)}>
|
||||||
|
<ConfirmWithMessageModal
|
||||||
|
evt={evt}
|
||||||
|
title="Remove Message"
|
||||||
|
description="Permanently remove the content of this event?"
|
||||||
|
placeholder="Reason for removal"
|
||||||
|
confirmButton="Remove"
|
||||||
|
onConfirm={reason => {
|
||||||
|
client.rpc.redactEvent(evt.room_id, evt.event_id, reason)
|
||||||
|
.catch(err => window.alert(`Failed to redact message: ${err}`))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</RoomContext>,
|
||||||
|
})
|
||||||
|
}, [evt, room, openModal, client])
|
||||||
|
const onClickPin = useCallback(() => {
|
||||||
|
closeModal()
|
||||||
|
client.pinMessage(room, evt.event_id, true)
|
||||||
|
.catch(err => window.alert(`Failed to pin message: ${err}`))
|
||||||
|
}, [closeModal, client, room, evt.event_id])
|
||||||
|
const onClickUnpin = useCallback(() => {
|
||||||
|
closeModal()
|
||||||
|
client.pinMessage(room, evt.event_id, false)
|
||||||
|
.catch(err => window.alert(`Failed to unpin message: ${err}`))
|
||||||
|
}, [closeModal, client, room, evt.event_id])
|
||||||
|
|
||||||
|
const plEvent = useRoomState(room, "m.room.power_levels", "")
|
||||||
|
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
|
||||||
|
useRoomState(room, "m.room.pinned_events", "")
|
||||||
|
const pls = (plEvent?.content ?? {}) as PowerLevelEventContent
|
||||||
|
const pins = room.getPinnedEvents()
|
||||||
|
const ownPL = pls.users?.[userID] ?? pls.users_default ?? 0
|
||||||
|
const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50
|
||||||
|
const redactEvtPL = pls.events?.["m.room.redaction"] ?? pls.events_default ?? 0
|
||||||
|
const redactOtherPL = pls.redact ?? 50
|
||||||
|
const canRedact = !evt.redacted_by && ownPL >= redactEvtPL && (evt.sender === userID || ownPL >= redactOtherPL)
|
||||||
|
|
||||||
|
return <div style={style} className="event-context-menu-extra">
|
||||||
|
<button onClick={onClickViewSource}><ViewSourceIcon/>View source</button>
|
||||||
|
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
||||||
|
? <button onClick={onClickUnpin}><UnpinIcon/>Unpin message</button>
|
||||||
|
: <button onClick={onClickPin}><PinIcon/>Pin message</button>)}
|
||||||
|
<button onClick={onClickReport}><ReportIcon/>Report</button>
|
||||||
|
{canRedact && <button onClick={onClickRedact} className="redact-button"><DeleteIcon/>Remove</button>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventExtraMenu
|
100
web/src/ui/timeline/menu/EventMenu.tsx
Normal file
100
web/src/ui/timeline/menu/EventMenu.tsx
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
// 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 { CSSProperties, use, useCallback, useRef } from "react"
|
||||||
|
import { MemDBEvent } from "@/api/types"
|
||||||
|
import { emojiToReactionContent } from "@/util/emoji"
|
||||||
|
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
|
import { ClientContext } from "../../ClientContext.ts"
|
||||||
|
import EmojiPicker from "../../emojipicker/EmojiPicker.tsx"
|
||||||
|
import { ModalContext } from "../../modal/Modal.tsx"
|
||||||
|
import { useRoomContext } from "../../roomcontext.ts"
|
||||||
|
import EventExtraMenu from "./EventExtraMenu.tsx"
|
||||||
|
import EditIcon from "@/icons/edit.svg?react"
|
||||||
|
import MoreIcon from "@/icons/more.svg?react"
|
||||||
|
import ReactIcon from "@/icons/react.svg?react"
|
||||||
|
import ReplyIcon from "@/icons/reply.svg?react"
|
||||||
|
import "./index.css"
|
||||||
|
|
||||||
|
interface EventHoverMenuProps {
|
||||||
|
evt: MemDBEvent
|
||||||
|
setForceOpen: (forceOpen: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModalStyle(button: HTMLButtonElement, modalHeight: number): CSSProperties {
|
||||||
|
const rect = button.getBoundingClientRect()
|
||||||
|
const style: CSSProperties = { right: window.innerWidth - rect.right }
|
||||||
|
if (rect.bottom + modalHeight > window.innerHeight) {
|
||||||
|
style.bottom = window.innerHeight - rect.top
|
||||||
|
} else {
|
||||||
|
style.top = rect.bottom
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
const userID = client.userID
|
||||||
|
const roomCtx = useRoomContext()
|
||||||
|
const openModal = use(ModalContext)
|
||||||
|
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const onClickReply = useCallback(() => roomCtx.setReplyTo(evt.event_id), [roomCtx, evt.event_id])
|
||||||
|
const onClickReact = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
const emojiPickerHeight = 34 * 16
|
||||||
|
setForceOpen(true)
|
||||||
|
openModal({
|
||||||
|
content: <EmojiPicker
|
||||||
|
style={getModalStyle(mevt.currentTarget, emojiPickerHeight)}
|
||||||
|
onSelect={emoji => {
|
||||||
|
client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id))
|
||||||
|
.catch(err => window.alert(`Failed to send reaction: ${err}`))
|
||||||
|
}}
|
||||||
|
room={roomCtx.store}
|
||||||
|
closeOnSelect={true}
|
||||||
|
allowFreeform={true}
|
||||||
|
/>,
|
||||||
|
onClose: () => setForceOpen(false),
|
||||||
|
})
|
||||||
|
}, [client, roomCtx, evt, setForceOpen, openModal])
|
||||||
|
const onClickEdit = useCallback(() => {
|
||||||
|
roomCtx.setEditing(evt)
|
||||||
|
}, [roomCtx, evt])
|
||||||
|
const onClickMore = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
const moreMenuHeight = 10 * 16
|
||||||
|
setForceOpen(true)
|
||||||
|
openModal({
|
||||||
|
content: <EventExtraMenu
|
||||||
|
evt={evt}
|
||||||
|
room={roomCtx.store}
|
||||||
|
style={getModalStyle(mevt.currentTarget, moreMenuHeight)}
|
||||||
|
/>,
|
||||||
|
onClose: () => setForceOpen(false),
|
||||||
|
})
|
||||||
|
}, [evt, roomCtx, setForceOpen, openModal])
|
||||||
|
const isEditing = useNonNullEventAsState(roomCtx.isEditing)
|
||||||
|
return <div className="event-hover-menu" ref={contextMenuRef}>
|
||||||
|
<button onClick={onClickReact}><ReactIcon/></button>
|
||||||
|
<button
|
||||||
|
disabled={isEditing}
|
||||||
|
title={isEditing ? "Can't reply to messages while editing a message" : undefined}
|
||||||
|
onClick={onClickReply}
|
||||||
|
><ReplyIcon/></button>
|
||||||
|
{evt.sender === userID && evt.type === "m.room.message" && evt.relation_type !== "m.replace" && !evt.redacted_by
|
||||||
|
&& <button onClick={onClickEdit}><EditIcon/></button>}
|
||||||
|
<button onClick={onClickMore}><MoreIcon/></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventMenu
|
30
web/src/ui/timeline/menu/ViewSourceModal.tsx
Normal file
30
web/src/ui/timeline/menu/ViewSourceModal.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
// 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 { MemDBEvent } from "@/api/types"
|
||||||
|
|
||||||
|
interface ViewSourceModalProps {
|
||||||
|
evt: MemDBEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const ViewSourceModal = ({ evt }: ViewSourceModalProps) => {
|
||||||
|
return <div className="view-source-modal">
|
||||||
|
<pre>
|
||||||
|
{JSON.stringify(evt, null, " ")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ViewSourceModal
|
105
web/src/ui/timeline/menu/index.css
Normal file
105
web/src/ui/timeline/menu/index.css
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
div.event-hover-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: .5rem;
|
||||||
|
top: -1.5rem;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: .5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
padding: .125rem;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.event-context-menu-extra {
|
||||||
|
position: fixed;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: .5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
border-radius: 0;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: left;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-radius: .5rem .5rem 0 0;
|
||||||
|
}
|
||||||
|
&:last-of-type {
|
||||||
|
border-radius: 0 0 .5rem .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.redact-button {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.view-source-modal {
|
||||||
|
max-width: min(80rem, 80vw);
|
||||||
|
max-height: min(80rem, 80vh);
|
||||||
|
overflow: auto;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
> pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.confirm-message-modal {
|
||||||
|
width: min(40rem, 80vw);
|
||||||
|
max-height: min(40rem, 80vh);
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
|
||||||
|
> h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div.timeline-event-container {
|
||||||
|
margin: .5rem 0;
|
||||||
|
padding-left: .5rem;
|
||||||
|
border-left: 2px solid #ccc;
|
||||||
|
|
||||||
|
> div.timeline-event {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> input {
|
||||||
|
padding: 1rem;
|
||||||
|
outline: none;
|
||||||
|
border-radius: .25rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div.confirm-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: right;
|
||||||
|
> button {
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue