diff --git a/web/src/icons/notifications-off.svg b/web/src/icons/notifications-off.svg new file mode 100644 index 0000000..dcaba96 --- /dev/null +++ b/web/src/icons/notifications-off.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/notifications.svg b/web/src/icons/notifications.svg new file mode 100644 index 0000000..5c49afe --- /dev/null +++ b/web/src/icons/notifications.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/thread.svg b/web/src/icons/thread.svg new file mode 100644 index 0000000..aa5dec1 --- /dev/null +++ b/web/src/icons/thread.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index 0124c0e..ee83e62 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -60,15 +60,30 @@ export interface ComposerState { media: MediaMessageEventContent | null location: ComposerLocationValue | null replyTo: EventID | null + silentReply: boolean + explicitReplyInThread: boolean uninited?: boolean } const MAX_TEXTAREA_ROWS = 10 -const emptyComposer: ComposerState = { text: "", media: null, replyTo: null, location: null } +const emptyComposer: ComposerState = { + text: "", + media: null, + replyTo: null, + location: null, + silentReply: false, + explicitReplyInThread: false, +} const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true } -const composerReducer = (state: ComposerState, action: Partial) => - ({ ...state, ...action, uninited: undefined }) +const composerReducer = ( + state: ComposerState, + action: Partial | ((current: ComposerState) => Partial), +) => ({ + ...state, + ...(typeof action === "function" ? action(state) : action), + uninited: undefined, +}) const draftStore = { get: (roomID: RoomID): ComposerState | null => { @@ -108,9 +123,25 @@ const MessageComposer = () => { document.execCommand("insertText", false, text) }, []) roomCtx.setReplyTo = useCallback((evt: EventID | null) => { - setState({ replyTo: evt }) + setState({ replyTo: evt, silentReply: false, explicitReplyInThread: false }) textInput.current?.focus() }, []) + const setSilentReply = useCallback((newVal: boolean | React.MouseEvent) => { + if (typeof newVal === "boolean") { + setState({ silentReply: newVal }) + } else { + newVal.stopPropagation() + setState(state => ({ silentReply: !state.silentReply })) + } + }, []) + const setExplicitReplyInThread = useCallback((newVal: boolean | React.MouseEvent) => { + if (typeof newVal === "boolean") { + setState({ explicitReplyInThread: newVal }) + } else { + newVal.stopPropagation() + setState(state => ({ explicitReplyInThread: !state.explicitReplyInThread })) + } + }, []) roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => { if (evt === null) { rawSetEditing(null) @@ -128,6 +159,8 @@ const MessageComposer = () => { ? (evt.local_content?.edit_source ?? evtContent.body ?? "") : "", replyTo: null, + silentReply: false, + explicitReplyInThread: false, }) textInput.current?.focus() }, [room.roomID]) @@ -154,18 +187,20 @@ const MessageComposer = () => { event_id: editing.event_id, } } else if (replyToEvt) { - mentions.user_ids.push(replyToEvt.sender) + const isThread = replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread" + && typeof replyToEvt.content?.["m.relates_to"]?.event_id === "string" + if (!state.silentReply && (!isThread || state.explicitReplyInThread)) { + mentions.user_ids.push(replyToEvt.sender) + } relates_to = { "m.in_reply_to": { event_id: replyToEvt.event_id, }, } - if (replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread" - && typeof replyToEvt.content?.["m.relates_to"]?.event_id === "string") { + if (isThread) { relates_to.rel_type = "m.thread" relates_to.event_id = replyToEvt.content?.["m.relates_to"].event_id - // TODO set this to true if replying to the last event in a thread? - relates_to.is_falling_back = false + relates_to.is_falling_back = !state.explicitReplyInThread } } let base_content: MessageEventContent | undefined @@ -465,6 +500,10 @@ const MessageComposer = () => { event={replyToEvt} onClose={closeReply} isThread={replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread"} + isSilent={state.silentReply} + onSetSilent={setSilentReply} + isExplicitInThread={state.explicitReplyInThread} + onSetExplicitInThread={setExplicitReplyInThread} />} {editing && button.close-reply { - display: flex; + > div.buttons { margin-left: auto; - align-items: center; - border-radius: .25rem; - padding: 0; + display: flex; + gap: .25rem; - > svg { - height: 24px; - width: 24px; + > button { + display: flex; + align-items: center; + border-radius: .25rem; + padding: 0; + + > svg { + height: 24px; + width: 24px; + } } } } diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index 3a142b5..fadaa54 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -19,8 +19,13 @@ import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore" import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types" import { getDisplayname } from "@/util/validation.ts" import ClientContext from "../ClientContext.ts" +import TooltipButton from "../util/TooltipButton.tsx" import { ContentErrorBoundary, getBodyType } from "./content" import CloseIcon from "@/icons/close.svg?react" +import NotificationsOffIcon from "@/icons/notifications-off.svg?react" +import NotificationsIcon from "@/icons/notifications.svg?react" +import ReplyIcon from "@/icons/reply.svg?react" +import ThreadIcon from "@/icons/thread.svg?react" import "./ReplyBody.css" interface ReplyBodyProps { @@ -29,6 +34,10 @@ interface ReplyBodyProps { isThread: boolean isEditing?: boolean onClose?: (evt: React.MouseEvent) => void + isSilent?: boolean + onSetSilent?: (evt: React.MouseEvent) => void + isExplicitInThread?: boolean + onSetExplicitInThread?: (evt: React.MouseEvent) => void } interface ReplyIDBodyProps { @@ -68,7 +77,9 @@ const onClickReply = (evt: React.MouseEvent) => { } } -export const ReplyBody = ({ room, event, onClose, isThread, isEditing }: ReplyBodyProps) => { +export const ReplyBody = ({ + room, event, onClose, isThread, isEditing, isSilent, onSetSilent, isExplicitInThread, onSetExplicitInThread, +}: ReplyBodyProps) => { const memberEvt = useRoomState(room, "m.room.member", event.sender) if (!memberEvt) { use(ClientContext)?.requestMemberEvent(room, event.sender) @@ -100,7 +111,29 @@ export const ReplyBody = ({ room, event, onClose, isThread, isEditing }: ReplyBo {getDisplayname(event.sender, memberEvtContent)} - {onClose && } + {onClose &&
+ {onSetSilent && (isExplicitInThread || !isThread) && + {isSilent ? : } + } + {isThread && onSetExplicitInThread && + {isExplicitInThread ? : } + } + {onClose && } +
} diff --git a/web/src/ui/util/TooltipButton.css b/web/src/ui/util/TooltipButton.css new file mode 100644 index 0000000..e539546 --- /dev/null +++ b/web/src/ui/util/TooltipButton.css @@ -0,0 +1,36 @@ +button.with-tooltip { + position: relative; + + div.button-tooltip { + display: none; + position: absolute; + z-index: 5; + background-color: white; + border: 1px solid var(--border-color); + padding: .25rem; + border-radius: .25rem; + text-wrap: wrap; + width: max-content; + + &.button-tooltip-top { + bottom: 100%; + margin-bottom: .25rem; + } + &.button-tooltip-bottom { + top: 100%; + margin-top: .25rem; + } + &.button-tooltip-left { + right: 100%; + margin-right: .25rem; + } + &.button-tooltip-right { + left: 100%; + margin-left: .25rem; + } + } + + &:hover > div.button-tooltip { + display: block; + } +} diff --git a/web/src/ui/util/TooltipButton.tsx b/web/src/ui/util/TooltipButton.tsx new file mode 100644 index 0000000..99527f6 --- /dev/null +++ b/web/src/ui/util/TooltipButton.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 { HTMLAttributes, ReactNode } from "react" +import "./TooltipButton.css" + +export interface TooltipButtonProps extends HTMLAttributes { + tooltipDirection?: "top" | "bottom" | "left" | "right" + tooltipText: string + tooltipProps?: HTMLAttributes + children: ReactNode +} + +const TooltipButton = ({ + tooltipDirection, tooltipText, children, className, tooltipProps, ...attrs +}: TooltipButtonProps) => { + if (!tooltipDirection) { + tooltipDirection = "top" + } + className = className ? `with-tooltip ${className}` : "with-tooltip" + const tooltipClassName = `button-tooltip button-tooltip-${tooltipDirection} ${tooltipProps?.className ?? ""}` + return +} + +export default TooltipButton