diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index d96db6019..d4bc07aad 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -286,7 +286,7 @@ function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCou export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReaction): ApiUserReaction | undefined { const { - peerId, reaction, big, unread, + peerId, reaction, big, unread, date, } = userReaction; const apiReaction = buildApiReaction(reaction); @@ -295,6 +295,7 @@ export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReactio return { userId: getApiChatIdFromMtpPeer(peerId), reaction: apiReaction, + addedDate: date, isUnread: unread, isBig: big, }; diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index fb6af6949..d596a05a7 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1416,7 +1416,13 @@ export async function fetchSeenBy({ chat, messageId }: { chat: ApiChat; messageI msgId: messageId, })); - return result ? result.map((readDate) => readDate.userId.toString()) : undefined; + return result + ? result.reduce((acc, readDate) => { + acc[readDate.userId.toString()] = readDate.date; + + return acc; + }, {} as Record) + : undefined; } export async function fetchSendAs({ diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 0ce9b3d8b..65007cf8e 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -428,7 +428,7 @@ export interface ApiMessage { isFromScheduled?: boolean; isSilent?: boolean; isPinned?: boolean; - seenByUserIds?: string[]; + seenByDates?: Record; isProtected?: boolean; isForwardingAllowed?: boolean; transcriptionId?: string; @@ -453,6 +453,7 @@ export interface ApiUserReaction { reaction: ApiReaction; isBig?: boolean; isUnread?: boolean; + addedDate: number; } export interface ApiReactionCount { diff --git a/src/components/common/PrivateChatInfo.tsx b/src/components/common/PrivateChatInfo.tsx index 3fd13f473..0fd5eecfe 100644 --- a/src/components/common/PrivateChatInfo.tsx +++ b/src/components/common/PrivateChatInfo.tsx @@ -32,6 +32,7 @@ type OwnProps = { avatarSize?: 'tiny' | 'small' | 'medium' | 'large' | 'jumbo'; forceShowSelf?: boolean; status?: string; + statusIcon?: string; withDots?: boolean; withMediaViewer?: boolean; withUsername?: boolean; @@ -56,6 +57,7 @@ const PrivateChatInfo: FC = ({ typingStatus, avatarSize = 'medium', status, + statusIcon, withDots, withMediaViewer, withUsername, @@ -109,7 +111,10 @@ const PrivateChatInfo: FC = ({ return withDots ? ( ) : ( - {renderText(status)} + + {statusIcon && } + {renderText(status)} + ); } diff --git a/src/components/common/SeenByModal.tsx b/src/components/common/SeenByModal.tsx index 1092a5a7d..e3d1340b1 100644 --- a/src/components/common/SeenByModal.tsx +++ b/src/components/common/SeenByModal.tsx @@ -1,9 +1,9 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { useCallback, memo } from '../../lib/teact/teact'; +import React, { useCallback, memo, useMemo } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import useLang from '../../hooks/useLang'; import { selectChatMessage, selectTabState } from '../../global/selectors'; +import { formatDateAtTime } from '../../util/dateFormat'; +import useLang from '../../hooks/useLang'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import Modal from '../ui/Modal'; @@ -16,15 +16,15 @@ export type OwnProps = { }; export type StateProps = { - memberIds?: string[]; + seenByDates?: Record; }; const CLOSE_ANIMATION_DURATION = 100; -const SeenByModal: FC = ({ +function SeenByModal({ isOpen, - memberIds, -}) => { + seenByDates, +}: OwnProps & StateProps) { const { openChat, closeSeenByModal, @@ -32,6 +32,18 @@ const SeenByModal: FC = ({ const lang = useLang(); + const renderingSeenByDates = useCurrentOrPrev(seenByDates, true); + const memberIds = useMemo(() => { + if (!renderingSeenByDates) { + return undefined; + } + + const result = Object.keys(renderingSeenByDates); + result.sort((leftId, rightId) => renderingSeenByDates[rightId] - renderingSeenByDates[leftId]); + + return result; + }, [renderingSeenByDates]); + const handleClick = useCallback((userId: string) => { closeSeenByModal(); @@ -44,8 +56,6 @@ const SeenByModal: FC = ({ closeSeenByModal(); }, [closeSeenByModal]); - const renderingMemberIds = useCurrentOrPrev(memberIds, true); - return ( = ({ title={`Seen by ${memberIds?.length} users`} >
- {renderingMemberIds && renderingMemberIds.map((userId) => ( + {memberIds && memberIds.map((userId) => ( handleClick(userId)} > - + ))}
@@ -76,7 +91,7 @@ const SeenByModal: FC = ({
); -}; +} export default memo(withGlobal( (global): StateProps => { @@ -86,7 +101,7 @@ export default memo(withGlobal( } return { - memberIds: selectChatMessage(global, chatId, messageId)?.seenByUserIds, + seenByDates: selectChatMessage(global, chatId, messageId)?.seenByDates, }; }, )(SeenByModal)); diff --git a/src/components/middle/ReactorListModal.scss b/src/components/middle/ReactorListModal.scss index 82283c2d1..dd88af977 100644 --- a/src/components/middle/ReactorListModal.scss +++ b/src/components/middle/ReactorListModal.scss @@ -5,6 +5,18 @@ .modal-content { overflow: hidden; + height: min(92vh, 32rem); + display: flex; + flex-direction: column; + } + + .reactor-list-wrapper { + flex-grow: 1; + min-height: 0; + } + + .confirm-dialog-button { + align-self: flex-end; } .Reactions { @@ -14,15 +26,15 @@ .icon-heart { width: 1.125rem; height: 1.125rem; - margin-right: 0.25rem; + margin-inline-end: 0.25rem; } .reaction-filter-emoji { - margin-right: 0.25rem; + margin-inline-end: 0.25rem; } .reactor-list { - max-height: 400px; + max-height: 100%; overflow: auto; overflow-x: hidden; } @@ -41,4 +53,8 @@ height: 1.5rem; margin-inline-start: auto; } + + .status { + color: var(--color-text-secondary); + } } diff --git a/src/components/middle/ReactorListModal.tsx b/src/components/middle/ReactorListModal.tsx index c1eaec065..1d3472f04 100644 --- a/src/components/middle/ReactorListModal.tsx +++ b/src/components/middle/ReactorListModal.tsx @@ -15,6 +15,7 @@ import buildClassName from '../../util/buildClassName'; import { formatIntegerCompact } from '../../util/textFormat'; import { unique } from '../../util/iteratees'; import { isSameReaction, getReactionUniqueKey } from '../../global/helpers'; +import { formatDateAtTime } from '../../util/dateFormat'; import useLang from '../../hooks/useLang'; import useInfiniteScroll from '../../hooks/useInfiniteScroll'; @@ -28,6 +29,7 @@ import ListItem from '../ui/ListItem'; import ReactionStaticEmoji from '../common/ReactionStaticEmoji'; import Loading from '../ui/Loading'; import FullNameTitle from '../common/FullNameTitle'; +import PrivateChatInfo from '../common/PrivateChatInfo'; import './ReactorListModal.scss'; @@ -37,7 +39,7 @@ export type OwnProps = { isOpen: boolean; }; -export type StateProps = Pick & { +export type StateProps = Pick & { chatId?: string; messageId?: number; availableReactions?: ApiAvailableReaction[]; @@ -49,7 +51,7 @@ const ReactorListModal: FC = ({ reactions, chatId, messageId, - seenByUserIds, + seenByDates, availableReactions, }) => { const { @@ -118,8 +120,11 @@ const ReactorListModal: FC = ({ .filter(({ reaction }) => isSameReaction(reaction, chosenTab)) .map(({ userId }) => userId); } + + const seenByUserIds = Object.keys(seenByDates || {}); + return unique(reactors?.reactions.map(({ userId }) => userId).concat(seenByUserIds || []) || []); - }, [chosenTab, reactors, seenByUserIds]); + }, [chosenTab, reactors, seenByDates]); const [viewportIds, getMore] = useInfiniteScroll( handleLoadMore, userIds, reactors && reactors.nextOffset === undefined, @@ -138,7 +143,7 @@ const ReactorListModal: FC = ({ onCloseAnimationEnd={handleCloseAnimationEnd} > {canShowFilters && ( -
+
)} -
+
{viewportIds?.length ? ( = ({ const user = usersById[userId]; const userReactions = reactors?.reactions.filter((reactor) => reactor.userId === userId); const items: React.ReactNode[] = []; + const seenByUser = seenByDates?.[userId]; + userReactions?.forEach((r) => { if (chosenTab && !isSameReaction(r.reaction, chosenTab)) return; + items.push( = ({ onClick={() => handleClick(userId)} > - +
+ + + + {formatDateAtTime(lang, r.addedDate * 1000)} + +
{r.reaction && ( = ({
, ); }); + + if (!chosenTab && !userReactions?.length) { + items.push( + handleClick(userId)} + > + + , + ); + } return items; }, )} @@ -233,7 +265,7 @@ export default memo(withGlobal( messageId, reactions: message?.reactions, reactors: message?.reactors, - seenByUserIds: message?.seenByUserIds, + seenByDates: message?.seenByDates, availableReactions: global.availableReactions, }; }, diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index b8d6cdf5d..1d8f7ed46 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -240,14 +240,14 @@ const ContextMenuContainer: FC = ({ return Array.from(uniqueReactors).filter(Boolean).slice(0, 3); } - if (!message.seenByUserIds) { + if (!message.seenByDates) { return undefined; } // No need for expensive global updates on users, so we avoid them const usersById = getGlobal().users.byId; - return message.seenByUserIds?.slice(0, 3).map((id) => usersById[id]).filter(Boolean); - }, [message.reactions?.recentReactions, message.seenByUserIds]); + return Object.keys(message.seenByDates).slice(0, 3).map((id) => usersById[id]).filter(Boolean); + }, [message.reactions?.recentReactions, message.seenByDates]); const isDownloading = album ? album.messages.some((msg) => activeDownloads.includes(msg.id)) : activeDownloads.includes(message.id); diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index ee4359d90..7edb9d2c4 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useRef, + memo, useCallback, useEffect, useMemo, useRef, } from '../../../lib/teact/teact'; import { getActions } from '../../../global'; @@ -205,10 +205,12 @@ const MessageContextMenu: FC = ({ const withReactions = canShowReactionList && !noReactions; const isSponsoredMessage = !('id' in message); const messageId = !isSponsoredMessage ? message.id : ''; + const seenByDates = !isSponsoredMessage ? message.seenByDates : undefined; const [areItemsHidden, hideItems] = useFlag(); const [isReady, markIsReady, unmarkIsReady] = useFlag(); const { isMobile, isDesktop } = useAppLayout(); + const seenByDatesCount = useMemo(() => (seenByDates ? Object.keys(seenByDates).length : 0), [seenByDates]); const handleAfterCopy = useCallback(() => { showNotification({ @@ -384,21 +386,21 @@ const MessageContextMenu: FC = ({ className="MessageContextMenu--seen-by" icon={canShowReactionsCount ? 'heart-outline' : 'group'} onClick={canShowReactionsCount ? onShowReactors : onShowSeenBy} - disabled={!canShowReactionsCount && !message.seenByUserIds?.length} + disabled={!canShowReactionsCount && !seenByDatesCount} > {canShowReactionsCount && message.reactors?.count ? ( - canShowSeenBy && message.seenByUserIds?.length + canShowSeenBy && seenByDatesCount ? lang( 'Chat.OutgoingContextMixedReactionCount', - [message.reactors.count, message.seenByUserIds.length], + [message.reactors.count, seenByDatesCount], ) : lang('Chat.ContextReactionCount', message.reactors.count, 'i') ) : ( - message.seenByUserIds?.length === 1 && seenByRecentUsers + seenByDatesCount === 1 && seenByRecentUsers ? renderText(getUserFullName(seenByRecentUsers[0])!) - : (message.seenByUserIds?.length - ? lang('Conversation.ContextMenuSeen', message.seenByUserIds.length, 'i') + : (seenByDatesCount + ? lang('Conversation.ContextMenuSeen', seenByDatesCount, 'i') : lang('Conversation.ContextMenuNoViews') ) )} diff --git a/src/components/ui/ListItem.scss b/src/components/ui/ListItem.scss index 3a21fd5e8..d4d33c041 100644 --- a/src/components/ui/ListItem.scss +++ b/src/components/ui/ListItem.scss @@ -234,6 +234,11 @@ } } + .status-icon { + vertical-align: text-bottom; + margin-inline-end: 0.125rem; + } + .contact-phone, .contact-username { font-size: 0.875rem; diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 9f6a0d92b..11f25b965 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -1222,7 +1222,7 @@ addActionHandler('loadSeenBy', async (global, actions, payload): Promise = global = getGlobal(); global = updateChatMessage(global, chatId, messageId, { - seenByUserIds: result, + seenByDates: result, }); setGlobal(global); }); diff --git a/src/global/reducers/reactions.ts b/src/global/reducers/reactions.ts index 6f2d5aa54..cdd169e69 100644 --- a/src/global/reducers/reactions.ts +++ b/src/global/reducers/reactions.ts @@ -80,6 +80,7 @@ export function addMessageReaction( recentReactions.unshift({ userId: currentUserId!, reaction, + addedDate: Math.floor(Date.now() / 1000), }); }); diff --git a/src/util/dateFormat.ts b/src/util/dateFormat.ts index c984323d5..9b396d5a8 100644 --- a/src/util/dateFormat.ts +++ b/src/util/dateFormat.ts @@ -310,6 +310,31 @@ export function formatDateTimeToString( ); } +export function formatDateAtTime( + lang: LangFn, + datetime: number | Date, +) { + const date = typeof datetime === 'number' ? new Date(datetime) : datetime; + + const today = getDayStart(new Date()); + const time = formatTime(lang, date); + + if (toIsoString(date) === toIsoString(today)) { + return lang('Time.TodayAt', time); + } + + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + if (toIsoString(date) === toIsoString(yesterday)) { + return lang('Time.YesterdayAt', time); + } + + const noYear = date.getFullYear() === today.getFullYear(); + const formattedDate = formatDateToString(date, lang.code, noYear); + + return lang('formatDateAtTime', [formattedDate, time]); +} + function isValidDate(day: number, month: number, year = 2021): boolean { if (month > (MAX_MONTH_IN_YEAR - 1) || day > MAX_DAY_IN_MONTH) { return false;