1
0
Fork 0
forked from Mirrors/gomuks

web/timeline: add hover menu for events

Includes pinning events and a `set_state` command in hicli
This commit is contained in:
Tulir Asokan 2024-10-25 01:50:02 +03:00
parent c52600d0d7
commit a66c70c241
20 changed files with 282 additions and 47 deletions

View file

@ -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"`

View file

@ -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,

View file

@ -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) {

View file

@ -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 })
} }

View file

@ -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()

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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

View file

@ -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}>
<RoomHeader room={room} clearActiveRoom={clearActiveRoom}/> <RoomContext value={roomContextDataRef.current}>
<TimelineView room={room} scrollToBottomRef={scrollToBottomRef} setReplyToRef={setReplyToRef}/> <RoomHeader room={room} clearActiveRoom={clearActiveRoom}/>
<MessageComposer room={room} scrollToBottomRef={scrollToBottomRef} setReplyToRef={setReplyToRef}/> <TimelineView/>
<MessageComposer/>
</RoomContext>
</div> </div>
} }

View file

@ -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
View 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
}

View 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;
}
}

View 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

View file

@ -1,12 +1,11 @@
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:
"avatar gap sender sender" auto "cmc cmc cmc cmc" 0
"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;

View file

@ -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>

View file

@ -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