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}
+
+
+ |
+
+ >
+ )}
+
+ {isOwnMessage && (
+
+ )}
+
+ )}
);
@@ -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;
-};