mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/composer: allow customizing reply notification and thread fallback behavior
This commit is contained in:
parent
b664e45a97
commit
35eb50cf8a
8 changed files with 177 additions and 19 deletions
1
web/src/icons/notifications-off.svg
Normal file
1
web/src/icons/notifications-off.svg
Normal 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 |
1
web/src/icons/notifications.svg
Normal file
1
web/src/icons/notifications.svg
Normal 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
1
web/src/icons/thread.svg
Normal 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 |
|
@ -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<ComposerState>) =>
|
||||
({ ...state, ...action, uninited: undefined })
|
||||
const composerReducer = (
|
||||
state: ComposerState,
|
||||
action: Partial<ComposerState> | ((current: ComposerState) => Partial<ComposerState>),
|
||||
) => ({
|
||||
...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 && <ReplyBody
|
||||
room={room}
|
||||
|
|
|
@ -62,16 +62,21 @@ blockquote.reply-body {
|
|||
margin-right: .25rem;
|
||||
}
|
||||
|
||||
> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|||
<span className={`event-sender sender-color-${userColorIndex}`}>
|
||||
{getDisplayname(event.sender, memberEvtContent)}
|
||||
</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>
|
||||
<ContentErrorBoundary>
|
||||
<BodyType room={room} event={event} sender={memberEvt}/>
|
||||
|
|
36
web/src/ui/util/TooltipButton.css
Normal file
36
web/src/ui/util/TooltipButton.css
Normal 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;
|
||||
}
|
||||
}
|
42
web/src/ui/util/TooltipButton.tsx
Normal file
42
web/src/ui/util/TooltipButton.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 { 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
|
Loading…
Add table
Reference in a new issue