From e8f2029dbbaeeda25b87ec43e46385622b2ff300 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 27 Oct 2024 02:09:59 +0300 Subject: [PATCH] web/timeline: add view source, report and redact event buttons to menu --- pkg/hicli/json-commands.go | 22 ++++ web/src/api/rpc.ts | 8 ++ web/src/icons/code.svg | 1 + web/src/icons/report.svg | 1 + web/src/ui/modal/Modal.tsx | 26 ++-- web/src/ui/timeline/EventMenu.css | 20 ---- web/src/ui/timeline/EventMenu.tsx | 112 ------------------ web/src/ui/timeline/TimelineEvent.tsx | 9 +- .../timeline/menu/ConfirmWithMessageModal.tsx | 59 +++++++++ web/src/ui/timeline/menu/EventExtraMenu.tsx | 112 ++++++++++++++++++ web/src/ui/timeline/menu/EventMenu.tsx | 100 ++++++++++++++++ web/src/ui/timeline/menu/ViewSourceModal.tsx | 30 +++++ web/src/ui/timeline/menu/index.css | 105 ++++++++++++++++ 13 files changed, 457 insertions(+), 148 deletions(-) create mode 100644 web/src/icons/code.svg create mode 100644 web/src/icons/report.svg delete mode 100644 web/src/ui/timeline/EventMenu.css delete mode 100644 web/src/ui/timeline/EventMenu.tsx create mode 100644 web/src/ui/timeline/menu/ConfirmWithMessageModal.tsx create mode 100644 web/src/ui/timeline/menu/EventExtraMenu.tsx create mode 100644 web/src/ui/timeline/menu/EventMenu.tsx create mode 100644 web/src/ui/timeline/menu/ViewSourceModal.tsx create mode 100644 web/src/ui/timeline/menu/index.css diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index c8e0546..b20108d 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -47,6 +47,16 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) { return h.Send(ctx, params.RoomID, params.EventType, params.Content) }) + case "report_event": + return unmarshalAndCall(req.Data, func(params *reportEventParams) (bool, error) { + return true, h.Client.ReportEvent(ctx, params.RoomID, params.EventID, params.Reason) + }) + case "redact_event": + return unmarshalAndCall(req.Data, func(params *redactEventParams) (*mautrix.RespSendEvent, error) { + return h.Client.RedactEvent(ctx, params.RoomID, params.EventID, mautrix.ReqRedact{ + Reason: params.Reason, + }) + }) case "set_state": return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) { return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content) @@ -148,6 +158,18 @@ type sendEventParams struct { Content json.RawMessage `json:"content"` } +type reportEventParams struct { + RoomID id.RoomID `json:"room_id"` + EventID id.EventID `json:"event_id"` + Reason string `json:"reason"` +} + +type redactEventParams struct { + RoomID id.RoomID `json:"room_id"` + EventID id.EventID `json:"event_id"` + Reason string `json:"reason"` +} + type sendStateEventParams struct { RoomID id.RoomID `json:"room_id"` EventType event.Type `json:"type"` diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 8f3a2a2..62ac75a 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -133,6 +133,14 @@ export default abstract class RPCClient { return this.request("send_event", { room_id, type, content }) } + reportEvent(room_id: RoomID, event_id: EventID, reason: string): Promise { + return this.request("report_event", { room_id, event_id, reason }) + } + + redactEvent(room_id: RoomID, event_id: EventID, reason: string): Promise { + return this.request("redact_event", { room_id, event_id, reason }) + } + setState( room_id: RoomID, type: EventType, state_key: string, content: Record, ): Promise { diff --git a/web/src/icons/code.svg b/web/src/icons/code.svg new file mode 100644 index 0000000..d258791 --- /dev/null +++ b/web/src/icons/code.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/report.svg b/web/src/icons/report.svg new file mode 100644 index 0000000..5b7cc8c --- /dev/null +++ b/web/src/icons/report.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/modal/Modal.tsx b/web/src/ui/modal/Modal.tsx index 2cc7017..2e5fbc7 100644 --- a/web/src/ui/modal/Modal.tsx +++ b/web/src/ui/modal/Modal.tsx @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { JSX, createContext, useCallback, useState } from "react" +import React, { JSX, createContext, useCallback, useReducer } from "react" export interface ModalState { content: JSX.Element @@ -30,25 +30,27 @@ export const ModalContext = createContext(() => export const ModalCloseContext = createContext<() => void>(() => {}) export const ModalWrapper = ({ children }: { children: React.ReactNode }) => { - const [state, setState] = useState(null) + const [state, setState] = useReducer((prevState: ModalState | null, newState: ModalState | null) => { + prevState?.onClose?.() + return newState + }, null) const onClickWrapper = useCallback((evt?: React.MouseEvent) => { if (evt && evt.target !== evt.currentTarget) { return } setState(null) - state?.onClose?.() - }, [state]) + }, []) return <> {children} + {state &&
+ + {state.content} + +
}
- {state &&
- - {state.content} - -
} } diff --git a/web/src/ui/timeline/EventMenu.css b/web/src/ui/timeline/EventMenu.css deleted file mode 100644 index c8b12fa..0000000 --- a/web/src/ui/timeline/EventMenu.css +++ /dev/null @@ -1,20 +0,0 @@ -div.context-menu { - position: absolute; - right: .5rem; - top: -1.5rem; - background-color: white; - border: 1px solid #ccc; - border-radius: .25rem; - display: flex; - gap: .25rem; - padding: .125rem; - - > button { - padding: 0; - width: 2rem; - height: 2rem; - display: flex; - justify-content: center; - align-items: center; - } -} diff --git a/web/src/ui/timeline/EventMenu.tsx b/web/src/ui/timeline/EventMenu.tsx deleted file mode 100644 index be7aca5..0000000 --- a/web/src/ui/timeline/EventMenu.tsx +++ /dev/null @@ -1,112 +0,0 @@ -// 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 { CSSProperties, use, useCallback, useRef } from "react" -import { useRoomState } from "@/api/statestore" -import { MemDBEvent, PowerLevelEventContent } from "@/api/types" -import { emojiToReactionContent } from "@/util/emoji" -import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" -import { ClientContext } from "../ClientContext.ts" -import EmojiPicker from "../emojipicker/EmojiPicker.tsx" -import { ModalContext } from "../modal/Modal.tsx" -import { useRoomContext } from "../roomcontext.ts" -import EditIcon from "../../icons/edit.svg?react" -import MoreIcon from "../../icons/more.svg?react" -import PinIcon from "../../icons/pin.svg?react" -import ReactIcon from "../../icons/react.svg?react" -import ReplyIcon from "../../icons/reply.svg?react" -import UnpinIcon from "../../icons/unpin.svg?react" -import "./EventMenu.css" - -interface EventHoverMenuProps { - evt: MemDBEvent - setForceOpen: (forceOpen: boolean) => void -} - -const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => { - const client = use(ClientContext)! - const userID = client.userID - const roomCtx = useRoomContext() - const openModal = use(ModalContext) - const contextMenuRef = useRef(null) - const onClickReply = useCallback(() => roomCtx.setReplyTo(evt.event_id), [roomCtx, evt.event_id]) - const onClickPin = useCallback(() => { - client.pinMessage(roomCtx.store, evt.event_id, true) - .catch(err => window.alert(`Failed to pin message: ${err}`)) - }, [client, roomCtx, evt.event_id]) - const onClickUnpin = useCallback(() => { - client.pinMessage(roomCtx.store, evt.event_id, false) - .catch(err => window.alert(`Failed to unpin message: ${err}`)) - }, [client, roomCtx, evt.event_id]) - const onClickReact = useCallback(() => { - const reactionButton = contextMenuRef.current?.getElementsByClassName("reaction-button")?.[0] - if (!reactionButton) { - return - } - const rect = reactionButton.getBoundingClientRect() - const style: CSSProperties = { right: window.innerWidth - rect.right } - const emojiPickerHeight = 30 * 16 - if (rect.bottom + emojiPickerHeight > window.innerHeight) { - style.bottom = window.innerHeight - rect.top - } else { - style.top = rect.bottom - } - setForceOpen(true) - openModal({ - content: { - client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id)) - .catch(err => window.alert(`Failed to send reaction: ${err}`)) - }} - room={roomCtx.store} - closeOnSelect={true} - allowFreeform={true} - />, - onClose: () => setForceOpen(false), - }) - }, [client, roomCtx, evt, setForceOpen, openModal]) - const onClickEdit = useCallback(() => { - roomCtx.setEditing(evt) - }, [roomCtx, evt]) - const onClickMore = useCallback(() => { - window.alert("Nothing here yet :(") - }, []) - const isEditing = useNonNullEventAsState(roomCtx.isEditing) - const plEvent = useRoomState(roomCtx.store, "m.room.power_levels", "") - // We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes - useRoomState(roomCtx.store, "m.room.pinned_events", "") - const pls = (plEvent?.content ?? {}) as PowerLevelEventContent - const pins = roomCtx.store.getPinnedEvents() - const ownPL = pls.users?.[userID] ?? pls.users_default ?? 0 - const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50 - return
- - - {evt.sender === userID && evt.type === "m.room.message" && evt.relation_type !== "m.replace" - && } - {ownPL >= pinPL && (pins.includes(evt.event_id) - ? - : )} - -
-} - -export default EventMenu diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 7069f70..a001b7e 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -21,9 +21,9 @@ import { isEventID } from "@/util/validation.ts" import { ClientContext } from "../ClientContext.ts" import { LightboxContext } from "../modal/Lightbox.tsx" import { useRoomContext } from "../roomcontext.ts" -import EventMenu from "./EventMenu.tsx" import { ReplyIDBody } from "./ReplyBody.tsx" import getBodyType, { ContentErrorBoundary, EventContentProps, HiddenEvent, MemberBody } from "./content" +import EventMenu from "./menu/EventMenu.tsx" import ErrorIcon from "../../icons/error.svg?react" import PendingIcon from "../../icons/pending.svg?react" import SentIcon from "../../icons/sent.svg?react" @@ -32,6 +32,7 @@ import "./TimelineEvent.css" export interface TimelineEventProps { evt: MemDBEvent prevEvt: MemDBEvent | null + disableMenu?: boolean } const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" }) @@ -67,7 +68,7 @@ function isSmallEvent(bodyType: React.FunctionComponent): boo return bodyType === HiddenEvent || bodyType === MemberBody } -const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => { +const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => { const roomCtx = useRoomContext() const client = use(ClientContext)! const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false) @@ -96,9 +97,9 @@ const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => { const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"] const replyTo = relatesTo?.["m.in_reply_to"]?.event_id const mainEvent =
-
+ {!disableMenu &&
-
+
}
. +import { use, useCallback, useState } from "react" +import { MemDBEvent } from "@/api/types" +import useEvent from "@/util/useEvent.ts" +import { ModalCloseContext } from "../../modal/Modal.tsx" +import TimelineEvent from "../TimelineEvent.tsx" + +interface ConfirmWithMessageProps { + evt: MemDBEvent + title: string + description: string + placeholder: string + confirmButton: string + onConfirm: (reason: string) => void +} + +const ConfirmWithMessageProps = ({ + evt, title, description, placeholder, confirmButton, onConfirm, +}: ConfirmWithMessageProps) => { + const [reason, setReason] = useState("") + const closeModal = use(ModalCloseContext) + const onConfirmWrapped = useEvent(() => { + closeModal() + onConfirm(reason) + }) + const onChangeReason = useCallback((evt: React.ChangeEvent) => { + setReason(evt.target.value) + }, []) + return
+

{title}

+
+ +
+
+ {description} +
+ +
+ + +
+
+} + +export default ConfirmWithMessageProps diff --git a/web/src/ui/timeline/menu/EventExtraMenu.tsx b/web/src/ui/timeline/menu/EventExtraMenu.tsx new file mode 100644 index 0000000..935a4b6 --- /dev/null +++ b/web/src/ui/timeline/menu/EventExtraMenu.tsx @@ -0,0 +1,112 @@ +// 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 { CSSProperties, use, useCallback } from "react" +import { RoomStateStore, useRoomState } from "@/api/statestore" +import { MemDBEvent, PowerLevelEventContent } from "@/api/types" +import { ClientContext } from "../../ClientContext.ts" +import { ModalCloseContext, ModalContext } from "../../modal/Modal.tsx" +import { RoomContext, RoomContextData } from "../../roomcontext.ts" +import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx" +import ViewSourceModal from "./ViewSourceModal.tsx" +import ViewSourceIcon from "@/icons/code.svg?react" +import DeleteIcon from "@/icons/delete.svg?react" +import PinIcon from "@/icons/pin.svg?react" +import ReportIcon from "@/icons/report.svg?react" +import UnpinIcon from "@/icons/unpin.svg?react" + +interface EventExtraMenuProps { + evt: MemDBEvent + room: RoomStateStore + style: CSSProperties +} + +const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => { + const client = use(ClientContext)! + const userID = client.userID + const closeModal = use(ModalCloseContext) + const openModal = use(ModalContext) + const onClickViewSource = useCallback(() => { + openModal({ dimmed: true, content: }) + }, [evt, openModal]) + const onClickReport = useCallback(() => { + openModal({ + dimmed: true, + content: + { + client.rpc.reportEvent(evt.room_id, evt.event_id, reason) + .catch(err => window.alert(`Failed to report message: ${err}`)) + }} + /> + , + }) + }, [evt, room, openModal, client]) + const onClickRedact = useCallback(() => { + openModal({ + dimmed: true, + content: + { + client.rpc.redactEvent(evt.room_id, evt.event_id, reason) + .catch(err => window.alert(`Failed to redact message: ${err}`)) + }} + /> + , + }) + }, [evt, room, openModal, client]) + const onClickPin = useCallback(() => { + closeModal() + client.pinMessage(room, evt.event_id, true) + .catch(err => window.alert(`Failed to pin message: ${err}`)) + }, [closeModal, client, room, evt.event_id]) + const onClickUnpin = useCallback(() => { + closeModal() + client.pinMessage(room, evt.event_id, false) + .catch(err => window.alert(`Failed to unpin message: ${err}`)) + }, [closeModal, client, room, evt.event_id]) + + const plEvent = useRoomState(room, "m.room.power_levels", "") + // We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes + useRoomState(room, "m.room.pinned_events", "") + const pls = (plEvent?.content ?? {}) as PowerLevelEventContent + const pins = room.getPinnedEvents() + const ownPL = pls.users?.[userID] ?? pls.users_default ?? 0 + const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50 + const redactEvtPL = pls.events?.["m.room.redaction"] ?? pls.events_default ?? 0 + const redactOtherPL = pls.redact ?? 50 + const canRedact = !evt.redacted_by && ownPL >= redactEvtPL && (evt.sender === userID || ownPL >= redactOtherPL) + + return
+ + {ownPL >= pinPL && (pins.includes(evt.event_id) + ? + : )} + + {canRedact && } +
+} + +export default EventExtraMenu diff --git a/web/src/ui/timeline/menu/EventMenu.tsx b/web/src/ui/timeline/menu/EventMenu.tsx new file mode 100644 index 0000000..eac5519 --- /dev/null +++ b/web/src/ui/timeline/menu/EventMenu.tsx @@ -0,0 +1,100 @@ +// 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 { CSSProperties, use, useCallback, useRef } from "react" +import { MemDBEvent } from "@/api/types" +import { emojiToReactionContent } from "@/util/emoji" +import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" +import { ClientContext } from "../../ClientContext.ts" +import EmojiPicker from "../../emojipicker/EmojiPicker.tsx" +import { ModalContext } from "../../modal/Modal.tsx" +import { useRoomContext } from "../../roomcontext.ts" +import EventExtraMenu from "./EventExtraMenu.tsx" +import EditIcon from "@/icons/edit.svg?react" +import MoreIcon from "@/icons/more.svg?react" +import ReactIcon from "@/icons/react.svg?react" +import ReplyIcon from "@/icons/reply.svg?react" +import "./index.css" + +interface EventHoverMenuProps { + evt: MemDBEvent + setForceOpen: (forceOpen: boolean) => void +} + +function getModalStyle(button: HTMLButtonElement, modalHeight: number): CSSProperties { + const rect = button.getBoundingClientRect() + const style: CSSProperties = { right: window.innerWidth - rect.right } + if (rect.bottom + modalHeight > window.innerHeight) { + style.bottom = window.innerHeight - rect.top + } else { + style.top = rect.bottom + } + return style +} + +const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => { + const client = use(ClientContext)! + const userID = client.userID + const roomCtx = useRoomContext() + const openModal = use(ModalContext) + const contextMenuRef = useRef(null) + const onClickReply = useCallback(() => roomCtx.setReplyTo(evt.event_id), [roomCtx, evt.event_id]) + const onClickReact = useCallback((mevt: React.MouseEvent) => { + const emojiPickerHeight = 34 * 16 + setForceOpen(true) + openModal({ + content: { + client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id)) + .catch(err => window.alert(`Failed to send reaction: ${err}`)) + }} + room={roomCtx.store} + closeOnSelect={true} + allowFreeform={true} + />, + onClose: () => setForceOpen(false), + }) + }, [client, roomCtx, evt, setForceOpen, openModal]) + const onClickEdit = useCallback(() => { + roomCtx.setEditing(evt) + }, [roomCtx, evt]) + const onClickMore = useCallback((mevt: React.MouseEvent) => { + const moreMenuHeight = 10 * 16 + setForceOpen(true) + openModal({ + content: , + onClose: () => setForceOpen(false), + }) + }, [evt, roomCtx, setForceOpen, openModal]) + const isEditing = useNonNullEventAsState(roomCtx.isEditing) + return
+ + + {evt.sender === userID && evt.type === "m.room.message" && evt.relation_type !== "m.replace" && !evt.redacted_by + && } + +
+} + +export default EventMenu diff --git a/web/src/ui/timeline/menu/ViewSourceModal.tsx b/web/src/ui/timeline/menu/ViewSourceModal.tsx new file mode 100644 index 0000000..fd20fbc --- /dev/null +++ b/web/src/ui/timeline/menu/ViewSourceModal.tsx @@ -0,0 +1,30 @@ +// 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 { MemDBEvent } from "@/api/types" + +interface ViewSourceModalProps { + evt: MemDBEvent +} + +const ViewSourceModal = ({ evt }: ViewSourceModalProps) => { + return
+
+			{JSON.stringify(evt, null, "  ")}
+		
+
+} + +export default ViewSourceModal diff --git a/web/src/ui/timeline/menu/index.css b/web/src/ui/timeline/menu/index.css new file mode 100644 index 0000000..82ff616 --- /dev/null +++ b/web/src/ui/timeline/menu/index.css @@ -0,0 +1,105 @@ +div.event-hover-menu { + position: absolute; + right: .5rem; + top: -1.5rem; + background-color: white; + border: 1px solid #ccc; + border-radius: .5rem; + display: flex; + gap: .25rem; + padding: .125rem; + + > button { + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; + } +} + +div.event-context-menu-extra { + position: fixed; + background-color: white; + border-radius: .5rem; + border: 1px solid #ccc; + + display: flex; + flex-direction: column; + + > button { + border-radius: 0; + padding: .5rem .75rem; + display: flex; + justify-content: left; + align-items: center; + gap: .5rem; + + &:first-of-type { + border-radius: .5rem .5rem 0 0; + } + &:last-of-type { + border-radius: 0 0 .5rem .5rem; + } + + &.redact-button { + color: red; + } + } +} + +div.view-source-modal { + max-width: min(80rem, 80vw); + max-height: min(80rem, 80vh); + overflow: auto; + background-color: white; + border-radius: 1rem; + padding: 1rem; + + > pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + } +} + +div.confirm-message-modal { + width: min(40rem, 80vw); + max-height: min(40rem, 80vh); + background-color: white; + border-radius: 1rem; + padding: 1rem; + display: flex; + flex-direction: column; + gap: .5rem; + + > h3 { + margin: 0; + } + + > div.timeline-event-container { + margin: .5rem 0; + padding-left: .5rem; + border-left: 2px solid #ccc; + + > div.timeline-event { + margin: 0; + } + } + + > input { + padding: 1rem; + outline: none; + border-radius: .25rem; + border: 1px solid #ccc; + } + + > div.confirm-buttons { + display: flex; + justify-content: right; + > button { + padding: .5rem 1rem; + } + } +}