1
0
Fork 0
forked from Mirrors/gomuks

web/timeline: add option for compact replies

Closes #549
This commit is contained in:
Tulir Asokan 2024-12-22 01:33:40 +02:00
parent bb26bc4e64
commit fa4d4144ba
7 changed files with 103 additions and 31 deletions

View file

@ -108,6 +108,12 @@ export const preferences = {
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: true, defaultValue: true,
}), }),
small_replies: new Preference<boolean>({
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<boolean>({ show_date_separators: new Preference<boolean>({
displayName: "Show date separators", displayName: "Show date separators",
description: "Whether messages in different days should have a date separator between them in the room timeline.", description: "Whether messages in different days should have a date separator between them in the room timeline.",

View file

@ -11,6 +11,7 @@
--semisecondary-text-color: #555; --semisecondary-text-color: #555;
--link-text-color: #0467dd; --link-text-color: #0467dd;
--visited-link-text-color: var(--link-text-color); --visited-link-text-color: var(--link-text-color);
--small-font-size: .875rem;
--code-background-color: rgba(0, 0, 0, 0.15); --code-background-color: rgba(0, 0, 0, 0.15);
--media-placeholder-default-background: rgba(0, 0, 0, .1); --media-placeholder-default-background: rgba(0, 0, 0, .1);

View file

@ -1,18 +1,43 @@
blockquote.reply-body { blockquote.reply-body {
margin: 0 0 .25rem; margin: 0 0 .25rem;
border-left: 2px solid var(--blockquote-border-color); border-left: 2px solid var(--reply-border-color);
padding: .25rem .5rem; padding: .25rem .5rem;
&.sender-color-0 { border-color: var(--sender-color-0); } &.sender-color-0 { --reply-border-color: var(--sender-color-0); }
&.sender-color-1 { border-color: var(--sender-color-1); } &.sender-color-1 { --reply-border-color: var(--sender-color-1); }
&.sender-color-2 { border-color: var(--sender-color-2); } &.sender-color-2 { --reply-border-color: var(--sender-color-2); }
&.sender-color-3 { border-color: var(--sender-color-3); } &.sender-color-3 { --reply-border-color: var(--sender-color-3); }
&.sender-color-4 { border-color: var(--sender-color-4); } &.sender-color-4 { --reply-border-color: var(--sender-color-4); }
&.sender-color-5 { border-color: var(--sender-color-5); } &.sender-color-5 { --reply-border-color: var(--sender-color-5); }
&.sender-color-6 { border-color: var(--sender-color-6); } &.sender-color-6 { --reply-border-color: var(--sender-color-6); }
&.sender-color-7 { border-color: var(--sender-color-7); } &.sender-color-7 { --reply-border-color: var(--sender-color-7); }
&.sender-color-8 { border-color: var(--sender-color-8); } &.sender-color-8 { --reply-border-color: var(--sender-color-8); }
&.sender-color-9 { border-color: var(--sender-color-9); } &.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 { pre {
display: inline; display: inline;
@ -38,6 +63,7 @@ blockquote.reply-body {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
color: var(--semisecondary-text-color); color: var(--semisecondary-text-color);
user-select: none;
} }
&.thread > div.reply-sender > span.event-sender::after { &.thread > div.reply-sender > span.event-sender::after {
@ -60,6 +86,11 @@ blockquote.reply-body {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
margin-right: .25rem; margin-right: .25rem;
> img {
width: 100%;
height: 100%;
}
} }
> div.buttons { > div.buttons {

View file

@ -32,6 +32,7 @@ interface ReplyBodyProps {
room: RoomStateStore room: RoomStateStore
event: MemDBEvent event: MemDBEvent
isThread: boolean isThread: boolean
small?: boolean
isEditing?: boolean isEditing?: boolean
onClose?: (evt: React.MouseEvent) => void onClose?: (evt: React.MouseEvent) => void
isSilent?: boolean isSilent?: boolean
@ -44,9 +45,10 @@ interface ReplyIDBodyProps {
room: RoomStateStore room: RoomStateStore
eventID: EventID eventID: EventID
isThread: boolean isThread: boolean
small: boolean
} }
export const ReplyIDBody = ({ room, eventID, isThread }: ReplyIDBodyProps) => { export const ReplyIDBody = ({ room, eventID, isThread, small }: ReplyIDBodyProps) => {
const event = useRoomEvent(room, eventID) const event = useRoomEvent(room, eventID)
if (!event) { if (!event) {
// This caches whether the event is requested or not, so it doesn't need to be wrapped in an effect. // 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<br/><code>{eventID}</code> Reply to unknown event<br/><code>{eventID}</code>
</blockquote> </blockquote>
} }
return <ReplyBody room={room} event={event} isThread={isThread}/> return <ReplyBody room={room} event={event} isThread={isThread} small={small}/>
} }
const onClickReply = (evt: React.MouseEvent) => { const onClickReply = (evt: React.MouseEvent) => {
@ -78,7 +80,7 @@ const onClickReply = (evt: React.MouseEvent) => {
} }
export const ReplyBody = ({ export const ReplyBody = ({
room, event, onClose, isThread, isEditing, isSilent, onSetSilent, isExplicitInThread, onSetExplicitInThread, room, event, onClose, isThread, isEditing, isSilent, onSetSilent, isExplicitInThread, onSetExplicitInThread, small,
}: ReplyBodyProps) => { }: ReplyBodyProps) => {
const client = use(ClientContext) const client = use(ClientContext)
const memberEvt = useRoomMember(client, room, event.sender) const memberEvt = useRoomMember(client, room, event.sender)
@ -94,9 +96,13 @@ export const ReplyBody = ({
if (isEditing) { if (isEditing) {
classNames.push("editing") classNames.push("editing")
} }
if (small) {
classNames.push("small")
}
const userColorIndex = getUserColorIndex(event.sender) const userColorIndex = getUserColorIndex(event.sender)
classNames.push(`sender-color-${userColorIndex}`) classNames.push(`sender-color-${userColorIndex}`)
return <blockquote data-reply-to={event.event_id} className={classNames.join(" ")} onClick={onClickReply}> return <blockquote data-reply-to={event.event_id} className={classNames.join(" ")} onClick={onClickReply}>
{small && <div className="reply-spine"/>}
<div className="reply-sender"> <div className="reply-sender">
<div className="sender-avatar" title={event.sender}> <div className="sender-avatar" title={event.sender}>
<img <img

View file

@ -5,7 +5,7 @@ div.timeline-event {
padding: 0 var(--timeline-horizontal-padding); padding: 0 var(--timeline-horizontal-padding);
display: grid; display: grid;
grid-template: grid-template:
"cmc cmc cmc empty" 0 "cmc cmc cmc empty" 0
"avatar gap sender sender" auto "avatar gap sender sender" auto
"avatar gap content status" auto "avatar gap content status" auto
/ var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size); / var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size);
@ -157,6 +157,15 @@ div.timeline-event {
margin-top: var(--timeline-message-gap-small-event); margin-top: var(--timeline-message-gap-small-event);
} }
} }
&.reply-above {
grid-template:
"cmc cmc cmc empty" 0
"reply reply reply empty" auto
"avatar gap sender sender" auto
"avatar gap content status" auto
/ var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size);
}
} }
div.pinned-event > div.timeline-event { div.pinned-event > div.timeline-event {

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback, useState } from "react" import React, { JSX, use, useCallback, useState } from "react"
import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts" import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
import { useRoomMember } from "@/api/statestore" import { useRoomMember } from "@/api/statestore"
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types" import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
@ -37,6 +37,7 @@ export interface TimelineEventProps {
evt: MemDBEvent evt: MemDBEvent
prevEvt: MemDBEvent | null prevEvt: MemDBEvent | null
disableMenu?: boolean disableMenu?: boolean
smallReplies?: boolean
} }
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" }) 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 roomCtx = useRoomContext()
const client = use(ClientContext)! const client = use(ClientContext)!
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
@ -130,17 +131,39 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
<hr role="none"/> <hr role="none"/>
</div> </div>
} }
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 = <ReplyIDBody
room={roomCtx.store}
eventID={replyTo}
isThread={relatesTo?.rel_type === "m.thread"}
small={!!smallReplies}
/>
if (smallReplies && !isSmallBodyType) {
replyAboveMessage = replyElem
wrapperClassNames.push("reply-above")
} else {
replyInMessage = replyElem
}
}
let smallAvatar = false let smallAvatar = false
let renderAvatar = true let renderAvatar = true
let eventTimeOnly = false let eventTimeOnly = false
if (isSmallEvent(BodyType)) { if (isSmallBodyType) {
wrapperClassNames.push("small-event") wrapperClassNames.push("small-event")
smallAvatar = true smallAvatar = true
eventTimeOnly = true eventTimeOnly = true
} else if (prevEvt?.sender === evt.sender && } else if (
prevEvt.timestamp + 15 * 60 * 1000 > evt.timestamp && prevEvt?.sender === evt.sender
!isSmallEvent(getBodyType(prevEvt)) && && prevEvt.timestamp + 15 * 60 * 1000 > evt.timestamp
dateSeparator === null) { && dateSeparator === null
&& !replyAboveMessage
&& !isSmallEvent(getBodyType(prevEvt))
) {
wrapperClassNames.push("same-sender") wrapperClassNames.push("same-sender")
eventTimeOnly = true eventTimeOnly = true
renderAvatar = false renderAvatar = false
@ -148,8 +171,6 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
const fullTime = fullTimeFormatter.format(eventTS) const fullTime = fullTimeFormatter.format(eventTS)
const shortTime = formatShortTime(eventTS) const shortTime = formatShortTime(eventTS)
const editTime = editEventTS ? `Edited at ${fullTimeFormatter.format(editEventTS)}` : null 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 = <div const mainEvent = <div
data-event-id={evt.event_id} data-event-id={evt.event_id}
className={wrapperClassNames.join(" ")} className={wrapperClassNames.join(" ")}
@ -160,6 +181,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
> >
<EventHoverMenu evt={evt} setForceOpen={setForceContextMenuOpen}/> <EventHoverMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
</div>} </div>}
{replyAboveMessage}
{renderAvatar && <div {renderAvatar && <div
className="sender-avatar" className="sender-avatar"
title={evt.sender} title={evt.sender}
@ -190,11 +212,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span> <span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span>
</div>} </div>}
<div className="event-content"> <div className="event-content">
{isEventID(replyTo) && BodyType !== HiddenEvent && !evt.redacted_by ? <ReplyIDBody {replyInMessage}
room={roomCtx.store}
eventID={replyTo}
isThread={relatesTo?.rel_type === "m.thread"}
/> : null}
<ContentErrorBoundary> <ContentErrorBoundary>
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/> <BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
<URLPreviews room={roomCtx.store} event={evt}/> <URLPreviews room={roomCtx.store} event={evt}/>

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners" import { ScaleLoader } from "react-spinners"
import { useRoomTimeline } from "@/api/statestore" import { usePreference, useRoomTimeline } from "@/api/statestore"
import { MemDBEvent } from "@/api/types" import { MemDBEvent } from "@/api/types"
import useFocus from "@/util/focus.ts" import useFocus from "@/util/focus.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
@ -42,6 +42,7 @@ const TimelineView = () => {
const oldestTimelineRow = timeline[0]?.timeline_rowid const oldestTimelineRow = timeline[0]?.timeline_rowid
const oldScrollHeight = useRef(0) const oldScrollHeight = useRef(0)
const focused = useFocus() 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, // 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. // so that we can keep them at the bottom when new events are added.
@ -131,7 +132,7 @@ const TimelineView = () => {
return null return null
} }
const thisEvt = <TimelineEvent const thisEvt = <TimelineEvent
key={entry.rowid} evt={entry} prevEvt={prevEvt} key={entry.rowid} evt={entry} prevEvt={prevEvt} smallReplies={smallReplies}
/> />
prevEvt = entry prevEvt = entry
return thisEvt return thisEvt