Replace 'following' banner with WhatsApp-style delivery status checkmarks on own messages.

This commit is contained in:
heaven 2026-04-25 17:49:51 +03:00
parent 3652320b0f
commit 6c65dee82e
12 changed files with 180 additions and 296 deletions

View file

@ -462,10 +462,6 @@
"drop_files": "Drop Files in \"{{name}}\"", "drop_files": "Drop Files in \"{{name}}\"",
"drag_drop_desc": "Drag and drop files here or click for selection dialog", "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", "pinned_messages": "Pinned Messages",
"no_pinned_messages": "No Pinned Messages", "no_pinned_messages": "No Pinned Messages",
"no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.", "no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.",

View file

@ -462,10 +462,6 @@
"drop_files": "Перетащите файлы в \"{{name}}\"", "drop_files": "Перетащите файлы в \"{{name}}\"",
"drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора", "drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора",
"is_following": " читает чат.",
"are_following": " читают чат.",
"others_following_count": "ещё {{count}}",
"pinned_messages": "Закреплённые сообщения", "pinned_messages": "Закреплённые сообщения",
"no_pinned_messages": "Нет закреплённых сообщений", "no_pinned_messages": "Нет закреплённых сообщений",
"no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.", "no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.",

View file

@ -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 <Icon size="50" src={Icons.Clock} style={{ opacity: 0.5, flexShrink: 0 }} />;
}
if (status === MessageDeliveryStatus.Read && !hideReadReceipts) {
return (
<Icon size="50" src={Icons.CheckTwice} style={{ color: color.Success.Main, flexShrink: 0 }} />
);
}
if (status === MessageDeliveryStatus.Sent || status === MessageDeliveryStatus.Read) {
return <Icon size="50" src={Icons.Check} style={{ opacity: 0.5, flexShrink: 0 }} />;
}
return null;
}

View file

@ -8,3 +8,4 @@ export * from './Time';
export * from './MsgTypeRenderers'; export * from './MsgTypeRenderers';
export * from './FileHeader'; export * from './FileHeader';
export * from './RenderBody'; export * from './RenderBody';
export * from './MessageStatus';

View file

@ -625,7 +625,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
} }
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true; scrollToBottomRef.current.smooth = mEvt.getSender() !== mx.getUserId();
setTimeline((ct) => ({ setTimeline((ct) => ({
...ct, ...ct,

View file

@ -13,12 +13,9 @@ import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping'; import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone'; import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput'; import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page'; import { Page } from '../../components/page';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom'; import { editableActiveElement } from '../../utils/dom';
import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
@ -58,8 +55,6 @@ export function RoomView({ eventId }: { eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null); const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null); const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const room = useRoom(); const room = useRoom();
const { roomId } = room; const { roomId } = room;
const editor = useEditor(); const editor = useEditor();
@ -133,7 +128,6 @@ export function RoomView({ eventId }: { eventId?: string }) {
</> </>
)} )}
</div> </div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box> </Box>
</Page> </Page>
); );

View file

@ -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,
},
},
},
},
},
});

View file

@ -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 <div className={css.RoomViewFollowingPlaceholder} />;
}
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 && (
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<Box
as={names.length > 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 && (
<>
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
<Text size="T300" truncate>
{names.length === 1 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.is_following')}
</Text>
</>
)}
{names.length === 2 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.and')}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.are_following')}
</Text>
</>
)}
{names.length === 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.and')}
</Text>
<b>{names[2]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.are_following')}
</Text>
</>
)}
{names.length > 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[2]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.and')}
</Text>
<b>{t('Room.others_following_count', { count: names.length - 3 })}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.are_following')}
</Text>
</>
)}
</Text>
</>
)}
</Box>
</>
);
}
);

View file

@ -42,6 +42,7 @@ import {
BubbleLayout, BubbleLayout,
CompactLayout, CompactLayout,
MessageBase, MessageBase,
MessageStatus,
ModernLayout, ModernLayout,
Time, Time,
Username, Username,
@ -53,10 +54,7 @@ import {
getMemberAvatarMxc, getMemberAvatarMxc,
getMemberDisplayName, getMemberDisplayName,
} from '../../../utils/room'; } from '../../../utils/room';
import { import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
getMxIdLocalPart,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
@ -467,9 +465,7 @@ export const MessageDeleteItem = as<
direction="Column" direction="Column"
gap="400" gap="400"
> >
<Text priority="400"> <Text priority="400">{t('Room.delete_confirm')}</Text>
{t('Room.delete_confirm')}
</Text>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400"> <Text size="L400">
{t('Room.reason')}{' '} {t('Room.reason')}{' '}
@ -495,7 +491,9 @@ export const MessageDeleteItem = as<
aria-disabled={deleteState.status === AsyncStatus.Loading} aria-disabled={deleteState.status === AsyncStatus.Loading}
> >
<Text size="B400"> <Text size="B400">
{deleteState.status === AsyncStatus.Loading ? t('Room.deleting') : t('Room.delete')} {deleteState.status === AsyncStatus.Loading
? t('Room.deleting')
: t('Room.delete')}
</Text> </Text>
</Button> </Button>
</Box> </Box>
@ -598,9 +596,7 @@ export const MessageReportItem = as<
direction="Column" direction="Column"
gap="400" gap="400"
> >
<Text priority="400"> <Text priority="400">{t('Room.report_desc')}</Text>
{t('Room.report_desc')}
</Text>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Room.report_reason')}</Text> <Text size="L400">{t('Room.report_reason')}</Text>
<Input name="reasonInput" variant="Background" required /> <Input name="reasonInput" variant="Background" required />
@ -629,7 +625,9 @@ export const MessageReportItem = as<
} }
> >
<Text size="B400"> <Text size="B400">
{reportState.status === AsyncStatus.Loading ? t('Room.reporting') : t('Room.report')} {reportState.status === AsyncStatus.Loading
? t('Room.reporting')
: t('Room.report')}
</Text> </Text>
</Button> </Button>
</Box> </Box>
@ -733,6 +731,7 @@ export const Message = as<'div', MessageProps>(
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
const isOwnMessage = senderId === mx.getUserId();
const senderDisplayName = const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderAvatarMxc = getMemberAvatarMxc(room, senderId); const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
@ -746,6 +745,9 @@ export const Message = as<'div', MessageProps>(
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
const isBubble = messageLayout === MessageLayout.Bubble;
const showTimeInHeader = !isBubble || !isOwnMessage;
const headerJSX = !collapse && ( const headerJSX = !collapse && (
<Box <Box
gap="300" gap="300"
@ -762,34 +764,35 @@ export const Message = as<'div', MessageProps>(
onContextMenu={onUserClick} onContextMenu={onUserClick}
onClick={onUsernameClick} onClick={onUsernameClick}
> >
<Text <Text as="span" size={isBubble ? 'T300' : 'T400'} truncate>
as="span"
size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'}
truncate
>
<UsernameBold>{senderDisplayName}</UsernameBold> <UsernameBold>{senderDisplayName}</UsernameBold>
</Text> </Text>
</Username> </Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />} {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box> </Box>
<Box shrink="No" gap="100"> {showTimeInHeader && (
{messageLayout === MessageLayout.Modern && hover && ( <Box shrink="No" gap="100" alignItems="Center">
<> {messageLayout === MessageLayout.Modern && hover && (
<Text as="span" size="T200" priority="300"> <>
{senderId} <Text as="span" size="T200" priority="300">
</Text> {senderId}
<Text as="span" size="T200" priority="300"> </Text>
| <Text as="span" size="T200" priority="300">
</Text> |
</> </Text>
)} </>
<Time )}
ts={mEvent.getTs()} <Time
compact={messageLayout === MessageLayout.Compact} ts={mEvent.getTs()}
hour24Clock={hour24Clock} compact={messageLayout === MessageLayout.Compact}
dateFormatString={dateFormatString} hour24Clock={hour24Clock}
/> dateFormatString={dateFormatString}
</Box> />
{isOwnMessage && (
<MessageStatus room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
)}
</Box>
)}
</Box> </Box>
); );
@ -818,6 +821,18 @@ export const Message = as<'div', MessageProps>(
</AvatarBase> </AvatarBase>
); );
const bubbleMetaJSX = isBubble && isOwnMessage && (
<span className={css.BubbleMeta}>
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
<MessageStatus room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
</span>
);
const msgContentJSX = ( const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}> <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply} {reply}
@ -1139,12 +1154,25 @@ export const Message = as<'div', MessageProps>(
{msgContentJSX} {msgContentJSX}
</CompactLayout> </CompactLayout>
)} )}
{messageLayout === MessageLayout.Bubble && ( {isBubble && isOwnMessage && (
<Box gap="200" alignItems="End">
<BubbleLayout
style={{ flexGrow: 1, minWidth: 0 }}
before={avatarJSX}
header={headerJSX}
onContextMenu={handleContextMenu}
>
{msgContentJSX}
</BubbleLayout>
{bubbleMetaJSX}
</Box>
)}
{isBubble && !isOwnMessage && (
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}> <BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX} {msgContentJSX}
</BubbleLayout> </BubbleLayout>
)} )}
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( {messageLayout !== MessageLayout.Compact && !isBubble && (
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}> <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX} {headerJSX}
{msgContentJSX} {msgContentJSX}

View file

@ -55,3 +55,11 @@ export const ReactionsContainer = style({
export const ReactionsTooltipText = style({ export const ReactionsTooltipText = style({
wordBreak: 'break-word', wordBreak: 'break-word',
}); });
export const BubbleMeta = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: toRem(2),
whiteSpace: 'nowrap',
});

View file

@ -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<MessageDeliveryStatus | null>(() =>
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;
}

View file

@ -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<MatrixEvent>();
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;
};