web/timeline: use custom message focus state on mobile to match context menu state

This commit is contained in:
Tulir Asokan 2024-12-30 10:54:13 +02:00
parent c25ab057dc
commit df551fe4cb
10 changed files with 69 additions and 33 deletions

View file

@ -19,3 +19,15 @@ div.room-view {
align-items: center;
}
}
div#mobile-event-menu-container {
grid-area: header;
&:empty {
display: none;
}
&:not(:empty) + div.room-header {
display: none;
}
}

View file

@ -41,8 +41,15 @@ const RoomView = ({ room, rightPanelResizeHandle, rightPanel }: RoomViewProps) =
}
}
}, [roomContextData])
const onClick = (evt: React.MouseEvent<HTMLDivElement>) => {
if (roomContextData.focusedEventRowID) {
roomContextData.setFocusedEventRowID(null)
evt.stopPropagation()
}
}
return <RoomContext value={roomContextData}>
<div className="room-view">
<div className="room-view" onClick={onClick}>
<div id="mobile-event-menu-container"/>
<RoomViewHeader room={room}/>
<TimelineView/>
<MessageComposer/>

View file

@ -5,6 +5,7 @@ div.room-header {
padding-left: .5rem;
border-bottom: 1px solid var(--border-color);
overflow: hidden;
grid-area: header;
> div.room-name-and-topic {
flex: 1;

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { RefObject, createContext, createRef, use } from "react"
import { RoomStateStore } from "@/api/statestore"
import { EventID, MemDBEvent } from "@/api/types"
import { EventID, EventRowID, MemDBEvent } from "@/api/types"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
import { escapeMarkdown } from "@/util/markdown.ts"
@ -28,6 +28,8 @@ export class RoomContextData {
public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo")
public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing")
public insertText: (text: string) => void = noop("insertText")
public directSetFocusedEventRowID: (eventRowID: EventRowID | null) => void = noop("setFocusedEventRowID")
public focusedEventRowID: EventRowID | null = null
public readonly isEditing = new NonNullCachedEventDispatcher<boolean>(false)
public scrolledToBottom = true
@ -39,6 +41,11 @@ export class RoomContextData {
}
}
setFocusedEventRowID = (eventRowID: number | null) => {
this.directSetFocusedEventRowID(eventRowID)
this.focusedEventRowID = eventRowID
}
appendMentionToComposer = (evt: React.MouseEvent<HTMLSpanElement>) => {
const targetUser = evt.currentTarget.getAttribute("data-target-user")
if (!targetUser) {

View file

@ -24,7 +24,7 @@ div.timeline-event {
transition: background-color 1s;
}
&:hover {
&:hover:not(.no-hover), &.focused-event {
background-color: var(--timeline-hover-bg-color);
&.highlight {

View file

@ -14,6 +14,7 @@
// 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 React, { JSX, use, useState } from "react"
import { createPortal } from "react-dom"
import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
import { useRoomMember } from "@/api/statestore"
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
@ -38,6 +39,7 @@ export interface TimelineEventProps {
prevEvt: MemDBEvent | null
disableMenu?: boolean
smallReplies?: boolean
isFocused?: boolean
}
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
@ -73,7 +75,7 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
}
}
const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEventProps) => {
const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: TimelineEventProps) => {
const roomCtx = useRoomContext()
const client = use(ClientContext)!
const mainScreen = use(MainScreenContext)
@ -107,21 +109,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
return
}
mouseEvt.preventDefault()
if (window.hackyOpenEventContextMenu === evt.event_id) {
window.closeModal()
window.hackyOpenEventContextMenu = undefined
} else {
openModal({
content: <EventFixedMenu evt={evt} roomCtx={roomCtx} />,
captureInput: false,
onClose: () => {
if (window.hackyOpenEventContextMenu === evt.event_id) {
window.hackyOpenEventContextMenu = undefined
}
},
})
window.hackyOpenEventContextMenu = evt.event_id
}
mouseEvt.stopPropagation()
roomCtx.setFocusedEventRowID(roomCtx.focusedEventRowID === evt.rowid ? null : evt.rowid)
}
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
@ -144,6 +133,12 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
if (evt.sender === client.userID) {
wrapperClassNames.push("own-event")
}
if (isMobileDevice || disableMenu) {
wrapperClassNames.push("no-hover")
}
if (isFocused) {
wrapperClassNames.push("focused-event")
}
let dateSeparator = null
const prevEvtDate = prevEvt ? new Date(prevEvt.timestamp) : null
if (prevEvtDate && (
@ -207,6 +202,10 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
>
<EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/>
</div>}
{isMobileDevice && isFocused && createPortal(
<EventFixedMenu evt={evt} roomCtx={roomCtx} />,
document.getElementById("mobile-event-menu-container")!,
)}
{replyAboveMessage}
{renderAvatar && <div
className="sender-avatar"

View file

@ -16,7 +16,7 @@
import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners"
import { usePreference, useRoomTimeline } from "@/api/statestore"
import { MemDBEvent } from "@/api/types"
import { EventRowID, MemDBEvent } from "@/api/types"
import useFocus from "@/util/focus.ts"
import ClientContext from "../ClientContext.ts"
import { useRoomContext } from "../roomview/roomcontext.ts"
@ -29,6 +29,7 @@ const TimelineView = () => {
const timeline = useRoomTimeline(room)
const client = use(ClientContext)!
const [isLoadingHistory, setLoadingHistory] = useState(false)
const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null)
const loadHistory = useCallback(() => {
setLoadingHistory(true)
client.loadMoreHistory(room.roomID)
@ -68,6 +69,9 @@ const TimelineView = () => {
}
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
}, [client.userID, roomCtx, timeline])
useEffect(() => {
roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID
}, [])
useEffect(() => {
const newestEvent = timeline[timeline.length - 1]
if (
@ -126,7 +130,11 @@ const TimelineView = () => {
return null
}
const thisEvt = <TimelineEvent
key={entry.rowid} evt={entry} prevEvt={prevEvt} smallReplies={smallReplies}
key={entry.rowid}
evt={entry}
prevEvt={prevEvt}
smallReplies={smallReplies}
isFocused={focusedEventRowID === entry.rowid}
/>
prevEvt = entry
return thisEvt

View file

@ -13,7 +13,7 @@
//
// 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 { CSSProperties, JSX, use, useReducer } from "react"
import React, { CSSProperties, JSX, use, useReducer, useState } from "react"
import { Blurhash } from "react-blurhash"
import { GridLoader } from "react-spinners"
import { usePreference } from "@/api/statestore"
@ -49,7 +49,7 @@ const MediaMessageBody = ({ event, room, sender }: EventContentProps) => {
const supportsClickToShow = supportsLoadingPlaceholder || content.msgtype === "m.video"
const showPreviewsByDefault = usePreference(client.store, room, "show_media_previews")
const [loaded, onLoad] = useReducer(switchToTrue, !supportsLoadingPlaceholder)
const [clickedShow, onClickShow] = useReducer(switchToTrue, false)
const [clickedShow, setClickedShow] = useState(false)
let contentWarning = content["town.robin.msc3725.content_warning"]
if (content["page.codeberg.everypizza.msc4193.spoiler"]) {
@ -72,8 +72,12 @@ const MediaMessageBody = ({ event, room, sender }: EventContentProps) => {
const blurhash = ensureString(
content.info?.["xyz.amorgan.blurhash"] ?? content.info?.thumbnail_info?.["xyz.amorgan.blurhash"],
)
const onClick = !clickedShow ? (evt: React.MouseEvent<HTMLDivElement>) => {
setClickedShow(true)
evt.stopPropagation()
} : undefined
placeholderElem = <div
onClick={onClickShow}
onClick={onClick}
className="placeholder"
>
{(blurhash && containerStyle.width) ? <Blurhash

View file

@ -20,9 +20,12 @@ import { RoomContextData } from "../../roomview/roomcontext.ts"
import { usePrimaryItems } from "./usePrimaryItems.tsx"
import { useSecondaryItems } from "./useSecondaryItems.tsx"
interface EventHoverMenuProps {
interface BaseEventMenuProps {
evt: MemDBEvent
roomCtx: RoomContextData
}
interface EventHoverMenuProps extends BaseEventMenuProps {
setForceOpen: (forceOpen: boolean) => void
}
@ -31,9 +34,7 @@ export const EventHoverMenu = ({ evt, roomCtx, setForceOpen }: EventHoverMenuPro
return <div className="event-hover-menu">{elements}</div>
}
interface EventContextMenuProps {
evt: MemDBEvent
roomCtx: RoomContextData
interface EventContextMenuProps extends BaseEventMenuProps {
style: CSSProperties
}
@ -53,12 +54,13 @@ export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) =>
</div>
}
export const EventFixedMenu = ({ evt, roomCtx }: Omit<EventContextMenuProps, "style">) => {
export const EventFixedMenu = ({ evt, roomCtx }: BaseEventMenuProps) => {
const client = use(ClientContext)!
const primary = usePrimaryItems(client, roomCtx, evt, false, true, undefined, undefined)
const secondary = useSecondaryItems(client, roomCtx, evt, false)
return <div className="event-fixed-menu">
{primary}
<div className="vertical-line"/>
{secondary}
</div>
}

View file

@ -20,12 +20,8 @@ div.event-hover-menu, div.event-fixed-menu {
}
div.event-fixed-menu {
position: fixed;
inset: 0 0 auto;
height: 3.5rem;
padding: .25rem;
border-bottom: 1px solid var(--border-color);
box-sizing: border-box;
justify-content: right;
flex-direction: row-reverse;
overflow-x: auto;