diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 7025ccd..6ee3d5f 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -47,6 +47,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) { return h.Send(ctx, params.RoomID, params.EventType, params.Content) }) + case "set_state": + return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) { + return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content) + }) case "mark_read": return unmarshalAndCall(req.Data, func(params *markReadParams) (bool, error) { return true, h.MarkRead(ctx, params.RoomID, params.EventID, params.ReceiptType) @@ -132,6 +136,13 @@ type sendEventParams struct { Content json.RawMessage `json:"content"` } +type sendStateEventParams struct { + RoomID id.RoomID `json:"room_id"` + EventType event.Type `json:"type"` + StateKey string `json:"state_key"` + Content json.RawMessage `json:"content"` +} + type markReadParams struct { RoomID id.RoomID `json:"room_id"` EventID id.EventID `json:"event_id"` diff --git a/pkg/hicli/send.go b/pkg/hicli/send.go index 0b7b16f..eb48a2f 100644 --- a/pkg/hicli/send.go +++ b/pkg/hicli/send.go @@ -110,6 +110,26 @@ func (h *HiClient) SetTyping(ctx context.Context, roomID id.RoomID, timeout time return err } +func (h *HiClient) SetState( + ctx context.Context, + roomID id.RoomID, + evtType event.Type, + stateKey string, + content any, +) (id.EventID, error) { + room, err := h.DB.Room.Get(ctx, roomID) + if err != nil { + return "", fmt.Errorf("failed to get room metadata: %w", err) + } else if room == nil { + return "", fmt.Errorf("unknown room") + } + resp, err := h.Client.SendStateEvent(ctx, room.ID, evtType, stateKey, content) + if err != nil { + return "", err + } + return resp.EventID, nil +} + func (h *HiClient) Send( ctx context.Context, roomID id.RoomID, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2916dd3..f057a1b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -58,6 +58,23 @@ export default class Client { ) } + async pinMessage(room: RoomStateStore, evtID: EventID, wantPinned: boolean) { + const pinnedEvents = room.getPinnedEvents() + const currentlyPinned = pinnedEvents.includes(evtID) + if (currentlyPinned === wantPinned) { + return + } + if (wantPinned) { + pinnedEvents.push(evtID) + } else { + const idx = pinnedEvents.indexOf(evtID) + if (idx !== -1) { + pinnedEvents.splice(idx, 1) + } + } + await this.rpc.setState(room.roomID, "m.room.pinned_events", "", { pinned: pinnedEvents }) + } + async sendMessage(params: SendMessageParams): Promise { const room = this.store.rooms.get(params.room_id) if (!room) { diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 916f824..686e650 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -132,6 +132,12 @@ export default abstract class RPCClient { return this.request("send_event", { room_id, type, content }) } + setState( + room_id: RoomID, type: EventType, state_key: string, content: Record, + ): Promise { + return this.request("set_state", { room_id, type, state_key, content }) + } + markRead(room_id: RoomID, event_id: EventID, receipt_type: ReceiptType = "m.read"): Promise { return this.request("mark_read", { room_id, event_id, receipt_type }) } diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index f3ca352..ffca5e9 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -111,6 +111,14 @@ export class RoomStateStore { return this.eventsByRowID.get(rowID) } + getPinnedEvents(): EventID[] { + const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned + if (Array.isArray(pinnedList)) { + return pinnedList.filter(evtID => typeof evtID === "string") + } + return [] + } + applyPagination(history: RawDBEvent[]) { // Pagination comes in newest to oldest, timeline is in the opposite order history.reverse() diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 90a427a..b4fe57a 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -69,6 +69,25 @@ export interface MemberEventContent { reason?: string } +export interface PowerLevelEventContent { + users?: Record + users_default?: number + events?: Record + events_default?: number + state_default?: number + notifications?: { + room?: number + } + ban?: number + redact?: number + invite?: number + kick?: number +} + +export interface PinnedEventsContent { + pinned?: EventID[] +} + export interface Mentions { user_ids: UserID[] room: boolean diff --git a/web/src/icons/edit.svg b/web/src/icons/edit.svg new file mode 100644 index 0000000..69b403e --- /dev/null +++ b/web/src/icons/edit.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/more.svg b/web/src/icons/more.svg new file mode 100644 index 0000000..5e7eb58 --- /dev/null +++ b/web/src/icons/more.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/pin.svg b/web/src/icons/pin.svg new file mode 100644 index 0000000..579d8a2 --- /dev/null +++ b/web/src/icons/pin.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/react.svg b/web/src/icons/react.svg new file mode 100644 index 0000000..51340a4 --- /dev/null +++ b/web/src/icons/react.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/reply.svg b/web/src/icons/reply.svg new file mode 100644 index 0000000..540e097 --- /dev/null +++ b/web/src/icons/reply.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/unpin.svg b/web/src/icons/unpin.svg new file mode 100644 index 0000000..c9418ba --- /dev/null +++ b/web/src/icons/unpin.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/RoomView.tsx b/web/src/ui/RoomView.tsx index 436575e..26a610c 100644 --- a/web/src/ui/RoomView.tsx +++ b/web/src/ui/RoomView.tsx @@ -16,10 +16,10 @@ import { use, useRef } from "react" import { getAvatarURL } from "@/api/media.ts" import { RoomStateStore } from "@/api/statestore" -import { EventID } from "@/api/types" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { LightboxContext } from "./Lightbox.tsx" import MessageComposer from "./composer/MessageComposer.tsx" +import { RoomContext, RoomContextData } from "./roomcontext.ts" import TimelineView from "./timeline/TimelineView.tsx" import BackIcon from "@/icons/back.svg?react" import "./RoomView.css" @@ -55,12 +55,16 @@ const onKeyDownRoomView = (evt: React.KeyboardEvent) => { } const RoomView = ({ room, clearActiveRoom }: RoomViewProps) => { - const scrollToBottomRef = useRef<() => void>(() => {}) - const setReplyToRef = useRef<(evt: EventID | null) => void>(() => {}) + const roomContextDataRef = useRef(undefined) + if (roomContextDataRef.current === undefined) { + roomContextDataRef.current = new RoomContextData(room) + } return
- - - + + + + +
} diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index 969adf2..c405e79 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -15,10 +15,11 @@ // along with this program. If not, see . import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react" import { ScaleLoader } from "react-spinners" -import { RoomStateStore, useRoomEvent } from "@/api/statestore" +import { useRoomEvent } from "@/api/statestore" import type { EventID, MediaMessageEventContent, Mentions, RelatesTo, RoomID } from "@/api/types" import useEvent from "@/util/useEvent.ts" import { ClientContext } from "../ClientContext.ts" +import { useRoomContext } from "../roomcontext.ts" import { ReplyBody } from "../timeline/ReplyBody.tsx" import { useMediaContent } from "../timeline/content/useMediaContent.tsx" import type { AutocompleteQuery } from "./Autocompleter.tsx" @@ -28,12 +29,6 @@ import CloseIcon from "@/icons/close.svg?react" import SendIcon from "@/icons/send.svg?react" import "./MessageComposer.css" -interface MessageComposerProps { - room: RoomStateStore - scrollToBottomRef: React.RefObject<() => void> - setReplyToRef: React.RefObject<(evt: EventID | null) => void> -} - export interface ComposerState { text: string media: MediaMessageEventContent | null @@ -66,7 +61,9 @@ const draftStore = { type CaretEvent = React.MouseEvent | React.KeyboardEvent | React.ChangeEvent -const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComposerProps) => { +const MessageComposer = () => { + const roomCtx = useRoomContext() + const room = roomCtx.store const client = use(ClientContext)! const [autocomplete, setAutocomplete] = useState(null) const [state, setState] = useReducer(composerReducer, uninitedComposer) @@ -76,8 +73,9 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp const textRows = useRef(1) const typingSentAt = useRef(0) const replyToEvt = useRoomEvent(room, state.replyTo) - setReplyToRef.current = useCallback((evt: EventID | null) => { + roomCtx.setReplyTo = useCallback((evt: EventID | null) => { setState({ replyTo: evt }) + textInput.current?.focus() }, []) const sendMessage = useEvent((evt: React.FormEvent) => { evt.preventDefault() @@ -227,8 +225,8 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp textInput.current.rows = newTextRows textRows.current = newTextRows // This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise - scrollToBottomRef.current?.() - }, [state, scrollToBottomRef]) + roomCtx.scrollToBottom() + }, [state, roomCtx]) // Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect. useEffect(() => { if (state.uninited) { diff --git a/web/src/ui/roomcontext.ts b/web/src/ui/roomcontext.ts new file mode 100644 index 0000000..a310788 --- /dev/null +++ b/web/src/ui/roomcontext.ts @@ -0,0 +1,43 @@ +// 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 { RefObject, createContext, createRef, use } from "react" +import { RoomStateStore } from "@/api/statestore" +import { EventID } from "@/api/types" + +const noop = (name: string) => () => { + console.warn(`${name} called before initialization`) +} + +export class RoomContextData { + public timelineBottomRef: RefObject = createRef() + public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo") + + constructor(public store: RoomStateStore) {} + + scrollToBottom() { + this.timelineBottomRef.current?.scrollIntoView() + } +} + +export const RoomContext = createContext(undefined) + +export const useRoomContext = () => { + const roomCtx = use(RoomContext) + if (!roomCtx) { + throw new Error("useRoomContext called outside RoomContext provider") + } + return roomCtx +} diff --git a/web/src/ui/timeline/EventMenu.css b/web/src/ui/timeline/EventMenu.css new file mode 100644 index 0000000..c8b12fa --- /dev/null +++ b/web/src/ui/timeline/EventMenu.css @@ -0,0 +1,20 @@ +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; + } +} diff --git a/web/src/ui/timeline/EventMenu.tsx b/web/src/ui/timeline/EventMenu.tsx new file mode 100644 index 0000000..035b420 --- /dev/null +++ b/web/src/ui/timeline/EventMenu.tsx @@ -0,0 +1,73 @@ +// 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, useCallback } from "react" +import { useRoomState } from "@/api/statestore" +import { MemDBEvent, PowerLevelEventContent } from "@/api/types" +import { ClientContext } from "../ClientContext.ts" +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 +} + +const EventMenu = ({ evt }: EventHoverMenuProps) => { + const client = use(ClientContext)! + const userID = client.userID + const roomCtx = useRoomContext() + 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(() => { + window.alert("No reactions yet :(") + }, []) + const onClickEdit = useCallback(() => { + window.alert("No edits yet :(") + }, []) + const onClickMore = useCallback(() => { + window.alert("Nothing here yet :(") + }, []) + 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
+ + + + {ownPL >= pinPL && (pins.includes(evt.event_id) + ? + : )} + +
+} + +export default EventMenu diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index 880cc06..f0073d1 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -1,12 +1,11 @@ div.timeline-event { width: 100%; max-width: 100%; - overflow: hidden; - overflow-wrap: anywhere; display: grid; margin-top: .5rem; grid-template: - "avatar gap sender sender" auto + "cmc cmc cmc cmc" 0 + "avatar gap sender sender" auto "avatar gap content status" auto / 2.5rem .5rem 1fr 2rem; @@ -75,6 +74,7 @@ div.timeline-event { > div.event-content { grid-area: content; overflow: hidden; + overflow-wrap: anywhere; } > div.event-send-status { @@ -97,8 +97,19 @@ div.timeline-event { } } + > div.context-menu-container { + grid-area: cmc; + position: relative; + display: none; + } + + &:hover > div.context-menu-container { + display: block; + } + &.same-sender { grid-template: + "cmc cmc cmc" 0 "timestamp content status" auto / 3rem 1fr 2rem; margin-top: .25rem; @@ -114,6 +125,7 @@ div.timeline-event { &.hidden-event { grid-template: + "cmc cmc cmc cmc" 0 "timestamp avatar content status" auto / 3rem 1.5rem 1fr 2rem; diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index daa6787..eb5da5d 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -13,13 +13,15 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { use, useCallback } from "react" +import React, { use } from "react" import { getAvatarURL, getMediaURL } from "@/api/media.ts" -import { RoomStateStore, useRoomState } from "@/api/statestore" -import { EventID, MemDBEvent, MemberEventContent, UnreadType } from "@/api/types" +import { useRoomState } from "@/api/statestore" +import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types" import { isEventID } from "@/util/validation.ts" import { ClientContext } from "../ClientContext.ts" import { LightboxContext } from "../Lightbox.tsx" +import { useRoomContext } from "../roomcontext.ts" +import EventMenu from "./EventMenu.tsx" import { ReplyIDBody } from "./ReplyBody.tsx" import getBodyType, { ContentErrorBoundary, EventContentProps, HiddenEvent, MemberBody } from "./content" import ErrorIcon from "../../icons/error.svg?react" @@ -28,10 +30,8 @@ import SentIcon from "../../icons/sent.svg?react" import "./TimelineEvent.css" export interface TimelineEventProps { - room: RoomStateStore evt: MemDBEvent prevEvt: MemDBEvent | null - setReplyToRef: React.RefObject<(evt: EventID | null) => void> } const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" }) @@ -67,10 +67,10 @@ function isSmallEvent(bodyType: React.FunctionComponent): boo return bodyType === HiddenEvent || bodyType === MemberBody } -const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps) => { - const wrappedSetReplyTo = useCallback(() => setReplyToRef.current(evt.event_id), [evt, setReplyToRef]) +const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => { + const roomCtx = useRoomContext() const client = use(ClientContext)! - const memberEvt = useRoomState(room, "m.room.member", evt.sender) + const memberEvt = useRoomState(roomCtx.store, "m.room.member", evt.sender) const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const BodyType = getBodyType(evt) const eventTS = new Date(evt.timestamp) @@ -95,6 +95,9 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"] const replyTo = relatesTo?.["m.in_reply_to"]?.event_id const mainEvent =
+
+ +
-
+
{memberEvtContent?.displayname || evt.sender} {shortTime} {(editEventTS && editTime) ? (edited at {formatShortTime(editEventTS)}) : null}
-
+
{shortTime}
{isEventID(replyTo) && BodyType !== HiddenEvent ? : null} - + {evt.reactions ? : null}
diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 8555c85..0d9403a 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -13,28 +13,25 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { use, useCallback, useEffect, useLayoutEffect, useRef } from "react" -import { RoomStateStore, useRoomTimeline } from "@/api/statestore" -import { EventID, MemDBEvent } from "@/api/types" +import { use, useCallback, useEffect, useLayoutEffect, useRef } from "react" +import { useRoomTimeline } from "@/api/statestore" +import { MemDBEvent } from "@/api/types" import useFocus from "@/util/focus.ts" import { ClientContext } from "../ClientContext.ts" +import { useRoomContext } from "../roomcontext.ts" import TimelineEvent from "./TimelineEvent.tsx" import "./TimelineView.css" -interface TimelineViewProps { - room: RoomStateStore - scrollToBottomRef: React.RefObject<() => void> - setReplyToRef: React.RefObject<(evt: EventID | null) => void> -} - -const TimelineView = ({ room, scrollToBottomRef, setReplyToRef }: TimelineViewProps) => { +const TimelineView = () => { + const roomCtx = useRoomContext() + const room = roomCtx.store const timeline = useRoomTimeline(room) const client = use(ClientContext)! const loadHistory = useCallback(() => { client.loadMoreHistory(room.roomID) .catch(err => console.error("Failed to load history", err)) }, [client, room]) - const bottomRef = useRef(null) + const bottomRef = roomCtx.timelineBottomRef const topRef = useRef(null) const timelineViewRef = useRef(null) const prevOldestTimelineRow = useRef(0) @@ -55,8 +52,6 @@ const TimelineView = ({ room, scrollToBottomRef, setReplyToRef }: TimelineViewPr if (timelineViewRef.current) { oldScrollHeight.current = timelineViewRef.current.scrollHeight } - scrollToBottomRef.current = () => - bottomRef.current && scrolledToBottom.current && bottomRef.current.scrollIntoView() useLayoutEffect(() => { if (bottomRef.current && scrolledToBottom.current) { // For any timeline changes, if we were at the bottom, scroll to the new bottom @@ -66,7 +61,7 @@ const TimelineView = ({ room, scrollToBottomRef, setReplyToRef }: TimelineViewPr timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current } prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0 - }, [timeline]) + }, [bottomRef, timeline]) useEffect(() => { const newestEvent = timeline[timeline.length - 1] if ( @@ -115,7 +110,7 @@ const TimelineView = ({ room, scrollToBottomRef, setReplyToRef }: TimelineViewPr return null } const thisEvt = prevEvt = entry return thisEvt