mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/timeline: add power level event rendering
This commit is contained in:
parent
cffae7a3c8
commit
4bfa665937
8 changed files with 238 additions and 7 deletions
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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)!
|
||||
|
|
28
web/src/ui/timeline/content/ACLBody.tsx
Normal file
28
web/src/ui/timeline/content/ACLBody.tsx
Normal 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
|
42
web/src/ui/timeline/content/PinnedEventsBody.tsx
Normal file
42
web/src/ui/timeline/content/PinnedEventsBody.tsx
Normal 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
|
74
web/src/ui/timeline/content/PowerLevelBody.tsx
Normal file
74
web/src/ui/timeline/content/PowerLevelBody.tsx
Normal 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
|
|
@ -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
60
web/src/util/diff.ts
Normal 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
|
||||
}
|
Loading…
Add table
Reference in a new issue