From 6c65dee82e46fb8c5109eb75fff6f410486745a7 Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 25 Apr 2026 17:49:51 +0300 Subject: [PATCH] Replace 'following' banner with WhatsApp-style delivery status checkmarks on own messages. --- public/locales/en.json | 4 - public/locales/ru.json | 4 - src/app/components/message/MessageStatus.tsx | 30 ++++ src/app/components/message/index.ts | 1 + src/app/features/room/RoomTimeline.tsx | 2 +- src/app/features/room/RoomView.tsx | 6 - .../features/room/RoomViewFollowing.css.ts | 39 ----- src/app/features/room/RoomViewFollowing.tsx | 147 ------------------ src/app/features/room/message/Message.tsx | 102 +++++++----- src/app/features/room/message/styles.css.ts | 8 + src/app/hooks/useMessageStatus.ts | 75 +++++++++ src/app/hooks/useRoomLatestRenderedEvent.ts | 58 ------- 12 files changed, 180 insertions(+), 296 deletions(-) create mode 100644 src/app/components/message/MessageStatus.tsx delete mode 100644 src/app/features/room/RoomViewFollowing.css.ts delete mode 100644 src/app/features/room/RoomViewFollowing.tsx create mode 100644 src/app/hooks/useMessageStatus.ts delete mode 100644 src/app/hooks/useRoomLatestRenderedEvent.ts diff --git a/public/locales/en.json b/public/locales/en.json index e2424b04..ca7175a8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -462,10 +462,6 @@ "drop_files": "Drop Files in \"{{name}}\"", "drag_drop_desc": "Drag and drop files here or click for selection dialog", - "is_following": " is following the conversation.", - "are_following": " are following the conversation.", - "others_following_count": "{{count}} others", - "pinned_messages": "Pinned Messages", "no_pinned_messages": "No Pinned Messages", "no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.", diff --git a/public/locales/ru.json b/public/locales/ru.json index 92436e1e..2c7f31cb 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -462,10 +462,6 @@ "drop_files": "Перетащите файлы в \"{{name}}\"", "drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора", - "is_following": " читает чат.", - "are_following": " читают чат.", - "others_following_count": "ещё {{count}}", - "pinned_messages": "Закреплённые сообщения", "no_pinned_messages": "Нет закреплённых сообщений", "no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.", diff --git a/src/app/components/message/MessageStatus.tsx b/src/app/components/message/MessageStatus.tsx new file mode 100644 index 00000000..442b7db3 --- /dev/null +++ b/src/app/components/message/MessageStatus.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Icon, Icons, color } from 'folds'; +import { MatrixEvent, Room } from 'matrix-js-sdk'; +import { useMessageStatus, MessageDeliveryStatus } from '../../hooks/useMessageStatus'; + +export type MessageStatusProps = { + room: Room; + mEvent: MatrixEvent; + hideReadReceipts?: boolean; +}; + +export function MessageStatus({ room, mEvent, hideReadReceipts }: MessageStatusProps) { + const status = useMessageStatus(room, mEvent); + + if (status === MessageDeliveryStatus.Sending) { + return ; + } + + if (status === MessageDeliveryStatus.Read && !hideReadReceipts) { + return ( + + ); + } + + if (status === MessageDeliveryStatus.Sent || status === MessageDeliveryStatus.Read) { + return ; + } + + return null; +} diff --git a/src/app/components/message/index.ts b/src/app/components/message/index.ts index 6f7415ad..fa28609f 100644 --- a/src/app/components/message/index.ts +++ b/src/app/components/message/index.ts @@ -8,3 +8,4 @@ export * from './Time'; export * from './MsgTypeRenderers'; export * from './FileHeader'; export * from './RenderBody'; +export * from './MessageStatus'; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fc8e8cfb..eeb6b58a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -625,7 +625,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli } scrollToBottomRef.current.count += 1; - scrollToBottomRef.current.smooth = true; + scrollToBottomRef.current.smooth = mEvt.getSender() !== mx.getUserId(); setTimeline((ct) => ({ ...ct, diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a4f0d7af..7a4209bc 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -13,12 +13,9 @@ import { RoomTimeline } from './RoomTimeline'; import { RoomViewTyping } from './RoomViewTyping'; import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; -import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { Page } from '../../components/page'; import { useKeyDown } from '../../hooks/useKeyDown'; import { editableActiveElement } from '../../utils/dom'; -import { settingsAtom } from '../../state/settings'; -import { useSetting } from '../../state/hooks/settings'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoom } from '../../hooks/useRoom'; @@ -58,8 +55,6 @@ export function RoomView({ eventId }: { eventId?: string }) { const roomInputRef = useRef(null); const roomViewRef = useRef(null); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const room = useRoom(); const { roomId } = room; const editor = useEditor(); @@ -133,7 +128,6 @@ export function RoomView({ eventId }: { eventId?: string }) { )} - {hideActivity ? : } ); diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts deleted file mode 100644 index 18b53ac9..00000000 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { style } from '@vanilla-extract/css'; -import { recipe } from '@vanilla-extract/recipes'; -import { DefaultReset, color, config, toRem } from 'folds'; - -export const RoomViewFollowingPlaceholder = style([ - DefaultReset, - { - height: toRem(28), - }, -]); - -export const RoomViewFollowing = recipe({ - base: [ - DefaultReset, - { - minHeight: toRem(28), - padding: `0 ${config.space.S400}`, - width: '100%', - backgroundColor: color.Surface.Container, - color: color.Surface.OnContainer, - outline: 'none', - }, - ], - variants: { - clickable: { - true: { - cursor: 'pointer', - selectors: { - '&:hover, &:focus-visible': { - color: color.Primary.Main, - }, - '&:active': { - color: color.Primary.Main, - }, - }, - }, - }, - }, -}); diff --git a/src/app/features/room/RoomViewFollowing.tsx b/src/app/features/room/RoomViewFollowing.tsx deleted file mode 100644 index 28069239..00000000 --- a/src/app/features/room/RoomViewFollowing.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { useState } from 'react'; -import { - Box, - Icon, - Icons, - Modal, - Overlay, - OverlayBackdrop, - OverlayCenter, - Text, - as, - config, -} from 'folds'; -import { Room } from 'matrix-js-sdk'; -import classNames from 'classnames'; -import FocusTrap from 'focus-trap-react'; -import { useTranslation } from 'react-i18next'; - -import { getMemberDisplayName } from '../../utils/room'; -import { getMxIdLocalPart } from '../../utils/matrix'; -import * as css from './RoomViewFollowing.css'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent'; -import { useRoomEventReaders } from '../../hooks/useRoomEventReaders'; -import { EventReaders } from '../../components/event-readers'; -import { stopPropagation } from '../../utils/keyboard'; - -export function RoomViewFollowingPlaceholder() { - return
; -} - -export type RoomViewFollowingProps = { - room: Room; -}; -export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( - ({ className, room, ...props }, ref) => { - const { t } = useTranslation(); - const mx = useMatrixClient(); - const [open, setOpen] = useState(false); - const latestEvent = useRoomLatestRenderedEvent(room); - const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId()); - const names = latestEventReaders - .filter((readerId) => readerId !== mx.getUserId()) - .map( - (readerId) => getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId - ); - - const eventId = latestEvent?.getId(); - - return ( - <> - {eventId && ( - }> - - setOpen(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - - setOpen(false)} /> - - - - - )} - 0 ? 'button' : 'div'} - onClick={names.length > 0 ? () => setOpen(true) : undefined} - className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)} - alignItems="Center" - justifyContent="End" - gap="200" - {...props} - ref={ref} - > - {names.length > 0 && ( - <> - - - {names.length === 1 && ( - <> - {names[0]} - - {t('Room.is_following')} - - - )} - {names.length === 2 && ( - <> - {names[0]} - - {t('Room.and')} - - {names[1]} - - {t('Room.are_following')} - - - )} - {names.length === 3 && ( - <> - {names[0]} - - {', '} - - {names[1]} - - {t('Room.and')} - - {names[2]} - - {t('Room.are_following')} - - - )} - {names.length > 3 && ( - <> - {names[0]} - - {', '} - - {names[1]} - - {', '} - - {names[2]} - - {t('Room.and')} - - {t('Room.others_following_count', { count: names.length - 3 })} - - {t('Room.are_following')} - - - )} - - - )} - - - ); - } -); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 3733ffec..7a3a1ee5 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -42,6 +42,7 @@ import { BubbleLayout, CompactLayout, MessageBase, + MessageStatus, ModernLayout, Time, Username, @@ -53,10 +54,7 @@ import { getMemberAvatarMxc, getMemberDisplayName, } from '../../../utils/room'; -import { - getMxIdLocalPart, - mxcUrlToHttp, -} from '../../../utils/matrix'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; @@ -467,9 +465,7 @@ export const MessageDeleteItem = as< direction="Column" gap="400" > - - {t('Room.delete_confirm')} - + {t('Room.delete_confirm')} {t('Room.reason')}{' '} @@ -495,7 +491,9 @@ export const MessageDeleteItem = as< aria-disabled={deleteState.status === AsyncStatus.Loading} > - {deleteState.status === AsyncStatus.Loading ? t('Room.deleting') : t('Room.delete')} + {deleteState.status === AsyncStatus.Loading + ? t('Room.deleting') + : t('Room.delete')} @@ -598,9 +596,7 @@ export const MessageReportItem = as< direction="Column" gap="400" > - - {t('Room.report_desc')} - + {t('Room.report_desc')} {t('Room.report_reason')} @@ -629,7 +625,9 @@ export const MessageReportItem = as< } > - {reportState.status === AsyncStatus.Loading ? t('Room.reporting') : t('Room.report')} + {reportState.status === AsyncStatus.Loading + ? t('Room.reporting') + : t('Room.report')} @@ -733,6 +731,7 @@ export const Message = as<'div', MessageProps>( const [menuAnchor, setMenuAnchor] = useState(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); + const isOwnMessage = senderId === mx.getUserId(); const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); @@ -746,6 +745,9 @@ export const Message = as<'div', MessageProps>( const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; + const isBubble = messageLayout === MessageLayout.Bubble; + const showTimeInHeader = !isBubble || !isOwnMessage; + const headerJSX = !collapse && ( ( onContextMenu={onUserClick} onClick={onUsernameClick} > - + {senderDisplayName} {tagIconSrc && } - - {messageLayout === MessageLayout.Modern && hover && ( - <> - - {senderId} - - - | - - - )} - + {showTimeInHeader && ( + + {messageLayout === MessageLayout.Modern && hover && ( + <> + + {senderId} + + + | + + + )} + + )} ); @@ -818,6 +821,18 @@ export const Message = as<'div', MessageProps>( ); + const bubbleMetaJSX = isBubble && isOwnMessage && ( + + + ); + const msgContentJSX = ( {reply} @@ -1139,12 +1154,25 @@ export const Message = as<'div', MessageProps>( {msgContentJSX} )} - {messageLayout === MessageLayout.Bubble && ( + {isBubble && isOwnMessage && ( + + + {msgContentJSX} + + {bubbleMetaJSX} + + )} + {isBubble && !isOwnMessage && ( {msgContentJSX} )} - {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( + {messageLayout !== MessageLayout.Compact && !isBubble && ( {headerJSX} {msgContentJSX} diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts index 4be501bd..6c1ca3fd 100644 --- a/src/app/features/room/message/styles.css.ts +++ b/src/app/features/room/message/styles.css.ts @@ -55,3 +55,11 @@ export const ReactionsContainer = style({ export const ReactionsTooltipText = style({ wordBreak: 'break-word', }); + +export const BubbleMeta = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: toRem(2), + whiteSpace: 'nowrap', +}); diff --git a/src/app/hooks/useMessageStatus.ts b/src/app/hooks/useMessageStatus.ts new file mode 100644 index 00000000..41001773 --- /dev/null +++ b/src/app/hooks/useMessageStatus.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap, EventStatus } from 'matrix-js-sdk'; +import { useMatrixClient } from './useMatrixClient'; + +export enum MessageDeliveryStatus { + Sending = 'sending', + Sent = 'sent', + Read = 'read', +} + +function getDeliveryStatus( + room: Room, + mEvent: MatrixEvent, + myUserId: string +): MessageDeliveryStatus | null { + if (mEvent.getSender() !== myUserId) return null; + + const { status } = mEvent; + if ( + status === EventStatus.SENDING || + status === EventStatus.ENCRYPTING || + status === EventStatus.QUEUED + ) { + return MessageDeliveryStatus.Sending; + } + + if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) return null; + + const eventId = mEvent.getId(); + if (!eventId || !eventId.startsWith('$')) return MessageDeliveryStatus.Sending; + + const members = room.getMembers(); + const otherMembers = members.filter((m) => m.userId !== myUserId && m.membership !== 'invite'); + + const hasReader = otherMembers.some((member) => room.hasUserReadEvent(member.userId, eventId)); + if (hasReader) return MessageDeliveryStatus.Read; + + return MessageDeliveryStatus.Sent; +} + +export function useMessageStatus(room: Room, mEvent: MatrixEvent): MessageDeliveryStatus | null { + const mx = useMatrixClient(); + const myUserId = mx.getUserId() ?? ''; + const isOwnMessage = mEvent.getSender() === myUserId; + + const [status, setStatus] = useState(() => + getDeliveryStatus(room, mEvent, myUserId) + ); + + useEffect(() => { + if (!isOwnMessage) return undefined; + + setStatus(getDeliveryStatus(room, mEvent, myUserId)); + + const handleReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (_event, r) => { + if (r.roomId !== room.roomId) return; + setStatus(getDeliveryStatus(room, mEvent, myUserId)); + }; + + const handleLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event, r) => { + if (r.roomId !== room.roomId) return; + if (event.getId() !== mEvent.getId()) return; + setStatus(getDeliveryStatus(room, mEvent, myUserId)); + }; + + room.on(RoomEvent.Receipt, handleReceipt); + room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho); + return () => { + room.removeListener(RoomEvent.Receipt, handleReceipt); + room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho); + }; + }, [room, mEvent, myUserId, isOwnMessage]); + + return status; +} diff --git a/src/app/hooks/useRoomLatestRenderedEvent.ts b/src/app/hooks/useRoomLatestRenderedEvent.ts deleted file mode 100644 index fd0ed9e5..00000000 --- a/src/app/hooks/useRoomLatestRenderedEvent.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable no-continue */ -import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; -import { useEffect, useState } from 'react'; -import { settingsAtom } from '../state/settings'; -import { useSetting } from '../state/hooks/settings'; -import { MessageEvent, StateEvent } from '../../types/matrix/room'; -import { isMembershipChanged, reactionOrEditEvent } from '../utils/room'; - -export const useRoomLatestRenderedEvent = (room: Room) => { - const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); - const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); - const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); - const [latestEvent, setLatestEvent] = useState(); - - useEffect(() => { - const getLatestEvent = (): MatrixEvent | undefined => { - const liveEvents = room.getLiveTimeline().getEvents(); - for (let i = liveEvents.length - 1; i >= 0; i -= 1) { - const evt = liveEvents[i]; - - if (!evt) continue; - if (reactionOrEditEvent(evt)) continue; - if (evt.getType() === StateEvent.RoomMember) { - const membershipChanged = isMembershipChanged(evt); - if (membershipChanged && hideMembershipEvents) continue; - if (!membershipChanged && hideNickAvatarEvents) continue; - return evt; - } - - if ( - evt.getType() === MessageEvent.RoomMessage || - evt.getType() === MessageEvent.RoomMessageEncrypted || - evt.getType() === MessageEvent.Sticker || - evt.getType() === StateEvent.RoomName || - evt.getType() === StateEvent.RoomTopic || - evt.getType() === StateEvent.RoomAvatar - ) { - return evt; - } - - if (showHiddenEvents) return evt; - } - return undefined; - }; - - const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => { - setLatestEvent(getLatestEvent()); - }; - setLatestEvent(getLatestEvent()); - - room.on(RoomEvent.Timeline, handleTimelineEvent); - return () => { - room.removeListener(RoomEvent.Timeline, handleTimelineEvent); - }; - }, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]); - - return latestEvent; -};