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
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ACLEventContent {
|
||||||
|
allow?: string[]
|
||||||
|
allow_ip_literals?: boolean
|
||||||
|
deny?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface PowerLevelEventContent {
|
export interface PowerLevelEventContent {
|
||||||
users?: Record<UserID, number>
|
users?: Record<UserID, number>
|
||||||
users_default?: number
|
users_default?: number
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { getAvatarURL, getUserColorIndex } from "@/api/media.ts"
|
||||||
import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore"
|
import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore"
|
||||||
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
|
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import getBodyType, { ContentErrorBoundary } from "./content"
|
import { ContentErrorBoundary, getBodyType } from "./content"
|
||||||
import CloseButton from "@/icons/close.svg?react"
|
import CloseButton from "@/icons/close.svg?react"
|
||||||
import "./ReplyBody.css"
|
import "./ReplyBody.css"
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ import ClientContext from "../ClientContext.ts"
|
||||||
import { LightboxContext } from "../modal/Lightbox.tsx"
|
import { LightboxContext } from "../modal/Lightbox.tsx"
|
||||||
import { useRoomContext } from "../roomcontext.ts"
|
import { useRoomContext } from "../roomcontext.ts"
|
||||||
import { ReplyIDBody } from "./ReplyBody.tsx"
|
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 EventMenu from "./menu/EventMenu.tsx"
|
||||||
import ErrorIcon from "../../icons/error.svg?react"
|
import ErrorIcon from "../../icons/error.svg?react"
|
||||||
import PendingIcon from "../../icons/pending.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 TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
|
||||||
const roomCtx = useRoomContext()
|
const roomCtx = useRoomContext()
|
||||||
const client = use(ClientContext)!
|
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 React from "react"
|
||||||
import { MemDBEvent } from "@/api/types"
|
import { MemDBEvent } from "@/api/types"
|
||||||
|
import ACLBody from "./ACLBody.tsx"
|
||||||
import EncryptedBody from "./EncryptedBody.tsx"
|
import EncryptedBody from "./EncryptedBody.tsx"
|
||||||
import HiddenEvent from "./HiddenEvent.tsx"
|
import HiddenEvent from "./HiddenEvent.tsx"
|
||||||
import MediaMessageBody from "./MediaMessageBody.tsx"
|
import MediaMessageBody from "./MediaMessageBody.tsx"
|
||||||
import MemberBody from "./MemberBody.tsx"
|
import MemberBody from "./MemberBody.tsx"
|
||||||
|
import PinnedEventsBody from "./PinnedEventsBody.tsx"
|
||||||
|
import PowerLevelBody from "./PowerLevelBody.tsx"
|
||||||
import RedactedBody from "./RedactedBody.tsx"
|
import RedactedBody from "./RedactedBody.tsx"
|
||||||
import TextMessageBody from "./TextMessageBody.tsx"
|
import TextMessageBody from "./TextMessageBody.tsx"
|
||||||
import UnknownMessageBody from "./UnknownMessageBody.tsx"
|
import UnknownMessageBody from "./UnknownMessageBody.tsx"
|
||||||
import EventContentProps from "./props.ts"
|
import EventContentProps from "./props.ts"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
|
||||||
|
export { default as ACLBody } from "./ACLBody.tsx"
|
||||||
export { default as ContentErrorBoundary } from "./ContentErrorBoundary.tsx"
|
export { default as ContentErrorBoundary } from "./ContentErrorBoundary.tsx"
|
||||||
export { default as EncryptedBody } from "./EncryptedBody.tsx"
|
export { default as EncryptedBody } from "./EncryptedBody.tsx"
|
||||||
export { default as HiddenEvent } from "./HiddenEvent.tsx"
|
export { default as HiddenEvent } from "./HiddenEvent.tsx"
|
||||||
export { default as MediaMessageBody } from "./MediaMessageBody.tsx"
|
export { default as MediaMessageBody } from "./MediaMessageBody.tsx"
|
||||||
export { default as MemberBody } from "./MemberBody.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 RedactedBody } from "./RedactedBody.tsx"
|
||||||
export { default as TextMessageBody } from "./TextMessageBody.tsx"
|
export { default as TextMessageBody } from "./TextMessageBody.tsx"
|
||||||
export { default as UnknownMessageBody } from "./UnknownMessageBody.tsx"
|
export { default as UnknownMessageBody } from "./UnknownMessageBody.tsx"
|
||||||
export type { default as EventContentProps } from "./props.ts"
|
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") {
|
if (evt.relation_type === "m.replace") {
|
||||||
return HiddenEvent
|
return HiddenEvent
|
||||||
}
|
}
|
||||||
|
@ -62,6 +68,25 @@ export default function getBodyType(evt: MemDBEvent, forReply = false): React.Fu
|
||||||
return EncryptedBody
|
return EncryptedBody
|
||||||
case "m.room.member":
|
case "m.room.member":
|
||||||
return MemberBody
|
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
|
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