mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/timeline: add hover menu for events
Includes pinning events and a `set_state` command in hicli
This commit is contained in:
parent
c52600d0d7
commit
a66c70c241
20 changed files with 282 additions and 47 deletions
|
@ -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 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 "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":
|
case "mark_read":
|
||||||
return unmarshalAndCall(req.Data, func(params *markReadParams) (bool, error) {
|
return unmarshalAndCall(req.Data, func(params *markReadParams) (bool, error) {
|
||||||
return true, h.MarkRead(ctx, params.RoomID, params.EventID, params.ReceiptType)
|
return true, h.MarkRead(ctx, params.RoomID, params.EventID, params.ReceiptType)
|
||||||
|
@ -132,6 +136,13 @@ type sendEventParams struct {
|
||||||
Content json.RawMessage `json:"content"`
|
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 {
|
type markReadParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
EventID id.EventID `json:"event_id"`
|
EventID id.EventID `json:"event_id"`
|
||||||
|
|
|
@ -110,6 +110,26 @@ func (h *HiClient) SetTyping(ctx context.Context, roomID id.RoomID, timeout time
|
||||||
return err
|
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(
|
func (h *HiClient) Send(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
roomID id.RoomID,
|
roomID id.RoomID,
|
||||||
|
|
|
@ -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<void> {
|
async sendMessage(params: SendMessageParams): Promise<void> {
|
||||||
const room = this.store.rooms.get(params.room_id)
|
const room = this.store.rooms.get(params.room_id)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
|
|
|
@ -132,6 +132,12 @@ export default abstract class RPCClient {
|
||||||
return this.request("send_event", { room_id, type, content })
|
return this.request("send_event", { room_id, type, content })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState(
|
||||||
|
room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>,
|
||||||
|
): Promise<EventID> {
|
||||||
|
return this.request("set_state", { room_id, type, state_key, content })
|
||||||
|
}
|
||||||
|
|
||||||
markRead(room_id: RoomID, event_id: EventID, receipt_type: ReceiptType = "m.read"): Promise<boolean> {
|
markRead(room_id: RoomID, event_id: EventID, receipt_type: ReceiptType = "m.read"): Promise<boolean> {
|
||||||
return this.request("mark_read", { room_id, event_id, receipt_type })
|
return this.request("mark_read", { room_id, event_id, receipt_type })
|
||||||
}
|
}
|
||||||
|
|
|
@ -111,6 +111,14 @@ export class RoomStateStore {
|
||||||
return this.eventsByRowID.get(rowID)
|
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[]) {
|
applyPagination(history: RawDBEvent[]) {
|
||||||
// Pagination comes in newest to oldest, timeline is in the opposite order
|
// Pagination comes in newest to oldest, timeline is in the opposite order
|
||||||
history.reverse()
|
history.reverse()
|
||||||
|
|
|
@ -69,6 +69,25 @@ export interface MemberEventContent {
|
||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PowerLevelEventContent {
|
||||||
|
users?: Record<UserID, number>
|
||||||
|
users_default?: number
|
||||||
|
events?: Record<EventType, number>
|
||||||
|
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 {
|
export interface Mentions {
|
||||||
user_ids: UserID[]
|
user_ids: UserID[]
|
||||||
room: boolean
|
room: boolean
|
||||||
|
|
1
web/src/icons/edit.svg
Normal file
1
web/src/icons/edit.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="M200-200h57l391-391-57-57-391 391v57Zm-80 80v-170l528-527q12-11 26.5-17t30.5-6q16 0 31 6t26 18l55 56q12 11 17.5 26t5.5 30q0 16-5.5 30.5T817-647L290-120H120Zm640-584-56-56 56 56Zm-141 85-28-29 57 57-29-28Z"/></svg>
|
After Width: | Height: | Size: 330 B |
1
web/src/icons/more.svg
Normal file
1
web/src/icons/more.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="M240-400q-33 0-56.5-23.5T160-480q0-33 23.5-56.5T240-560q33 0 56.5 23.5T320-480q0 33-23.5 56.5T240-400Zm240 0q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm240 0q-33 0-56.5-23.5T640-480q0-33 23.5-56.5T720-560q33 0 56.5 23.5T800-480q0 33-23.5 56.5T720-400Z"/></svg>
|
After Width: | Height: | Size: 428 B |
1
web/src/icons/pin.svg
Normal file
1
web/src/icons/pin.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="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Zm-286 80h252l-46-46v-314H400v314l-46 46Zm126 0Z"/></svg>
|
After Width: | Height: | Size: 259 B |
1
web/src/icons/react.svg
Normal file
1
web/src/icons/react.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-480Zm0 400q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q43 0 83 8.5t77 24.5v90q-35-20-75.5-31.5T480-800q-133 0-226.5 93.5T160-480q0 133 93.5 226.5T480-160q133 0 226.5-93.5T800-480q0-32-6.5-62T776-600h86q9 29 13.5 58.5T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm320-600v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80ZM620-520q25 0 42.5-17.5T680-580q0-25-17.5-42.5T620-640q-25 0-42.5 17.5T560-580q0 25 17.5 42.5T620-520Zm-280 0q25 0 42.5-17.5T400-580q0-25-17.5-42.5T340-640q-25 0-42.5 17.5T280-580q0 25 17.5 42.5T340-520Zm140 260q68 0 123.5-38.5T684-400H276q25 63 80.5 101.5T480-260Z"/></svg>
|
After Width: | Height: | Size: 764 B |
1
web/src/icons/reply.svg
Normal file
1
web/src/icons/reply.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="M760-200v-160q0-50-35-85t-85-35H273l144 144-57 56-240-240 240-240 57 56-144 144h367q83 0 141.5 58.5T840-360v160h-80Z"/></svg>
|
After Width: | Height: | Size: 242 B |
1
web/src/icons/unpin.svg
Normal file
1
web/src/icons/unpin.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="M680-840v80h-40v327l-80-80v-247H400v87l-87-87-33-33v-47h400ZM480-40l-40-40v-240H240v-80l80-80v-46L56-792l56-56 736 736-58 56-264-264h-6v240l-40 40ZM354-400h92l-44-44-2-2-46 46Zm126-193Zm-78 149Z"/></svg>
|
After Width: | Height: | Size: 320 B |
|
@ -16,10 +16,10 @@
|
||||||
import { use, useRef } from "react"
|
import { use, useRef } from "react"
|
||||||
import { getAvatarURL } from "@/api/media.ts"
|
import { getAvatarURL } from "@/api/media.ts"
|
||||||
import { RoomStateStore } from "@/api/statestore"
|
import { RoomStateStore } from "@/api/statestore"
|
||||||
import { EventID } from "@/api/types"
|
|
||||||
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
|
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
import { LightboxContext } from "./Lightbox.tsx"
|
import { LightboxContext } from "./Lightbox.tsx"
|
||||||
import MessageComposer from "./composer/MessageComposer.tsx"
|
import MessageComposer from "./composer/MessageComposer.tsx"
|
||||||
|
import { RoomContext, RoomContextData } from "./roomcontext.ts"
|
||||||
import TimelineView from "./timeline/TimelineView.tsx"
|
import TimelineView from "./timeline/TimelineView.tsx"
|
||||||
import BackIcon from "@/icons/back.svg?react"
|
import BackIcon from "@/icons/back.svg?react"
|
||||||
import "./RoomView.css"
|
import "./RoomView.css"
|
||||||
|
@ -55,12 +55,16 @@ const onKeyDownRoomView = (evt: React.KeyboardEvent) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const RoomView = ({ room, clearActiveRoom }: RoomViewProps) => {
|
const RoomView = ({ room, clearActiveRoom }: RoomViewProps) => {
|
||||||
const scrollToBottomRef = useRef<() => void>(() => {})
|
const roomContextDataRef = useRef<RoomContextData | undefined>(undefined)
|
||||||
const setReplyToRef = useRef<(evt: EventID | null) => void>(() => {})
|
if (roomContextDataRef.current === undefined) {
|
||||||
|
roomContextDataRef.current = new RoomContextData(room)
|
||||||
|
}
|
||||||
return <div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}>
|
return <div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}>
|
||||||
|
<RoomContext value={roomContextDataRef.current}>
|
||||||
<RoomHeader room={room} clearActiveRoom={clearActiveRoom}/>
|
<RoomHeader room={room} clearActiveRoom={clearActiveRoom}/>
|
||||||
<TimelineView room={room} scrollToBottomRef={scrollToBottomRef} setReplyToRef={setReplyToRef}/>
|
<TimelineView/>
|
||||||
<MessageComposer room={room} scrollToBottomRef={scrollToBottomRef} setReplyToRef={setReplyToRef}/>
|
<MessageComposer/>
|
||||||
|
</RoomContext>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,10 +15,11 @@
|
||||||
// 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, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
|
import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
|
||||||
import { ScaleLoader } from "react-spinners"
|
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 type { EventID, MediaMessageEventContent, Mentions, RelatesTo, RoomID } from "@/api/types"
|
||||||
import useEvent from "@/util/useEvent.ts"
|
import useEvent from "@/util/useEvent.ts"
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
import { ClientContext } from "../ClientContext.ts"
|
||||||
|
import { useRoomContext } from "../roomcontext.ts"
|
||||||
import { ReplyBody } from "../timeline/ReplyBody.tsx"
|
import { ReplyBody } from "../timeline/ReplyBody.tsx"
|
||||||
import { useMediaContent } from "../timeline/content/useMediaContent.tsx"
|
import { useMediaContent } from "../timeline/content/useMediaContent.tsx"
|
||||||
import type { AutocompleteQuery } from "./Autocompleter.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 SendIcon from "@/icons/send.svg?react"
|
||||||
import "./MessageComposer.css"
|
import "./MessageComposer.css"
|
||||||
|
|
||||||
interface MessageComposerProps {
|
|
||||||
room: RoomStateStore
|
|
||||||
scrollToBottomRef: React.RefObject<() => void>
|
|
||||||
setReplyToRef: React.RefObject<(evt: EventID | null) => void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComposerState {
|
export interface ComposerState {
|
||||||
text: string
|
text: string
|
||||||
media: MediaMessageEventContent | null
|
media: MediaMessageEventContent | null
|
||||||
|
@ -66,7 +61,9 @@ const draftStore = {
|
||||||
|
|
||||||
type CaretEvent<T> = React.MouseEvent<T> | React.KeyboardEvent<T> | React.ChangeEvent<T>
|
type CaretEvent<T> = React.MouseEvent<T> | React.KeyboardEvent<T> | React.ChangeEvent<T>
|
||||||
|
|
||||||
const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComposerProps) => {
|
const MessageComposer = () => {
|
||||||
|
const roomCtx = useRoomContext()
|
||||||
|
const room = roomCtx.store
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const [autocomplete, setAutocomplete] = useState<AutocompleteQuery | null>(null)
|
const [autocomplete, setAutocomplete] = useState<AutocompleteQuery | null>(null)
|
||||||
const [state, setState] = useReducer(composerReducer, uninitedComposer)
|
const [state, setState] = useReducer(composerReducer, uninitedComposer)
|
||||||
|
@ -76,8 +73,9 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
|
||||||
const textRows = useRef(1)
|
const textRows = useRef(1)
|
||||||
const typingSentAt = useRef(0)
|
const typingSentAt = useRef(0)
|
||||||
const replyToEvt = useRoomEvent(room, state.replyTo)
|
const replyToEvt = useRoomEvent(room, state.replyTo)
|
||||||
setReplyToRef.current = useCallback((evt: EventID | null) => {
|
roomCtx.setReplyTo = useCallback((evt: EventID | null) => {
|
||||||
setState({ replyTo: evt })
|
setState({ replyTo: evt })
|
||||||
|
textInput.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
const sendMessage = useEvent((evt: React.FormEvent) => {
|
const sendMessage = useEvent((evt: React.FormEvent) => {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
|
@ -227,8 +225,8 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
|
||||||
textInput.current.rows = newTextRows
|
textInput.current.rows = newTextRows
|
||||||
textRows.current = newTextRows
|
textRows.current = newTextRows
|
||||||
// This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise
|
// This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise
|
||||||
scrollToBottomRef.current?.()
|
roomCtx.scrollToBottom()
|
||||||
}, [state, scrollToBottomRef])
|
}, [state, roomCtx])
|
||||||
// Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect.
|
// Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.uninited) {
|
if (state.uninited) {
|
||||||
|
|
43
web/src/ui/roomcontext.ts
Normal file
43
web/src/ui/roomcontext.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
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<HTMLDivElement | null> = createRef()
|
||||||
|
public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo")
|
||||||
|
|
||||||
|
constructor(public store: RoomStateStore) {}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
this.timelineBottomRef.current?.scrollIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoomContext = createContext<RoomContextData | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useRoomContext = () => {
|
||||||
|
const roomCtx = use(RoomContext)
|
||||||
|
if (!roomCtx) {
|
||||||
|
throw new Error("useRoomContext called outside RoomContext provider")
|
||||||
|
}
|
||||||
|
return roomCtx
|
||||||
|
}
|
20
web/src/ui/timeline/EventMenu.css
Normal file
20
web/src/ui/timeline/EventMenu.css
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
73
web/src/ui/timeline/EventMenu.tsx
Normal file
73
web/src/ui/timeline/EventMenu.tsx
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
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 <div className="context-menu">
|
||||||
|
<button onClick={onClickReact}><ReactIcon/></button>
|
||||||
|
<button onClick={onClickReply}><ReplyIcon/></button>
|
||||||
|
<button onClick={onClickEdit}><EditIcon/></button>
|
||||||
|
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
||||||
|
? <button onClick={onClickUnpin}><UnpinIcon/></button>
|
||||||
|
: <button onClick={onClickPin}><PinIcon/></button>)}
|
||||||
|
<button onClick={onClickMore}><MoreIcon/></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventMenu
|
|
@ -1,11 +1,10 @@
|
||||||
div.timeline-event {
|
div.timeline-event {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
grid-template:
|
grid-template:
|
||||||
|
"cmc cmc cmc cmc" 0
|
||||||
"avatar gap sender sender" auto
|
"avatar gap sender sender" auto
|
||||||
"avatar gap content status" auto
|
"avatar gap content status" auto
|
||||||
/ 2.5rem .5rem 1fr 2rem;
|
/ 2.5rem .5rem 1fr 2rem;
|
||||||
|
@ -75,6 +74,7 @@ div.timeline-event {
|
||||||
> div.event-content {
|
> div.event-content {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
> div.event-send-status {
|
> 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 {
|
&.same-sender {
|
||||||
grid-template:
|
grid-template:
|
||||||
|
"cmc cmc cmc" 0
|
||||||
"timestamp content status" auto
|
"timestamp content status" auto
|
||||||
/ 3rem 1fr 2rem;
|
/ 3rem 1fr 2rem;
|
||||||
margin-top: .25rem;
|
margin-top: .25rem;
|
||||||
|
@ -114,6 +125,7 @@ div.timeline-event {
|
||||||
|
|
||||||
&.hidden-event {
|
&.hidden-event {
|
||||||
grid-template:
|
grid-template:
|
||||||
|
"cmc cmc cmc cmc" 0
|
||||||
"timestamp avatar content status" auto
|
"timestamp avatar content status" auto
|
||||||
/ 3rem 1.5rem 1fr 2rem;
|
/ 3rem 1.5rem 1fr 2rem;
|
||||||
|
|
||||||
|
|
|
@ -13,13 +13,15 @@
|
||||||
//
|
//
|
||||||
// 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, { use, useCallback } from "react"
|
import React, { use } from "react"
|
||||||
import { getAvatarURL, getMediaURL } from "@/api/media.ts"
|
import { getAvatarURL, getMediaURL } from "@/api/media.ts"
|
||||||
import { RoomStateStore, useRoomState } from "@/api/statestore"
|
import { useRoomState } from "@/api/statestore"
|
||||||
import { EventID, MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
|
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
|
||||||
import { isEventID } from "@/util/validation.ts"
|
import { isEventID } from "@/util/validation.ts"
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
import { ClientContext } from "../ClientContext.ts"
|
||||||
import { LightboxContext } from "../Lightbox.tsx"
|
import { LightboxContext } from "../Lightbox.tsx"
|
||||||
|
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 ErrorIcon from "../../icons/error.svg?react"
|
import ErrorIcon from "../../icons/error.svg?react"
|
||||||
|
@ -28,10 +30,8 @@ import SentIcon from "../../icons/sent.svg?react"
|
||||||
import "./TimelineEvent.css"
|
import "./TimelineEvent.css"
|
||||||
|
|
||||||
export interface TimelineEventProps {
|
export interface TimelineEventProps {
|
||||||
room: RoomStateStore
|
|
||||||
evt: MemDBEvent
|
evt: MemDBEvent
|
||||||
prevEvt: MemDBEvent | null
|
prevEvt: MemDBEvent | null
|
||||||
setReplyToRef: React.RefObject<(evt: EventID | null) => void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
|
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
|
||||||
|
@ -67,10 +67,10 @@ function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps>): boo
|
||||||
return bodyType === HiddenEvent || bodyType === MemberBody
|
return bodyType === HiddenEvent || bodyType === MemberBody
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps) => {
|
const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => {
|
||||||
const wrappedSetReplyTo = useCallback(() => setReplyToRef.current(evt.event_id), [evt, setReplyToRef])
|
const roomCtx = useRoomContext()
|
||||||
const client = use(ClientContext)!
|
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 memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
||||||
const BodyType = getBodyType(evt)
|
const BodyType = getBodyType(evt)
|
||||||
const eventTS = new Date(evt.timestamp)
|
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 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">
|
||||||
|
<EventMenu evt={evt}/>
|
||||||
|
</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`}
|
||||||
|
@ -104,24 +107,24 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="event-sender-and-time" onClick={wrappedSetReplyTo}>
|
<div className="event-sender-and-time">
|
||||||
<span className="event-sender">{memberEvtContent?.displayname || evt.sender}</span>
|
<span className="event-sender">{memberEvtContent?.displayname || evt.sender}</span>
|
||||||
<span className="event-time" title={fullTime}>{shortTime}</span>
|
<span className="event-time" title={fullTime}>{shortTime}</span>
|
||||||
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}>
|
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}>
|
||||||
(edited at {formatShortTime(editEventTS)})
|
(edited at {formatShortTime(editEventTS)})
|
||||||
</span> : null}
|
</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="event-time-only" onClick={wrappedSetReplyTo}>
|
<div className="event-time-only">
|
||||||
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span>
|
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="event-content">
|
<div className="event-content">
|
||||||
{isEventID(replyTo) && BodyType !== HiddenEvent ? <ReplyIDBody
|
{isEventID(replyTo) && BodyType !== HiddenEvent ? <ReplyIDBody
|
||||||
room={room}
|
room={roomCtx.store}
|
||||||
eventID={replyTo}
|
eventID={replyTo}
|
||||||
isThread={relatesTo?.rel_type === "m.thread"}
|
isThread={relatesTo?.rel_type === "m.thread"}
|
||||||
/> : null}
|
/> : null}
|
||||||
<ContentErrorBoundary>
|
<ContentErrorBoundary>
|
||||||
<BodyType room={room} sender={memberEvt} event={evt}/>
|
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
|
||||||
</ContentErrorBoundary>
|
</ContentErrorBoundary>
|
||||||
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,28 +13,25 @@
|
||||||
//
|
//
|
||||||
// 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, { use, useCallback, useEffect, useLayoutEffect, useRef } from "react"
|
import { use, useCallback, useEffect, useLayoutEffect, useRef } from "react"
|
||||||
import { RoomStateStore, useRoomTimeline } from "@/api/statestore"
|
import { useRoomTimeline } from "@/api/statestore"
|
||||||
import { EventID, MemDBEvent } from "@/api/types"
|
import { MemDBEvent } from "@/api/types"
|
||||||
import useFocus from "@/util/focus.ts"
|
import useFocus from "@/util/focus.ts"
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
import { ClientContext } from "../ClientContext.ts"
|
||||||
|
import { useRoomContext } from "../roomcontext.ts"
|
||||||
import TimelineEvent from "./TimelineEvent.tsx"
|
import TimelineEvent from "./TimelineEvent.tsx"
|
||||||
import "./TimelineView.css"
|
import "./TimelineView.css"
|
||||||
|
|
||||||
interface TimelineViewProps {
|
const TimelineView = () => {
|
||||||
room: RoomStateStore
|
const roomCtx = useRoomContext()
|
||||||
scrollToBottomRef: React.RefObject<() => void>
|
const room = roomCtx.store
|
||||||
setReplyToRef: React.RefObject<(evt: EventID | null) => void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const TimelineView = ({ room, scrollToBottomRef, setReplyToRef }: TimelineViewProps) => {
|
|
||||||
const timeline = useRoomTimeline(room)
|
const timeline = useRoomTimeline(room)
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const loadHistory = useCallback(() => {
|
const loadHistory = useCallback(() => {
|
||||||
client.loadMoreHistory(room.roomID)
|
client.loadMoreHistory(room.roomID)
|
||||||
.catch(err => console.error("Failed to load history", err))
|
.catch(err => console.error("Failed to load history", err))
|
||||||
}, [client, room])
|
}, [client, room])
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = roomCtx.timelineBottomRef
|
||||||
const topRef = useRef<HTMLDivElement>(null)
|
const topRef = useRef<HTMLDivElement>(null)
|
||||||
const timelineViewRef = useRef<HTMLDivElement>(null)
|
const timelineViewRef = useRef<HTMLDivElement>(null)
|
||||||
const prevOldestTimelineRow = useRef(0)
|
const prevOldestTimelineRow = useRef(0)
|
||||||
|
@ -55,8 +52,6 @@ const TimelineView = ({ room, scrollToBottomRef, setReplyToRef }: TimelineViewPr
|
||||||
if (timelineViewRef.current) {
|
if (timelineViewRef.current) {
|
||||||
oldScrollHeight.current = timelineViewRef.current.scrollHeight
|
oldScrollHeight.current = timelineViewRef.current.scrollHeight
|
||||||
}
|
}
|
||||||
scrollToBottomRef.current = () =>
|
|
||||||
bottomRef.current && scrolledToBottom.current && bottomRef.current.scrollIntoView()
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (bottomRef.current && scrolledToBottom.current) {
|
if (bottomRef.current && scrolledToBottom.current) {
|
||||||
// For any timeline changes, if we were at the bottom, scroll to the new bottom
|
// 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
|
timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current
|
||||||
}
|
}
|
||||||
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
|
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
|
||||||
}, [timeline])
|
}, [bottomRef, timeline])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newestEvent = timeline[timeline.length - 1]
|
const newestEvent = timeline[timeline.length - 1]
|
||||||
if (
|
if (
|
||||||
|
@ -115,7 +110,7 @@ const TimelineView = ({ room, scrollToBottomRef, setReplyToRef }: TimelineViewPr
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const thisEvt = <TimelineEvent
|
const thisEvt = <TimelineEvent
|
||||||
key={entry.rowid} room={room} evt={entry} prevEvt={prevEvt} setReplyToRef={setReplyToRef}
|
key={entry.rowid} evt={entry} prevEvt={prevEvt}
|
||||||
/>
|
/>
|
||||||
prevEvt = entry
|
prevEvt = entry
|
||||||
return thisEvt
|
return thisEvt
|
||||||
|
|
Loading…
Add table
Reference in a new issue