web/composer: allow customizing reply notification and thread fallback behavior

This commit is contained in:
Tulir Asokan 2024-12-11 02:19:43 +02:00
parent b664e45a97
commit 35eb50cf8a
8 changed files with 177 additions and 19 deletions

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M160-200v-80h80v-280q0-33 8.5-65t25.5-61l60 60q-7 16-10.5 32.5T320-560v280h248L56-792l56-56 736 736-56 56-146-144H160Zm560-154-80-80v-126q0-66-47-113t-113-47q-26 0-50 8t-44 24l-58-58q20-16 43-28t49-18v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v206Zm-276-50Zm36 324q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80Zm33-481Z"/></svg>

After

Width:  |  Height:  |  Size: 482 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M160-200v-80h80v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h80v80H160Zm320-300Zm0 420q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM320-280h320v-280q0-66-47-113t-113-47q-66 0-113 47t-47 113v280Z"/></svg>

After

Width:  |  Height:  |  Size: 388 B

1
web/src/icons/thread.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M554-120q-54 0-91-37t-37-89q0-76 61.5-137.5T641-460q-3-36-18-54.5T582-533q-30 0-65 25t-83 82q-78 93-114.5 121T241-277q-51 0-86-38t-35-92q0-54 23.5-110.5T223-653q19-26 28-44t9-29q0-7-2.5-10.5T250-740q-10 0-25 12.5T190-689l-70-71q32-39 65-59.5t65-20.5q46 0 78 32t32 80q0 29-15 64t-50 84q-38 54-56.5 95T220-413q0 17 5.5 26.5T241-377q10 0 17.5-5.5T286-409q13-14 31-34.5t44-50.5q63-75 114-107t107-32q67 0 110 45t49 123h99v100h-99q-8 112-58.5 178.5T554-120Zm2-100q32 0 54-36.5T640-358q-46 11-80 43.5T526-250q0 14 8 22t22 8Z"/></svg>

After

Width:  |  Height:  |  Size: 643 B

View file

@ -60,15 +60,30 @@ export interface ComposerState {
media: MediaMessageEventContent | null media: MediaMessageEventContent | null
location: ComposerLocationValue | null location: ComposerLocationValue | null
replyTo: EventID | null replyTo: EventID | null
silentReply: boolean
explicitReplyInThread: boolean
uninited?: boolean uninited?: boolean
} }
const MAX_TEXTAREA_ROWS = 10 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 uninitedComposer: ComposerState = { ...emptyComposer, uninited: true }
const composerReducer = (state: ComposerState, action: Partial<ComposerState>) => const composerReducer = (
({ ...state, ...action, uninited: undefined }) state: ComposerState,
action: Partial<ComposerState> | ((current: ComposerState) => Partial<ComposerState>),
) => ({
...state,
...(typeof action === "function" ? action(state) : action),
uninited: undefined,
})
const draftStore = { const draftStore = {
get: (roomID: RoomID): ComposerState | null => { get: (roomID: RoomID): ComposerState | null => {
@ -108,9 +123,25 @@ const MessageComposer = () => {
document.execCommand("insertText", false, text) document.execCommand("insertText", false, text)
}, []) }, [])
roomCtx.setReplyTo = useCallback((evt: EventID | null) => { roomCtx.setReplyTo = useCallback((evt: EventID | null) => {
setState({ replyTo: evt }) setState({ replyTo: evt, silentReply: false, explicitReplyInThread: false })
textInput.current?.focus() 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) => { roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => {
if (evt === null) { if (evt === null) {
rawSetEditing(null) rawSetEditing(null)
@ -128,6 +159,8 @@ const MessageComposer = () => {
? (evt.local_content?.edit_source ?? evtContent.body ?? "") ? (evt.local_content?.edit_source ?? evtContent.body ?? "")
: "", : "",
replyTo: null, replyTo: null,
silentReply: false,
explicitReplyInThread: false,
}) })
textInput.current?.focus() textInput.current?.focus()
}, [room.roomID]) }, [room.roomID])
@ -154,18 +187,20 @@ const MessageComposer = () => {
event_id: editing.event_id, event_id: editing.event_id,
} }
} else if (replyToEvt) { } 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 = { relates_to = {
"m.in_reply_to": { "m.in_reply_to": {
event_id: replyToEvt.event_id, event_id: replyToEvt.event_id,
}, },
} }
if (replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread" if (isThread) {
&& typeof replyToEvt.content?.["m.relates_to"]?.event_id === "string") {
relates_to.rel_type = "m.thread" relates_to.rel_type = "m.thread"
relates_to.event_id = replyToEvt.content?.["m.relates_to"].event_id 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 = !state.explicitReplyInThread
relates_to.is_falling_back = false
} }
} }
let base_content: MessageEventContent | undefined let base_content: MessageEventContent | undefined
@ -465,6 +500,10 @@ const MessageComposer = () => {
event={replyToEvt} event={replyToEvt}
onClose={closeReply} onClose={closeReply}
isThread={replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread"} isThread={replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread"}
isSilent={state.silentReply}
onSetSilent={setSilentReply}
isExplicitInThread={state.explicitReplyInThread}
onSetExplicitInThread={setExplicitReplyInThread}
/>} />}
{editing && <ReplyBody {editing && <ReplyBody
room={room} room={room}

View file

@ -62,16 +62,21 @@ blockquote.reply-body {
margin-right: .25rem; margin-right: .25rem;
} }
> button.close-reply { > div.buttons {
display: flex;
margin-left: auto; margin-left: auto;
align-items: center; display: flex;
border-radius: .25rem; gap: .25rem;
padding: 0;
> svg { > button {
height: 24px; display: flex;
width: 24px; align-items: center;
border-radius: .25rem;
padding: 0;
> svg {
height: 24px;
width: 24px;
}
} }
} }
} }

View file

@ -19,8 +19,13 @@ import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore"
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types" import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts" import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import TooltipButton from "../util/TooltipButton.tsx"
import { ContentErrorBoundary, getBodyType } from "./content" import { ContentErrorBoundary, getBodyType } from "./content"
import CloseIcon from "@/icons/close.svg?react" 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" import "./ReplyBody.css"
interface ReplyBodyProps { interface ReplyBodyProps {
@ -29,6 +34,10 @@ interface ReplyBodyProps {
isThread: boolean isThread: boolean
isEditing?: boolean isEditing?: boolean
onClose?: (evt: React.MouseEvent) => void onClose?: (evt: React.MouseEvent) => void
isSilent?: boolean
onSetSilent?: (evt: React.MouseEvent) => void
isExplicitInThread?: boolean
onSetExplicitInThread?: (evt: React.MouseEvent) => void
} }
interface ReplyIDBodyProps { 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) const memberEvt = useRoomState(room, "m.room.member", event.sender)
if (!memberEvt) { if (!memberEvt) {
use(ClientContext)?.requestMemberEvent(room, event.sender) use(ClientContext)?.requestMemberEvent(room, event.sender)
@ -100,7 +111,29 @@ export const ReplyBody = ({ room, event, onClose, isThread, isEditing }: ReplyBo
<span className={`event-sender sender-color-${userColorIndex}`}> <span className={`event-sender sender-color-${userColorIndex}`}>
{getDisplayname(event.sender, memberEvtContent)} {getDisplayname(event.sender, memberEvtContent)}
</span> </span>
{onClose && <button className="close-reply" onClick={onClose}><CloseIcon/></button>} {onClose && <div className="buttons">
{onSetSilent && (isExplicitInThread || !isThread) && <TooltipButton
tooltipText={isSilent
? "Click to enable pinging the original author"
: "Click to disable pinging the original author"}
tooltipDirection="left"
className="silent-reply"
onClick={onSetSilent}
>
{isSilent ? <NotificationsOffIcon /> : <NotificationsIcon />}
</TooltipButton>}
{isThread && onSetExplicitInThread && <TooltipButton
tooltipText={isExplicitInThread
? "Click to respond in thread without replying to a specific message"
: "Click to reply explicitly in thread"}
tooltipDirection="left"
className="thread-explicit-reply"
onClick={onSetExplicitInThread}
>
{isExplicitInThread ? <ReplyIcon /> : <ThreadIcon />}
</TooltipButton>}
{onClose && <button className="close-reply" onClick={onClose}><CloseIcon/></button>}
</div>}
</div> </div>
<ContentErrorBoundary> <ContentErrorBoundary>
<BodyType room={room} event={event} sender={memberEvt}/> <BodyType room={room} event={event} sender={memberEvt}/>

View file

@ -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;
}
}

View 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 { HTMLAttributes, ReactNode } from "react"
import "./TooltipButton.css"
export interface TooltipButtonProps extends HTMLAttributes<HTMLButtonElement> {
tooltipDirection?: "top" | "bottom" | "left" | "right"
tooltipText: string
tooltipProps?: HTMLAttributes<HTMLDivElement>
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 <button {...attrs} className={className}>
{children}
<div {...(tooltipProps ?? {})} className={tooltipClassName}>
{tooltipText}
</div>
</button>
}
export default TooltipButton