forked from Mirrors/gomuks
web: support rendering edits and refactor timeline updates
This commit is contained in:
parent
757c1b444e
commit
821701dec6
13 changed files with 229 additions and 55 deletions
2
go.mod
2
go.mod
|
@ -13,7 +13,7 @@ require (
|
||||||
golang.org/x/crypto v0.27.0
|
golang.org/x/crypto v0.27.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mauflag v1.0.0
|
maunium.net/go/mauflag v1.0.0
|
||||||
maunium.net/go/mautrix v0.21.1-0.20241010172818-50f4a2eec193
|
maunium.net/go/mautrix v0.21.1-0.20241012091419-9e796dd66c0d
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -60,5 +60,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||||
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||||
maunium.net/go/mautrix v0.21.1-0.20241010172818-50f4a2eec193 h1:WK4mjzzDZ+9cEthJHyc5h7yAYYK/syUPJDYjkn90ujs=
|
maunium.net/go/mautrix v0.21.1-0.20241012091419-9e796dd66c0d h1:wiiX3GSf8/6o3HIfjlgxdZTq3EiXQ6sgeXm9rLkGGDU=
|
||||||
maunium.net/go/mautrix v0.21.1-0.20241010172818-50f4a2eec193/go.mod h1:+fF5qsmXRCEXQZgW5ececC0PI3c7gISHTLcyftP4Bh0=
|
maunium.net/go/mautrix v0.21.1-0.20241012091419-9e796dd66c0d/go.mod h1:+fF5qsmXRCEXQZgW5ececC0PI3c7gISHTLcyftP4Bh0=
|
||||||
|
|
|
@ -70,11 +70,19 @@ export default class Client {
|
||||||
if (!room) {
|
if (!room) {
|
||||||
throw new Error("Room not found")
|
throw new Error("Room not found")
|
||||||
}
|
}
|
||||||
const oldestRowID = room.timeline.current[0]?.timeline_rowid
|
if (room.paginating) {
|
||||||
const resp = await this.rpc.paginate(roomID, oldestRowID ?? 0, 100)
|
return
|
||||||
if (room.timeline.current[0]?.timeline_rowid !== oldestRowID) {
|
}
|
||||||
throw new Error("Timeline changed while loading history")
|
room.paginating = true
|
||||||
|
try {
|
||||||
|
const oldestRowID = room.timeline[0]?.timeline_rowid
|
||||||
|
const resp = await this.rpc.paginate(roomID, oldestRowID ?? 0, 100)
|
||||||
|
if (room.timeline[0]?.timeline_rowid !== oldestRowID) {
|
||||||
|
throw new Error("Timeline changed while loading history")
|
||||||
|
}
|
||||||
|
room.applyPagination(resp.events)
|
||||||
|
} finally {
|
||||||
|
room.paginating = false
|
||||||
}
|
}
|
||||||
room.applyPagination(resp.events)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,8 @@
|
||||||
// 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 { UserID } from "./types"
|
||||||
|
|
||||||
const mediaRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/
|
const mediaRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/
|
||||||
|
|
||||||
export const getMediaURL = (mxc?: string): string | undefined => {
|
export const getMediaURL = (mxc?: string): string | undefined => {
|
||||||
|
@ -26,3 +28,17 @@ export const getMediaURL = (mxc?: string): string | undefined => {
|
||||||
}
|
}
|
||||||
return `_gomuks/media/${match[1]}/${match[2]}`
|
return `_gomuks/media/${match[1]}/${match[2]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getAvatarURL = (_userID: UserID, mxc?: string): string | undefined => {
|
||||||
|
if (!mxc) {
|
||||||
|
return undefined
|
||||||
|
// return `_gomuks/avatar/${encodeURIComponent(userID)}`
|
||||||
|
}
|
||||||
|
const match = mxc.match(mediaRegex)
|
||||||
|
if (!match) {
|
||||||
|
return undefined
|
||||||
|
// return `_gomuks/avatar/${encodeURIComponent(userID)}`
|
||||||
|
}
|
||||||
|
return `_gomuks/media/${match[1]}/${match[2]}`
|
||||||
|
// return `_gomuks/avatar/${encodeURIComponent(userID)}/${match[1]}/${match[2]}`
|
||||||
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { useSyncExternalStore } from "react"
|
||||||
import { NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
|
import { NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
|
||||||
import type {
|
import type {
|
||||||
ContentURI,
|
ContentURI,
|
||||||
|
@ -62,20 +63,51 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
|
||||||
meta1.has_member_list === meta2.has_member_list
|
meta1.has_member_list === meta2.has_member_list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
room.subscribeTimeline,
|
||||||
|
() => room.timelineCache,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscribeFunc = (callback: () => void) => () => void
|
||||||
|
|
||||||
export class RoomStateStore {
|
export class RoomStateStore {
|
||||||
readonly roomID: RoomID
|
readonly roomID: RoomID
|
||||||
readonly meta: NonNullCachedEventDispatcher<DBRoom>
|
readonly meta: NonNullCachedEventDispatcher<DBRoom>
|
||||||
readonly timeline = new NonNullCachedEventDispatcher<TimelineRowTuple[]>([])
|
timeline: TimelineRowTuple[] = []
|
||||||
|
timelineCache: (MemDBEvent | null)[] = []
|
||||||
state: Map<EventType, Map<string, EventRowID>> = new Map()
|
state: Map<EventType, Map<string, EventRowID>> = new Map()
|
||||||
stateLoaded = false
|
stateLoaded = false
|
||||||
readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map()
|
readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map()
|
||||||
readonly eventsByID: Map<EventID, MemDBEvent> = new Map()
|
readonly eventsByID: Map<EventID, MemDBEvent> = new Map()
|
||||||
|
readonly timelineSubscribers: Set<() => void> = new Set()
|
||||||
|
paginating = false
|
||||||
|
|
||||||
constructor(meta: DBRoom) {
|
constructor(meta: DBRoom) {
|
||||||
this.roomID = meta.room_id
|
this.roomID = meta.room_id
|
||||||
this.meta = new NonNullCachedEventDispatcher(meta)
|
this.meta = new NonNullCachedEventDispatcher(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subscribeTimeline: SubscribeFunc = callback => {
|
||||||
|
this.timelineSubscribers.add(callback)
|
||||||
|
return () => this.timelineSubscribers.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyTimelineSubscribers() {
|
||||||
|
this.timelineCache = this.timeline.map(rt => {
|
||||||
|
const evt = this.eventsByRowID.get(rt.event_rowid)
|
||||||
|
if (!evt) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
evt.timeline_rowid = rt.timeline_rowid
|
||||||
|
return evt
|
||||||
|
})
|
||||||
|
for (const sub of this.timelineSubscribers) {
|
||||||
|
sub()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getStateEvent(type: EventType, stateKey: string): MemDBEvent | undefined {
|
getStateEvent(type: EventType, stateKey: string): MemDBEvent | undefined {
|
||||||
const rowID = this.state.get(type)?.get(stateKey)
|
const rowID = this.state.get(type)?.get(stateKey)
|
||||||
if (!rowID) {
|
if (!rowID) {
|
||||||
|
@ -91,7 +123,8 @@ export class RoomStateStore {
|
||||||
this.applyEvent(evt)
|
this.applyEvent(evt)
|
||||||
return { timeline_rowid: evt.timeline_rowid, event_rowid: evt.rowid }
|
return { timeline_rowid: evt.timeline_rowid, event_rowid: evt.rowid }
|
||||||
})
|
})
|
||||||
this.timeline.emit([...newTimeline, ...this.timeline.current])
|
this.timeline.splice(0, 0, ...newTimeline)
|
||||||
|
this.notifyTimelineSubscribers()
|
||||||
}
|
}
|
||||||
|
|
||||||
applyEvent(evt: RawDBEvent) {
|
applyEvent(evt: RawDBEvent) {
|
||||||
|
@ -104,6 +137,13 @@ export class RoomStateStore {
|
||||||
}
|
}
|
||||||
delete evt.decrypted
|
delete evt.decrypted
|
||||||
delete evt.decrypted_type
|
delete evt.decrypted_type
|
||||||
|
if (memEvt.last_edit_rowid) {
|
||||||
|
memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid)
|
||||||
|
if (memEvt.last_edit) {
|
||||||
|
memEvt.orig_content = memEvt.content
|
||||||
|
memEvt.content = memEvt.last_edit.content["m.new_content"]
|
||||||
|
}
|
||||||
|
}
|
||||||
this.eventsByRowID.set(memEvt.rowid, memEvt)
|
this.eventsByRowID.set(memEvt.rowid, memEvt)
|
||||||
this.eventsByID.set(memEvt.event_id, memEvt)
|
this.eventsByID.set(memEvt.event_id, memEvt)
|
||||||
}
|
}
|
||||||
|
@ -128,20 +168,21 @@ export class RoomStateStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (sync.reset) {
|
if (sync.reset) {
|
||||||
this.timeline.emit(sync.timeline)
|
this.timeline = sync.timeline
|
||||||
} else {
|
} else {
|
||||||
this.timeline.emit([...this.timeline.current, ...sync.timeline])
|
this.timeline.push(...sync.timeline)
|
||||||
}
|
}
|
||||||
|
this.notifyTimelineSubscribers()
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDecrypted(decrypted: EventsDecryptedData) {
|
applyDecrypted(decrypted: EventsDecryptedData) {
|
||||||
let timelineChanged = false
|
let timelineChanged = false
|
||||||
for (const evt of decrypted.events) {
|
for (const evt of decrypted.events) {
|
||||||
timelineChanged = timelineChanged || !!this.timeline.current.find(rt => rt.event_rowid === evt.rowid)
|
timelineChanged = timelineChanged || !!this.timeline.find(rt => rt.event_rowid === evt.rowid)
|
||||||
this.applyEvent(evt)
|
this.applyEvent(evt)
|
||||||
}
|
}
|
||||||
if (timelineChanged) {
|
if (timelineChanged) {
|
||||||
this.timeline.emit([...this.timeline.current])
|
this.notifyTimelineSubscribers()
|
||||||
}
|
}
|
||||||
if (decrypted.preview_event_rowid) {
|
if (decrypted.preview_event_rowid) {
|
||||||
this.meta.current.preview_event_rowid = decrypted.preview_event_rowid
|
this.meta.current.preview_event_rowid = decrypted.preview_event_rowid
|
||||||
|
|
|
@ -62,6 +62,9 @@ export interface DBRoom {
|
||||||
prev_batch: string
|
prev_batch: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type UnknownEventContent = Record<string, any>
|
||||||
|
|
||||||
export interface BaseDBEvent {
|
export interface BaseDBEvent {
|
||||||
rowid: EventRowID
|
rowid: EventRowID
|
||||||
timeline_rowid: TimelineRowID
|
timeline_rowid: TimelineRowID
|
||||||
|
@ -73,8 +76,7 @@ export interface BaseDBEvent {
|
||||||
state_key?: string
|
state_key?: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
|
|
||||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
content: UnknownEventContent
|
||||||
content: Record<string, any>
|
|
||||||
unsigned: EventUnsigned
|
unsigned: EventUnsigned
|
||||||
|
|
||||||
transaction_id?: string
|
transaction_id?: string
|
||||||
|
@ -90,14 +92,15 @@ export interface BaseDBEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawDBEvent extends BaseDBEvent {
|
export interface RawDBEvent extends BaseDBEvent {
|
||||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
decrypted?: UnknownEventContent
|
||||||
decrypted?: Record<string, any>
|
|
||||||
decrypted_type?: EventType
|
decrypted_type?: EventType
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemDBEvent extends BaseDBEvent {
|
export interface MemDBEvent extends BaseDBEvent {
|
||||||
mem: true
|
mem: true
|
||||||
encrypted?: EncryptedEventContent
|
encrypted?: EncryptedEventContent
|
||||||
|
orig_content?: UnknownEventContent
|
||||||
|
last_edit?: MemDBEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DBAccountData {
|
export interface DBAccountData {
|
||||||
|
|
|
@ -7,7 +7,7 @@ div.timeline-event {
|
||||||
grid-template:
|
grid-template:
|
||||||
"avatar gap sender" auto
|
"avatar gap sender" auto
|
||||||
"avatar gap content" auto
|
"avatar gap content" auto
|
||||||
/ 40px .25rem 1fr;
|
/ 2.5rem .25rem 1fr;
|
||||||
|
|
||||||
> div.sender-avatar {
|
> div.sender-avatar {
|
||||||
grid-area: avatar;
|
grid-area: avatar;
|
||||||
|
@ -29,13 +29,13 @@ div.timeline-event {
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .5rem;
|
gap: .25rem;
|
||||||
|
|
||||||
span.event-sender {
|
> span.event-sender {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
span.event-time {
|
> span.event-time, > span.event-edited {
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,6 +44,32 @@ div.timeline-event {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.hidden-event {
|
||||||
|
grid-template:
|
||||||
|
"sender avatar content" auto
|
||||||
|
/ 2.5rem 1.5rem 1fr;
|
||||||
|
margin-top: 0;
|
||||||
|
|
||||||
|
> div.sender-avatar {
|
||||||
|
margin-top: 0;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> img {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div.event-sender-and-time {
|
||||||
|
> span.event-sender, > span.event-edited {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.html-body {
|
div.html-body {
|
||||||
|
|
|
@ -14,27 +14,37 @@
|
||||||
// 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 from "react"
|
import React from "react"
|
||||||
import { getMediaURL } from "../../api/media.ts"
|
import { getAvatarURL } from "../../api/media.ts"
|
||||||
import { RoomStateStore } from "../../api/statestore.ts"
|
import { RoomStateStore } from "../../api/statestore.ts"
|
||||||
import { MemDBEvent, MemberEventContent } from "../../api/types"
|
import { MemDBEvent, MemberEventContent } from "../../api/types"
|
||||||
|
import EncryptedBody from "./content/EncryptedBody.tsx"
|
||||||
import HiddenEvent from "./content/HiddenEvent.tsx"
|
import HiddenEvent from "./content/HiddenEvent.tsx"
|
||||||
import MessageBody from "./content/MessageBody.tsx"
|
import MessageBody from "./content/MessageBody.tsx"
|
||||||
|
import RedactedBody from "./content/RedactedBody.tsx"
|
||||||
import { EventContentProps } from "./content/props.ts"
|
import { EventContentProps } from "./content/props.ts"
|
||||||
import "./TimelineEvent.css"
|
import "./TimelineEvent.css"
|
||||||
|
|
||||||
export interface TimelineEventProps {
|
export interface TimelineEventProps {
|
||||||
room: RoomStateStore
|
room: RoomStateStore
|
||||||
eventRowID: number
|
evt: MemDBEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps> {
|
function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps> {
|
||||||
if (evt.content["m.relates_to"]?.relation_type === "m.replace") {
|
if (evt.relation_type === "m.replace") {
|
||||||
return HiddenEvent
|
return HiddenEvent
|
||||||
}
|
}
|
||||||
switch (evt.type) {
|
switch (evt.type) {
|
||||||
case "m.room.message":
|
case "m.room.message":
|
||||||
case "m.sticker":
|
case "m.sticker":
|
||||||
|
if (evt.redacted_by) {
|
||||||
|
return RedactedBody
|
||||||
|
}
|
||||||
return MessageBody
|
return MessageBody
|
||||||
|
case "m.room.encrypted":
|
||||||
|
if (evt.redacted_by) {
|
||||||
|
return RedactedBody
|
||||||
|
}
|
||||||
|
return EncryptedBody
|
||||||
}
|
}
|
||||||
return HiddenEvent
|
return HiddenEvent
|
||||||
}
|
}
|
||||||
|
@ -43,30 +53,34 @@ const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full",
|
||||||
const formatShortTime = (time: Date) =>
|
const formatShortTime = (time: Date) =>
|
||||||
`${time.getHours().toString().padStart(2, "0")}:${time.getMinutes().toString().padStart(2, "0")}`
|
`${time.getHours().toString().padStart(2, "0")}:${time.getMinutes().toString().padStart(2, "0")}`
|
||||||
|
|
||||||
const TimelineEvent = ({ room, eventRowID }: TimelineEventProps) => {
|
const EventReactions = ({ reactions }: { reactions: Record<string, number> }) => {
|
||||||
const evt = room.eventsByRowID.get(eventRowID)
|
return <div className="event-reactions">
|
||||||
if (!evt) {
|
{Object.entries(reactions).map(([reaction, count]) => <span key={reaction} className="reaction">
|
||||||
return null
|
{reaction} {count}
|
||||||
}
|
</span>)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimelineEvent = ({ room, evt }: TimelineEventProps) => {
|
||||||
const memberEvt = room.getStateEvent("m.room.member", evt.sender)
|
const memberEvt = room.getStateEvent("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)
|
||||||
// if (BodyType === HiddenEvent) {
|
|
||||||
// return <div className="timeline-event">
|
|
||||||
// <BodyType room={room} event={evt}/>
|
|
||||||
// </div>
|
|
||||||
// }
|
|
||||||
const eventTS = new Date(evt.timestamp)
|
const eventTS = new Date(evt.timestamp)
|
||||||
return <div className="timeline-event">
|
const editEventTS = evt.last_edit ? new Date(evt.last_edit.timestamp) : null
|
||||||
<div className="sender-avatar">
|
return <div className={`timeline-event ${BodyType === HiddenEvent ? "hidden-event" : ""}`}>
|
||||||
<img loading="lazy" src={getMediaURL(memberEvtContent?.avatar_url)} alt="" />
|
<div className="sender-avatar" title={evt.sender}>
|
||||||
|
<img loading="lazy" src={getAvatarURL(evt.sender, memberEvtContent?.avatar_url)} alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div className="event-sender-and-time">
|
<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={fullTimeFormatter.format(eventTS)}>{formatShortTime(eventTS)}</span>
|
<span className="event-time" title={fullTimeFormatter.format(eventTS)}>{formatShortTime(eventTS)}</span>
|
||||||
|
{editEventTS ? <span className="event-edited" title={`Edited at ${fullTimeFormatter.format(editEventTS)}`}>
|
||||||
|
(edited at {formatShortTime(editEventTS)})
|
||||||
|
</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="event-content">
|
<div className="event-content">
|
||||||
<BodyType room={room} event={evt}/>
|
<BodyType room={room} event={evt}/>
|
||||||
|
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,7 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { use, useCallback, useEffect, useRef } from "react"
|
import { use, useCallback, useEffect, useRef } from "react"
|
||||||
import { RoomStateStore } from "../../api/statestore.ts"
|
import { RoomStateStore, useRoomTimeline } from "../../api/statestore.ts"
|
||||||
import { useNonNullEventAsState } from "../../util/eventdispatcher.ts"
|
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
import { ClientContext } from "../ClientContext.ts"
|
||||||
import TimelineEvent from "./TimelineEvent.tsx"
|
import TimelineEvent from "./TimelineEvent.tsx"
|
||||||
import "./TimelineView.css"
|
import "./TimelineView.css"
|
||||||
|
@ -25,15 +24,17 @@ interface TimelineViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineView = ({ room }: TimelineViewProps) => {
|
const TimelineView = ({ room }: TimelineViewProps) => {
|
||||||
const timeline = useNonNullEventAsState(room.timeline)
|
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.roomID])
|
}, [client, room.roomID])
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = 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)
|
||||||
|
const paginationRequestedForRow = useRef(-1)
|
||||||
const oldScrollHeight = useRef(0)
|
const oldScrollHeight = useRef(0)
|
||||||
const scrolledToBottom = useRef(true)
|
const scrolledToBottom = useRef(true)
|
||||||
|
|
||||||
|
@ -54,21 +55,40 @@ const TimelineView = ({ room }: TimelineViewProps) => {
|
||||||
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
|
||||||
bottomRef.current.scrollIntoView()
|
bottomRef.current.scrollIntoView()
|
||||||
} else if (timelineViewRef.current && prevOldestTimelineRow.current > timeline[0]?.timeline_rowid) {
|
} else if (timelineViewRef.current && prevOldestTimelineRow.current > (timeline[0]?.timeline_rowid ?? 0)) {
|
||||||
// When new entries are added to the top of the timeline, scroll down to keep the same position
|
// When new entries are added to the top of the timeline, scroll down to keep the same position
|
||||||
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])
|
}, [timeline])
|
||||||
|
useEffect(() => {
|
||||||
|
const topElem = topRef.current
|
||||||
|
if (!topElem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0]?.isIntersecting && paginationRequestedForRow.current !== prevOldestTimelineRow.current) {
|
||||||
|
paginationRequestedForRow.current = prevOldestTimelineRow.current
|
||||||
|
loadHistory()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
root: topElem.parentElement!.parentElement,
|
||||||
|
rootMargin: "0px",
|
||||||
|
threshold: 1.0,
|
||||||
|
})
|
||||||
|
observer.observe(topElem)
|
||||||
|
return () => observer.unobserve(topElem)
|
||||||
|
}, [loadHistory, topRef])
|
||||||
|
|
||||||
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
|
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
|
||||||
<div className="timeline-beginning">
|
<div className="timeline-beginning">
|
||||||
<button onClick={loadHistory}>Load history</button>
|
<button onClick={loadHistory}>Load history</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="timeline-list">
|
<div className="timeline-list">
|
||||||
{timeline.map(entry => <TimelineEvent
|
<div className="timeline-top-ref" ref={topRef}/>
|
||||||
key={entry.event_rowid} room={room} eventRowID={entry.event_rowid}
|
{timeline.map(entry => entry ? <TimelineEvent
|
||||||
/>)}
|
key={entry.rowid} room={room} evt={entry}
|
||||||
|
/> : null)}
|
||||||
<div className="timeline-bottom-ref" ref={bottomRef}/>
|
<div className="timeline-bottom-ref" ref={bottomRef}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
25
web/src/ui/timeline/content/EncryptedBody.tsx
Normal file
25
web/src/ui/timeline/content/EncryptedBody.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
// 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 { EventContentProps } from "./props.ts"
|
||||||
|
|
||||||
|
const EncryptedBody = ({ event }: EventContentProps) => {
|
||||||
|
if (event.decryption_error) {
|
||||||
|
return `Failed to decrypt: ${event.decryption_error}`
|
||||||
|
}
|
||||||
|
return `Waiting for message`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EncryptedBody
|
|
@ -104,7 +104,7 @@ const MessageBody = ({ event }: EventContentProps) => {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return <code>{`{ "type": "${event.type}" }`}</code>
|
return <code>{`{ "type": "${event.type}", "content": { "msgtype": "${content.msgtype}" } }`}</code>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MessageBody
|
export default MessageBody
|
||||||
|
|
20
web/src/ui/timeline/content/RedactedBody.tsx
Normal file
20
web/src/ui/timeline/content/RedactedBody.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// 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/>.
|
||||||
|
const RedactedBody = () => {
|
||||||
|
return `Message deleted`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RedactedBody
|
19
websocket.go
19
websocket.go
|
@ -252,17 +252,18 @@ func (gmx *Gomuks) sendInitialData(ctx context.Context, conn *websocket.Conn) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to get preview event for room")
|
log.Err(err).Msg("Failed to get preview event for room")
|
||||||
return
|
return
|
||||||
} else if previewEvent != nil {
|
|
||||||
syncRoom.Events = append(syncRoom.Events, previewEvent)
|
|
||||||
}
|
}
|
||||||
if previewEvent != nil && previewEvent.LastEditRowID != nil {
|
if previewEvent != nil {
|
||||||
lastEdit, err := gmx.Client.DB.Event.GetByRowID(ctx, *previewEvent.LastEditRowID)
|
if previewEvent.LastEditRowID != nil {
|
||||||
if err != nil {
|
lastEdit, err := gmx.Client.DB.Event.GetByRowID(ctx, *previewEvent.LastEditRowID)
|
||||||
log.Err(err).Msg("Failed to get last edit for preview event")
|
if err != nil {
|
||||||
return
|
log.Err(err).Msg("Failed to get last edit for preview event")
|
||||||
} else if lastEdit != nil {
|
return
|
||||||
syncRoom.Events = append(syncRoom.Events, lastEdit)
|
} else if lastEdit != nil {
|
||||||
|
syncRoom.Events = append(syncRoom.Events, lastEdit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
syncRoom.Events = append(syncRoom.Events, previewEvent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue