web/timeline: add power level event rendering

This commit is contained in:
Tulir Asokan 2024-10-28 01:42:03 +02:00
parent cffae7a3c8
commit 4bfa665937
8 changed files with 238 additions and 7 deletions

View file

@ -69,6 +69,12 @@ export interface MemberEventContent {
reason?: string
}
export interface ACLEventContent {
allow?: string[]
allow_ip_literals?: boolean
deny?: string[]
}
export interface PowerLevelEventContent {
users?: Record<UserID, number>
users_default?: number

View file

@ -18,7 +18,7 @@ import { getAvatarURL, getUserColorIndex } from "@/api/media.ts"
import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore"
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
import ClientContext from "../ClientContext.ts"
import getBodyType, { ContentErrorBoundary } from "./content"
import { ContentErrorBoundary, getBodyType } from "./content"
import CloseButton from "@/icons/close.svg?react"
import "./ReplyBody.css"

View file

@ -22,7 +22,7 @@ import ClientContext from "../ClientContext.ts"
import { LightboxContext } from "../modal/Lightbox.tsx"
import { useRoomContext } from "../roomcontext.ts"
import { ReplyIDBody } from "./ReplyBody.tsx"
import getBodyType, { ContentErrorBoundary, EventContentProps, HiddenEvent, MemberBody } from "./content"
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
import EventMenu from "./menu/EventMenu.tsx"
import ErrorIcon from "../../icons/error.svg?react"
import PendingIcon from "../../icons/pending.svg?react"
@ -68,10 +68,6 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
}
}
function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps>): boolean {
return bodyType === HiddenEvent || bodyType === MemberBody
}
const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
const roomCtx = useRoomContext()
const client = use(ClientContext)!

View file

@ -0,0 +1,28 @@
// 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 { ACLEventContent } from "@/api/types"
import EventContentProps from "./props.ts"
const ACLBody = ({ event, sender }: EventContentProps) => {
// const content = event.content as ACLEventContent
// const prevContent = event.unsigned.prev_content as ACLEventContent | undefined
// TODO diff content and prevContent
return <div className="acl-body">
{sender?.content.displayname ?? event.sender} changed the server ACLs
</div>
}
export default ACLBody

View file

@ -0,0 +1,42 @@
// 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 { PinnedEventsContent } from "@/api/types"
import { listDiff } from "@/util/diff.ts"
import EventContentProps from "./props.ts"
function renderPinChanges(content: PinnedEventsContent, prevContent?: PinnedEventsContent): string {
const { added, removed } = listDiff(content.pinned ?? [], prevContent?.pinned ?? [])
if (added.length) {
if (removed.length) {
return `pinned ${added.join(", ")} and unpinned ${removed.join(", ")}`
}
return `pinned ${added.join(", ")}`
} else if (removed.length) {
return `unpinned ${removed.join(", ")}`
} else {
return "sent a no-op pin event"
}
}
const PinnedEventsBody = ({ event, sender }: EventContentProps) => {
const content = event.content as PinnedEventsContent
const prevContent = event.unsigned.prev_content as PinnedEventsContent | undefined
return <div className="pinned-events-body">
{sender?.content.displayname ?? event.sender} {renderPinChanges(content, prevContent)}
</div>
}
export default PinnedEventsBody

View file

@ -0,0 +1,74 @@
// 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 { PowerLevelEventContent } from "@/api/types"
import { objectDiff } from "@/util/diff.ts"
import EventContentProps from "./props.ts"
function intDiff(messageParts: TemplateStringsArray, oldVal: number, newVal: number): string | null {
if (oldVal === newVal) {
return null
}
return `${messageParts[0]}${oldVal}${messageParts[1]}${newVal}${messageParts[2] ?? ""}`
}
function renderPowerLevels(content: PowerLevelEventContent, prevContent?: PowerLevelEventContent): string[] {
/* eslint-disable max-len */
const output = [
intDiff`the default user power level from ${prevContent?.users_default ?? 0} to ${content.users_default ?? 0}`,
intDiff`the default event power level from ${prevContent?.events_default ?? 0} to ${content.events_default ?? 0}`,
intDiff`the default state event power level from ${prevContent?.state_default ?? 50} to ${content.state_default ?? 50}`,
intDiff`the ban power level from ${prevContent?.ban ?? 50} to ${content.ban ?? 50}`,
intDiff`the kick power level from ${prevContent?.kick ?? 50} to ${content.kick ?? 50}`,
intDiff`the redact power level from ${prevContent?.redact ?? 50} to ${content.redact ?? 50}`,
intDiff`the invite power level from ${prevContent?.redact ?? 0} to ${content.redact ?? 0}`,
intDiff`the @room notification power level from ${prevContent?.notifications?.room ?? 50} to ${content.notifications?.room ?? 50}`,
]
/* eslint-enable max-len */
const userDiffs = objectDiff(
content.users ?? {},
prevContent?.users ?? {},
content.users_default ?? 0,
prevContent?.users_default ?? 0,
)
for (const [userID, { old: oldLevel, new: newLevel }] of userDiffs.entries()) {
output.push(`changed ${userID}'s power level from ${oldLevel} to ${newLevel}`)
}
const eventDiffs = objectDiff(content.events ?? {}, prevContent?.events ?? {})
for (const [eventType, { old: oldLevel, new: newLevel }] of eventDiffs.entries()) {
if (oldLevel === undefined) {
output.push(`set the power level for ${eventType} to ${newLevel}`)
} else if (newLevel === undefined) {
output.push(`removed the power level for ${eventType} (was ${oldLevel})`)
} else {
output.push(`changed the power level for ${eventType} from ${oldLevel} to ${newLevel}`)
}
}
const filtered = output.filter(x => x !== null)
if (filtered.length === 0) {
return ["sent a power level event with no changes"]
}
return filtered
}
const PowerLevelBody = ({ event, sender }: EventContentProps) => {
const content = event.content as PowerLevelEventContent
const prevContent = event.unsigned.prev_content as PowerLevelEventContent | undefined
return <div className="power-level-body">
{sender?.content.displayname ?? event.sender} {renderPowerLevels(content, prevContent).join(", ")}
</div>
}
export default PowerLevelBody

View file

@ -1,26 +1,32 @@
import React from "react"
import { MemDBEvent } from "@/api/types"
import ACLBody from "./ACLBody.tsx"
import EncryptedBody from "./EncryptedBody.tsx"
import HiddenEvent from "./HiddenEvent.tsx"
import MediaMessageBody from "./MediaMessageBody.tsx"
import MemberBody from "./MemberBody.tsx"
import PinnedEventsBody from "./PinnedEventsBody.tsx"
import PowerLevelBody from "./PowerLevelBody.tsx"
import RedactedBody from "./RedactedBody.tsx"
import TextMessageBody from "./TextMessageBody.tsx"
import UnknownMessageBody from "./UnknownMessageBody.tsx"
import EventContentProps from "./props.ts"
import "./index.css"
export { default as ACLBody } from "./ACLBody.tsx"
export { default as ContentErrorBoundary } from "./ContentErrorBoundary.tsx"
export { default as EncryptedBody } from "./EncryptedBody.tsx"
export { default as HiddenEvent } from "./HiddenEvent.tsx"
export { default as MediaMessageBody } from "./MediaMessageBody.tsx"
export { default as MemberBody } from "./MemberBody.tsx"
export { default as PinnedEventsBody } from "./PinnedEventsBody.tsx"
export { default as PowerLevelBody } from "./PowerLevelBody.tsx"
export { default as RedactedBody } from "./RedactedBody.tsx"
export { default as TextMessageBody } from "./TextMessageBody.tsx"
export { default as UnknownMessageBody } from "./UnknownMessageBody.tsx"
export type { default as EventContentProps } from "./props.ts"
export default function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionComponent<EventContentProps> {
export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionComponent<EventContentProps> {
if (evt.relation_type === "m.replace") {
return HiddenEvent
}
@ -62,6 +68,25 @@ export default function getBodyType(evt: MemDBEvent, forReply = false): React.Fu
return EncryptedBody
case "m.room.member":
return MemberBody
case "m.room.server_acl":
return ACLBody
case "m.room.pinned_events":
return PinnedEventsBody
case "m.room.power_levels":
return PowerLevelBody
}
return HiddenEvent
}
export function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps>): boolean {
switch (bodyType) {
case HiddenEvent:
case MemberBody:
case ACLBody:
case PinnedEventsBody:
case PowerLevelBody:
return true
default:
return false
}
}

60
web/src/util/diff.ts Normal file
View file

@ -0,0 +1,60 @@
// 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 minSizeForSet = 10
export function listDiff<T>(newArr: T[], oldArr: T[]): { added: T[], removed: T[] } {
if (oldArr.length < minSizeForSet && newArr.length < minSizeForSet) {
return {
removed: oldArr.filter(item => !newArr.includes(item)),
added: newArr.filter(item => !oldArr.includes(item)),
}
}
const oldSet = new Set(oldArr)
const newSet = new Set(newArr)
return {
removed: oldArr.filter(item => !newSet.has(item)),
added: newArr.filter(item => !oldSet.has(item)),
}
}
export function objectDiff<T>(
newObj: Record<string, T>,
oldObj: Record<string, T>,
defaultValue: T,
prevDefaultValue?: T,
): Map<string, { old: T, new: T }>
export function objectDiff<T>(
newObj: Record<string, T>,
oldObj: Record<string, T>,
): Map<string, { old?: T, new?: T }>
export function objectDiff<T>(
newObj: Record<string, T>,
oldObj: Record<string, T>,
defaultValue?: T,
prevDefaultValue?: T,
): Map<string, { old?: T, new?: T }> {
const keys = new Set(Object.keys(oldObj).concat(Object.keys(newObj)))
const diff = new Map<string, { old?: T, new?: T }>()
for (const key of keys) {
const oldVal = Object.prototype.hasOwnProperty.call(oldObj, key) ? oldObj[key] :
(prevDefaultValue ?? defaultValue)
const newVal = Object.prototype.hasOwnProperty.call(newObj, key) ? newObj[key] : defaultValue
if (oldVal !== newVal) {
diff.set(key, { old: oldVal, new: newVal })
}
}
return diff
}