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

View file

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

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> {
const room = this.store.rooms.get(params.room_id)
if (!room) {

View file

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

View file

@ -69,6 +69,25 @@ export interface MemberEventContent {
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 {
user_ids: UserID[]
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 { 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<RoomContextData | undefined>(undefined)
if (roomContextDataRef.current === undefined) {
roomContextDataRef.current = new RoomContextData(room)
}
return <div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}>
<RoomHeader room={room} clearActiveRoom={clearActiveRoom}/>
<TimelineView room={room} scrollToBottomRef={scrollToBottomRef} setReplyToRef={setReplyToRef}/>
<MessageComposer room={room} scrollToBottomRef={scrollToBottomRef} setReplyToRef={setReplyToRef}/>
<RoomContext value={roomContextDataRef.current}>
<RoomHeader room={room} clearActiveRoom={clearActiveRoom}/>
<TimelineView/>
<MessageComposer/>
</RoomContext>
</div>
}

View file

@ -15,10 +15,11 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<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 [autocomplete, setAutocomplete] = useState<AutocompleteQuery | null>(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) {

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 {
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;

View file

@ -13,13 +13,15 @@
//
// 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 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<EventContentProps>): 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 = <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}>
<img
className={`${smallAvatar ? "small" : ""} avatar`}
@ -104,24 +107,24 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps
alt=""
/>
</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-time" title={fullTime}>{shortTime}</span>
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}>
(edited at {formatShortTime(editEventTS)})
</span> : null}
</div>
<div className="event-time-only" onClick={wrappedSetReplyTo}>
<div className="event-time-only">
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span>
</div>
<div className="event-content">
{isEventID(replyTo) && BodyType !== HiddenEvent ? <ReplyIDBody
room={room}
room={roomCtx.store}
eventID={replyTo}
isThread={relatesTo?.rel_type === "m.thread"}
/> : null}
<ContentErrorBoundary>
<BodyType room={room} sender={memberEvt} event={evt}/>
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
</ContentErrorBoundary>
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
</div>

View file

@ -13,28 +13,25 @@
//
// 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 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<HTMLDivElement>(null)
const bottomRef = roomCtx.timelineBottomRef
const topRef = useRef<HTMLDivElement>(null)
const timelineViewRef = useRef<HTMLDivElement>(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 = <TimelineEvent
key={entry.rowid} room={room} evt={entry} prevEvt={prevEvt} setReplyToRef={setReplyToRef}
key={entry.rowid} evt={entry} prevEvt={prevEvt}
/>
prevEvt = entry
return thisEvt