diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 9c10ad1..639c4c1 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -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 users_default?: number diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index c056420..14e8655 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -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" diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index b30e159..b979139 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -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): boolean { - return bodyType === HiddenEvent || bodyType === MemberBody -} - const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => { const roomCtx = useRoomContext() const client = use(ClientContext)! diff --git a/web/src/ui/timeline/content/ACLBody.tsx b/web/src/ui/timeline/content/ACLBody.tsx new file mode 100644 index 0000000..9a136fb --- /dev/null +++ b/web/src/ui/timeline/content/ACLBody.tsx @@ -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 . +// 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
+ {sender?.content.displayname ?? event.sender} changed the server ACLs +
+} + +export default ACLBody diff --git a/web/src/ui/timeline/content/PinnedEventsBody.tsx b/web/src/ui/timeline/content/PinnedEventsBody.tsx new file mode 100644 index 0000000..80ee124 --- /dev/null +++ b/web/src/ui/timeline/content/PinnedEventsBody.tsx @@ -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 . +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
+ {sender?.content.displayname ?? event.sender} {renderPinChanges(content, prevContent)} +
+} + +export default PinnedEventsBody diff --git a/web/src/ui/timeline/content/PowerLevelBody.tsx b/web/src/ui/timeline/content/PowerLevelBody.tsx new file mode 100644 index 0000000..780b9b7 --- /dev/null +++ b/web/src/ui/timeline/content/PowerLevelBody.tsx @@ -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 . +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
+ {sender?.content.displayname ?? event.sender} {renderPowerLevels(content, prevContent).join(", ")} +
+} + +export default PowerLevelBody diff --git a/web/src/ui/timeline/content/index.ts b/web/src/ui/timeline/content/index.ts index ecbf68c..05927ea 100644 --- a/web/src/ui/timeline/content/index.ts +++ b/web/src/ui/timeline/content/index.ts @@ -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 { +export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionComponent { 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): boolean { + switch (bodyType) { + case HiddenEvent: + case MemberBody: + case ACLBody: + case PinnedEventsBody: + case PowerLevelBody: + return true + default: + return false + } +} diff --git a/web/src/util/diff.ts b/web/src/util/diff.ts new file mode 100644 index 0000000..e4011f1 --- /dev/null +++ b/web/src/util/diff.ts @@ -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 . +const minSizeForSet = 10 + +export function listDiff(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( + newObj: Record, + oldObj: Record, + defaultValue: T, + prevDefaultValue?: T, +): Map +export function objectDiff( + newObj: Record, + oldObj: Record, +): Map +export function objectDiff( + newObj: Record, + oldObj: Record, + defaultValue?: T, + prevDefaultValue?: T, +): Map { + const keys = new Set(Object.keys(oldObj).concat(Object.keys(newObj))) + const diff = new Map() + 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 +}