From fa4d4144ba882c4d2a5bba35673fc4609e949d13 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 22 Dec 2024 01:33:40 +0200 Subject: [PATCH] web/timeline: add option for compact replies Closes #549 --- web/src/api/types/preferences/preferences.ts | 6 +++ web/src/index.css | 1 + web/src/ui/timeline/ReplyBody.css | 53 ++++++++++++++++---- web/src/ui/timeline/ReplyBody.tsx | 12 +++-- web/src/ui/timeline/TimelineEvent.css | 11 +++- web/src/ui/timeline/TimelineEvent.tsx | 46 +++++++++++------ web/src/ui/timeline/TimelineView.tsx | 5 +- 7 files changed, 103 insertions(+), 31 deletions(-) diff --git a/web/src/api/types/preferences/preferences.ts b/web/src/api/types/preferences/preferences.ts index 4d55ac3..a535817 100644 --- a/web/src/api/types/preferences/preferences.ts +++ b/web/src/api/types/preferences/preferences.ts @@ -108,6 +108,12 @@ export const preferences = { allowedContexts: anyContext, defaultValue: true, }), + small_replies: new Preference({ + displayName: "Compact reply style", + description: "Whether to use a Discord-like compact style for replies instead of the traditional style.", + allowedContexts: anyContext, + defaultValue: false, + }), show_date_separators: new Preference({ displayName: "Show date separators", description: "Whether messages in different days should have a date separator between them in the room timeline.", diff --git a/web/src/index.css b/web/src/index.css index 2c3a0cc..a79ca3f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -11,6 +11,7 @@ --semisecondary-text-color: #555; --link-text-color: #0467dd; --visited-link-text-color: var(--link-text-color); + --small-font-size: .875rem; --code-background-color: rgba(0, 0, 0, 0.15); --media-placeholder-default-background: rgba(0, 0, 0, .1); diff --git a/web/src/ui/timeline/ReplyBody.css b/web/src/ui/timeline/ReplyBody.css index 1efc20b..1895960 100644 --- a/web/src/ui/timeline/ReplyBody.css +++ b/web/src/ui/timeline/ReplyBody.css @@ -1,18 +1,43 @@ blockquote.reply-body { margin: 0 0 .25rem; - border-left: 2px solid var(--blockquote-border-color); + border-left: 2px solid var(--reply-border-color); padding: .25rem .5rem; - &.sender-color-0 { border-color: var(--sender-color-0); } - &.sender-color-1 { border-color: var(--sender-color-1); } - &.sender-color-2 { border-color: var(--sender-color-2); } - &.sender-color-3 { border-color: var(--sender-color-3); } - &.sender-color-4 { border-color: var(--sender-color-4); } - &.sender-color-5 { border-color: var(--sender-color-5); } - &.sender-color-6 { border-color: var(--sender-color-6); } - &.sender-color-7 { border-color: var(--sender-color-7); } - &.sender-color-8 { border-color: var(--sender-color-8); } - &.sender-color-9 { border-color: var(--sender-color-9); } + &.sender-color-0 { --reply-border-color: var(--sender-color-0); } + &.sender-color-1 { --reply-border-color: var(--sender-color-1); } + &.sender-color-2 { --reply-border-color: var(--sender-color-2); } + &.sender-color-3 { --reply-border-color: var(--sender-color-3); } + &.sender-color-4 { --reply-border-color: var(--sender-color-4); } + &.sender-color-5 { --reply-border-color: var(--sender-color-5); } + &.sender-color-6 { --reply-border-color: var(--sender-color-6); } + &.sender-color-7 { --reply-border-color: var(--sender-color-7); } + &.sender-color-8 { --reply-border-color: var(--sender-color-8); } + &.sender-color-9 { --reply-border-color: var(--sender-color-9); } + + &.small { + grid-area: reply; + display: flex; + gap: .25rem; + font-size: var(--small-font-size); + height: calc(var(--small-font-size) * 1.5); + border-left: none; + padding-left: 0; + padding-bottom: 0; + + > div.reply-spine { + margin-top: calc(var(--small-font-size) * 0.75 - 1px); + margin-left: calc(var(--timeline-avatar-size) / 2 - 1px); + width: calc(var(--timeline-avatar-size)/2 + var(--timeline-avatar-gap)); + border-left: 2px solid var(--reply-border-color); + border-top: 2px solid var(--reply-border-color); + border-top-left-radius: .5rem; + flex-shrink: 0; + } + + > div.message-text { + -webkit-line-clamp: 1; + } + } pre { display: inline; @@ -38,6 +63,7 @@ blockquote.reply-body { -webkit-box-orient: vertical; overflow: hidden; color: var(--semisecondary-text-color); + user-select: none; } &.thread > div.reply-sender > span.event-sender::after { @@ -60,6 +86,11 @@ blockquote.reply-body { width: 1rem; height: 1rem; margin-right: .25rem; + + > img { + width: 100%; + height: 100%; + } } > div.buttons { diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index ae179b9..bf7dc36 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -32,6 +32,7 @@ interface ReplyBodyProps { room: RoomStateStore event: MemDBEvent isThread: boolean + small?: boolean isEditing?: boolean onClose?: (evt: React.MouseEvent) => void isSilent?: boolean @@ -44,9 +45,10 @@ interface ReplyIDBodyProps { room: RoomStateStore eventID: EventID isThread: boolean + small: boolean } -export const ReplyIDBody = ({ room, eventID, isThread }: ReplyIDBodyProps) => { +export const ReplyIDBody = ({ room, eventID, isThread, small }: ReplyIDBodyProps) => { const event = useRoomEvent(room, eventID) if (!event) { // This caches whether the event is requested or not, so it doesn't need to be wrapped in an effect. @@ -55,7 +57,7 @@ export const ReplyIDBody = ({ room, eventID, isThread }: ReplyIDBodyProps) => { Reply to unknown event
{eventID} } - return + return } const onClickReply = (evt: React.MouseEvent) => { @@ -78,7 +80,7 @@ const onClickReply = (evt: React.MouseEvent) => { } export const ReplyBody = ({ - room, event, onClose, isThread, isEditing, isSilent, onSetSilent, isExplicitInThread, onSetExplicitInThread, + room, event, onClose, isThread, isEditing, isSilent, onSetSilent, isExplicitInThread, onSetExplicitInThread, small, }: ReplyBodyProps) => { const client = use(ClientContext) const memberEvt = useRoomMember(client, room, event.sender) @@ -94,9 +96,13 @@ export const ReplyBody = ({ if (isEditing) { classNames.push("editing") } + if (small) { + classNames.push("small") + } const userColorIndex = getUserColorIndex(event.sender) classNames.push(`sender-color-${userColorIndex}`) return
+ {small &&
}
div.timeline-event { diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 18a63e3..e01dbb8 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.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, { use, useCallback, useState } from "react" +import React, { JSX, use, useCallback, useState } from "react" import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts" import { useRoomMember } from "@/api/statestore" import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types" @@ -37,6 +37,7 @@ export interface TimelineEventProps { evt: MemDBEvent prevEvt: MemDBEvent | null disableMenu?: boolean + smallReplies?: boolean } const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" }) @@ -72,7 +73,7 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => { } } -const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => { +const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEventProps) => { const roomCtx = useRoomContext() const client = use(ClientContext)! const mainScreen = use(MainScreenContext) @@ -130,17 +131,39 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
} + const isSmallBodyType = isSmallEvent(BodyType) + const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"] + const replyTo = relatesTo?.["m.in_reply_to"]?.event_id + let replyAboveMessage: JSX.Element | null = null + let replyInMessage: JSX.Element | null = null + if (isEventID(replyTo) && BodyType !== HiddenEvent && !evt.redacted_by) { + const replyElem = + if (smallReplies && !isSmallBodyType) { + replyAboveMessage = replyElem + wrapperClassNames.push("reply-above") + } else { + replyInMessage = replyElem + } + } let smallAvatar = false let renderAvatar = true let eventTimeOnly = false - if (isSmallEvent(BodyType)) { + if (isSmallBodyType) { wrapperClassNames.push("small-event") smallAvatar = true eventTimeOnly = true - } else if (prevEvt?.sender === evt.sender && - prevEvt.timestamp + 15 * 60 * 1000 > evt.timestamp && - !isSmallEvent(getBodyType(prevEvt)) && - dateSeparator === null) { + } else if ( + prevEvt?.sender === evt.sender + && prevEvt.timestamp + 15 * 60 * 1000 > evt.timestamp + && dateSeparator === null + && !replyAboveMessage + && !isSmallEvent(getBodyType(prevEvt)) + ) { wrapperClassNames.push("same-sender") eventTimeOnly = true renderAvatar = false @@ -148,8 +171,6 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => { const fullTime = fullTimeFormatter.format(eventTS) const shortTime = formatShortTime(eventTS) const editTime = editEventTS ? `Edited at ${fullTimeFormatter.format(editEventTS)}` : null - const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"] - const replyTo = relatesTo?.["m.in_reply_to"]?.event_id const mainEvent =
{ >
} + {replyAboveMessage} {renderAvatar &&
{ {shortTime}
}
- {isEventID(replyTo) && BodyType !== HiddenEvent && !evt.redacted_by ? : null} + {replyInMessage} diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 7a8c987..b2c01ec 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -15,7 +15,7 @@ // along with this program. If not, see . import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" import { ScaleLoader } from "react-spinners" -import { useRoomTimeline } from "@/api/statestore" +import { usePreference, useRoomTimeline } from "@/api/statestore" import { MemDBEvent } from "@/api/types" import useFocus from "@/util/focus.ts" import ClientContext from "../ClientContext.ts" @@ -42,6 +42,7 @@ const TimelineView = () => { const oldestTimelineRow = timeline[0]?.timeline_rowid const oldScrollHeight = useRef(0) const focused = useFocus() + const smallReplies = usePreference(client.store, room, "small_replies") // When the user scrolls the timeline manually, remember if they were at the bottom, // so that we can keep them at the bottom when new events are added. @@ -131,7 +132,7 @@ const TimelineView = () => { return null } const thisEvt = prevEvt = entry return thisEvt