forked from Mirrors/gomuks
parent
bb26bc4e64
commit
fa4d4144ba
7 changed files with 103 additions and 31 deletions
|
@ -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.",
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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}/>
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Reference in a new issue