Seen By Users: Read Time in Small Groups (#3148)

This commit is contained in:
Alexander Zinchuk 2023-05-03 20:21:20 +04:00
parent 46c6f8fb9b
commit c8d64dc929
13 changed files with 147 additions and 38 deletions

View File

@ -286,7 +286,7 @@ function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCou
export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReaction): ApiUserReaction | undefined { export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReaction): ApiUserReaction | undefined {
const { const {
peerId, reaction, big, unread, peerId, reaction, big, unread, date,
} = userReaction; } = userReaction;
const apiReaction = buildApiReaction(reaction); const apiReaction = buildApiReaction(reaction);
@ -295,6 +295,7 @@ export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReactio
return { return {
userId: getApiChatIdFromMtpPeer(peerId), userId: getApiChatIdFromMtpPeer(peerId),
reaction: apiReaction, reaction: apiReaction,
addedDate: date,
isUnread: unread, isUnread: unread,
isBig: big, isBig: big,
}; };

View File

@ -1416,7 +1416,13 @@ export async function fetchSeenBy({ chat, messageId }: { chat: ApiChat; messageI
msgId: messageId, 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<string, number>)
: undefined;
} }
export async function fetchSendAs({ export async function fetchSendAs({

View File

@ -428,7 +428,7 @@ export interface ApiMessage {
isFromScheduled?: boolean; isFromScheduled?: boolean;
isSilent?: boolean; isSilent?: boolean;
isPinned?: boolean; isPinned?: boolean;
seenByUserIds?: string[]; seenByDates?: Record<string, number>;
isProtected?: boolean; isProtected?: boolean;
isForwardingAllowed?: boolean; isForwardingAllowed?: boolean;
transcriptionId?: string; transcriptionId?: string;
@ -453,6 +453,7 @@ export interface ApiUserReaction {
reaction: ApiReaction; reaction: ApiReaction;
isBig?: boolean; isBig?: boolean;
isUnread?: boolean; isUnread?: boolean;
addedDate: number;
} }
export interface ApiReactionCount { export interface ApiReactionCount {

View File

@ -32,6 +32,7 @@ type OwnProps = {
avatarSize?: 'tiny' | 'small' | 'medium' | 'large' | 'jumbo'; avatarSize?: 'tiny' | 'small' | 'medium' | 'large' | 'jumbo';
forceShowSelf?: boolean; forceShowSelf?: boolean;
status?: string; status?: string;
statusIcon?: string;
withDots?: boolean; withDots?: boolean;
withMediaViewer?: boolean; withMediaViewer?: boolean;
withUsername?: boolean; withUsername?: boolean;
@ -56,6 +57,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
typingStatus, typingStatus,
avatarSize = 'medium', avatarSize = 'medium',
status, status,
statusIcon,
withDots, withDots,
withMediaViewer, withMediaViewer,
withUsername, withUsername,
@ -109,7 +111,10 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
return withDots ? ( return withDots ? (
<DotAnimation className="status" content={status} /> <DotAnimation className="status" content={status} />
) : ( ) : (
<span className="status" dir="auto">{renderText(status)}</span> <span className="status" dir="auto">
{statusIcon && <i className={`icon ${statusIcon} status-icon`} />}
{renderText(status)}
</span>
); );
} }

View File

@ -1,9 +1,9 @@
import type { FC } from '../../lib/teact/teact'; import React, { useCallback, memo, useMemo } from '../../lib/teact/teact';
import React, { useCallback, memo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import useLang from '../../hooks/useLang';
import { selectChatMessage, selectTabState } from '../../global/selectors'; import { selectChatMessage, selectTabState } from '../../global/selectors';
import { formatDateAtTime } from '../../util/dateFormat';
import useLang from '../../hooks/useLang';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import Modal from '../ui/Modal'; import Modal from '../ui/Modal';
@ -16,15 +16,15 @@ export type OwnProps = {
}; };
export type StateProps = { export type StateProps = {
memberIds?: string[]; seenByDates?: Record<string, number>;
}; };
const CLOSE_ANIMATION_DURATION = 100; const CLOSE_ANIMATION_DURATION = 100;
const SeenByModal: FC<OwnProps & StateProps> = ({ function SeenByModal({
isOpen, isOpen,
memberIds, seenByDates,
}) => { }: OwnProps & StateProps) {
const { const {
openChat, openChat,
closeSeenByModal, closeSeenByModal,
@ -32,6 +32,18 @@ const SeenByModal: FC<OwnProps & StateProps> = ({
const lang = useLang(); 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) => { const handleClick = useCallback((userId: string) => {
closeSeenByModal(); closeSeenByModal();
@ -44,8 +56,6 @@ const SeenByModal: FC<OwnProps & StateProps> = ({
closeSeenByModal(); closeSeenByModal();
}, [closeSeenByModal]); }, [closeSeenByModal]);
const renderingMemberIds = useCurrentOrPrev(memberIds, true);
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
@ -54,14 +64,19 @@ const SeenByModal: FC<OwnProps & StateProps> = ({
title={`Seen by ${memberIds?.length} users`} title={`Seen by ${memberIds?.length} users`}
> >
<div dir={lang.isRtl ? 'rtl' : undefined}> <div dir={lang.isRtl ? 'rtl' : undefined}>
{renderingMemberIds && renderingMemberIds.map((userId) => ( {memberIds && memberIds.map((userId) => (
<ListItem <ListItem
key={userId} key={userId}
className="chat-item-clickable scroll-item small-icon" className="chat-item-clickable scroll-item small-icon"
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
onClick={() => handleClick(userId)} onClick={() => handleClick(userId)}
> >
<PrivateChatInfo userId={userId} noStatusOrTyping /> <PrivateChatInfo
userId={userId}
noStatusOrTyping
status={formatDateAtTime(lang, renderingSeenByDates![userId] * 1000)}
statusIcon="icon-message-read"
/>
</ListItem> </ListItem>
))} ))}
</div> </div>
@ -76,7 +91,7 @@ const SeenByModal: FC<OwnProps & StateProps> = ({
</div> </div>
</Modal> </Modal>
); );
}; }
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
@ -86,7 +101,7 @@ export default memo(withGlobal<OwnProps>(
} }
return { return {
memberIds: selectChatMessage(global, chatId, messageId)?.seenByUserIds, seenByDates: selectChatMessage(global, chatId, messageId)?.seenByDates,
}; };
}, },
)(SeenByModal)); )(SeenByModal));

View File

@ -5,6 +5,18 @@
.modal-content { .modal-content {
overflow: hidden; 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 { .Reactions {
@ -14,15 +26,15 @@
.icon-heart { .icon-heart {
width: 1.125rem; width: 1.125rem;
height: 1.125rem; height: 1.125rem;
margin-right: 0.25rem; margin-inline-end: 0.25rem;
} }
.reaction-filter-emoji { .reaction-filter-emoji {
margin-right: 0.25rem; margin-inline-end: 0.25rem;
} }
.reactor-list { .reactor-list {
max-height: 400px; max-height: 100%;
overflow: auto; overflow: auto;
overflow-x: hidden; overflow-x: hidden;
} }
@ -41,4 +53,8 @@
height: 1.5rem; height: 1.5rem;
margin-inline-start: auto; margin-inline-start: auto;
} }
.status {
color: var(--color-text-secondary);
}
} }

View File

@ -15,6 +15,7 @@ import buildClassName from '../../util/buildClassName';
import { formatIntegerCompact } from '../../util/textFormat'; import { formatIntegerCompact } from '../../util/textFormat';
import { unique } from '../../util/iteratees'; import { unique } from '../../util/iteratees';
import { isSameReaction, getReactionUniqueKey } from '../../global/helpers'; import { isSameReaction, getReactionUniqueKey } from '../../global/helpers';
import { formatDateAtTime } from '../../util/dateFormat';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useInfiniteScroll from '../../hooks/useInfiniteScroll'; import useInfiniteScroll from '../../hooks/useInfiniteScroll';
@ -28,6 +29,7 @@ import ListItem from '../ui/ListItem';
import ReactionStaticEmoji from '../common/ReactionStaticEmoji'; import ReactionStaticEmoji from '../common/ReactionStaticEmoji';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
import FullNameTitle from '../common/FullNameTitle'; import FullNameTitle from '../common/FullNameTitle';
import PrivateChatInfo from '../common/PrivateChatInfo';
import './ReactorListModal.scss'; import './ReactorListModal.scss';
@ -37,7 +39,7 @@ export type OwnProps = {
isOpen: boolean; isOpen: boolean;
}; };
export type StateProps = Pick<ApiMessage, 'reactors' | 'reactions' | 'seenByUserIds'> & { export type StateProps = Pick<ApiMessage, 'reactors' | 'reactions' | 'seenByDates'> & {
chatId?: string; chatId?: string;
messageId?: number; messageId?: number;
availableReactions?: ApiAvailableReaction[]; availableReactions?: ApiAvailableReaction[];
@ -49,7 +51,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
reactions, reactions,
chatId, chatId,
messageId, messageId,
seenByUserIds, seenByDates,
availableReactions, availableReactions,
}) => { }) => {
const { const {
@ -118,8 +120,11 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
.filter(({ reaction }) => isSameReaction(reaction, chosenTab)) .filter(({ reaction }) => isSameReaction(reaction, chosenTab))
.map(({ userId }) => userId); .map(({ userId }) => userId);
} }
const seenByUserIds = Object.keys(seenByDates || {});
return unique(reactors?.reactions.map(({ userId }) => userId).concat(seenByUserIds || []) || []); return unique(reactors?.reactions.map(({ userId }) => userId).concat(seenByUserIds || []) || []);
}, [chosenTab, reactors, seenByUserIds]); }, [chosenTab, reactors, seenByDates]);
const [viewportIds, getMore] = useInfiniteScroll( const [viewportIds, getMore] = useInfiniteScroll(
handleLoadMore, userIds, reactors && reactors.nextOffset === undefined, handleLoadMore, userIds, reactors && reactors.nextOffset === undefined,
@ -138,7 +143,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
onCloseAnimationEnd={handleCloseAnimationEnd} onCloseAnimationEnd={handleCloseAnimationEnd}
> >
{canShowFilters && ( {canShowFilters && (
<div className="Reactions"> <div className="Reactions" dir={lang.isRtl ? 'rtl' : undefined}>
<Button <Button
className={buildClassName(!chosenTab && 'chosen')} className={buildClassName(!chosenTab && 'chosen')}
size="tiny" size="tiny"
@ -173,7 +178,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
</div> </div>
)} )}
<div dir={lang.isRtl ? 'rtl' : undefined}> <div dir={lang.isRtl ? 'rtl' : undefined} className="reactor-list-wrapper">
{viewportIds?.length ? ( {viewportIds?.length ? (
<InfiniteScroll <InfiniteScroll
className="reactor-list custom-scroll" className="reactor-list custom-scroll"
@ -185,8 +190,11 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
const user = usersById[userId]; const user = usersById[userId];
const userReactions = reactors?.reactions.filter((reactor) => reactor.userId === userId); const userReactions = reactors?.reactions.filter((reactor) => reactor.userId === userId);
const items: React.ReactNode[] = []; const items: React.ReactNode[] = [];
const seenByUser = seenByDates?.[userId];
userReactions?.forEach((r) => { userReactions?.forEach((r) => {
if (chosenTab && !isSameReaction(r.reaction, chosenTab)) return; if (chosenTab && !isSameReaction(r.reaction, chosenTab)) return;
items.push( items.push(
<ListItem <ListItem
key={`${userId}-${getReactionUniqueKey(r.reaction)}`} key={`${userId}-${getReactionUniqueKey(r.reaction)}`}
@ -195,7 +203,13 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
onClick={() => handleClick(userId)} onClick={() => handleClick(userId)}
> >
<Avatar user={user} size="small" /> <Avatar user={user} size="small" />
<FullNameTitle peer={user} withEmojiStatus /> <div className="info">
<FullNameTitle peer={user} withEmojiStatus />
<span className="status" dir="auto">
<i className="icon icon-heart-outline status-icon" />
{formatDateAtTime(lang, r.addedDate * 1000)}
</span>
</div>
{r.reaction && ( {r.reaction && (
<ReactionStaticEmoji <ReactionStaticEmoji
className="reactors-list-emoji" className="reactors-list-emoji"
@ -206,6 +220,24 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
</ListItem>, </ListItem>,
); );
}); });
if (!chosenTab && !userReactions?.length) {
items.push(
<ListItem
key={`${userId}-seen-by`}
className="chat-item-clickable scroll-item small-icon"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleClick(userId)}
>
<PrivateChatInfo
userId={userId}
noStatusOrTyping
status={seenByUser ? formatDateAtTime(lang, seenByUser * 1000) : undefined}
statusIcon="icon-message-read"
/>
</ListItem>,
);
}
return items; return items;
}, },
)} )}
@ -233,7 +265,7 @@ export default memo(withGlobal<OwnProps>(
messageId, messageId,
reactions: message?.reactions, reactions: message?.reactions,
reactors: message?.reactors, reactors: message?.reactors,
seenByUserIds: message?.seenByUserIds, seenByDates: message?.seenByDates,
availableReactions: global.availableReactions, availableReactions: global.availableReactions,
}; };
}, },

View File

@ -240,14 +240,14 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
return Array.from(uniqueReactors).filter(Boolean).slice(0, 3); return Array.from(uniqueReactors).filter(Boolean).slice(0, 3);
} }
if (!message.seenByUserIds) { if (!message.seenByDates) {
return undefined; return undefined;
} }
// No need for expensive global updates on users, so we avoid them // No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId; const usersById = getGlobal().users.byId;
return message.seenByUserIds?.slice(0, 3).map((id) => usersById[id]).filter(Boolean); return Object.keys(message.seenByDates).slice(0, 3).map((id) => usersById[id]).filter(Boolean);
}, [message.reactions?.recentReactions, message.seenByUserIds]); }, [message.reactions?.recentReactions, message.seenByDates]);
const isDownloading = album ? album.messages.some((msg) => activeDownloads.includes(msg.id)) const isDownloading = album ? album.messages.some((msg) => activeDownloads.includes(msg.id))
: activeDownloads.includes(message.id); : activeDownloads.includes(message.id);

View File

@ -1,5 +1,5 @@
import React, { import React, {
memo, useCallback, useEffect, useRef, memo, useCallback, useEffect, useMemo, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
@ -205,10 +205,12 @@ const MessageContextMenu: FC<OwnProps> = ({
const withReactions = canShowReactionList && !noReactions; const withReactions = canShowReactionList && !noReactions;
const isSponsoredMessage = !('id' in message); const isSponsoredMessage = !('id' in message);
const messageId = !isSponsoredMessage ? message.id : ''; const messageId = !isSponsoredMessage ? message.id : '';
const seenByDates = !isSponsoredMessage ? message.seenByDates : undefined;
const [areItemsHidden, hideItems] = useFlag(); const [areItemsHidden, hideItems] = useFlag();
const [isReady, markIsReady, unmarkIsReady] = useFlag(); const [isReady, markIsReady, unmarkIsReady] = useFlag();
const { isMobile, isDesktop } = useAppLayout(); const { isMobile, isDesktop } = useAppLayout();
const seenByDatesCount = useMemo(() => (seenByDates ? Object.keys(seenByDates).length : 0), [seenByDates]);
const handleAfterCopy = useCallback(() => { const handleAfterCopy = useCallback(() => {
showNotification({ showNotification({
@ -384,21 +386,21 @@ const MessageContextMenu: FC<OwnProps> = ({
className="MessageContextMenu--seen-by" className="MessageContextMenu--seen-by"
icon={canShowReactionsCount ? 'heart-outline' : 'group'} icon={canShowReactionsCount ? 'heart-outline' : 'group'}
onClick={canShowReactionsCount ? onShowReactors : onShowSeenBy} onClick={canShowReactionsCount ? onShowReactors : onShowSeenBy}
disabled={!canShowReactionsCount && !message.seenByUserIds?.length} disabled={!canShowReactionsCount && !seenByDatesCount}
> >
<span className="MessageContextMenu--seen-by-label"> <span className="MessageContextMenu--seen-by-label">
{canShowReactionsCount && message.reactors?.count ? ( {canShowReactionsCount && message.reactors?.count ? (
canShowSeenBy && message.seenByUserIds?.length canShowSeenBy && seenByDatesCount
? lang( ? lang(
'Chat.OutgoingContextMixedReactionCount', 'Chat.OutgoingContextMixedReactionCount',
[message.reactors.count, message.seenByUserIds.length], [message.reactors.count, seenByDatesCount],
) )
: lang('Chat.ContextReactionCount', message.reactors.count, 'i') : lang('Chat.ContextReactionCount', message.reactors.count, 'i')
) : ( ) : (
message.seenByUserIds?.length === 1 && seenByRecentUsers seenByDatesCount === 1 && seenByRecentUsers
? renderText(getUserFullName(seenByRecentUsers[0])!) ? renderText(getUserFullName(seenByRecentUsers[0])!)
: (message.seenByUserIds?.length : (seenByDatesCount
? lang('Conversation.ContextMenuSeen', message.seenByUserIds.length, 'i') ? lang('Conversation.ContextMenuSeen', seenByDatesCount, 'i')
: lang('Conversation.ContextMenuNoViews') : lang('Conversation.ContextMenuNoViews')
) )
)} )}

View File

@ -234,6 +234,11 @@
} }
} }
.status-icon {
vertical-align: text-bottom;
margin-inline-end: 0.125rem;
}
.contact-phone, .contact-phone,
.contact-username { .contact-username {
font-size: 0.875rem; font-size: 0.875rem;

View File

@ -1222,7 +1222,7 @@ addActionHandler('loadSeenBy', async (global, actions, payload): Promise<void> =
global = getGlobal(); global = getGlobal();
global = updateChatMessage(global, chatId, messageId, { global = updateChatMessage(global, chatId, messageId, {
seenByUserIds: result, seenByDates: result,
}); });
setGlobal(global); setGlobal(global);
}); });

View File

@ -80,6 +80,7 @@ export function addMessageReaction<T extends GlobalState>(
recentReactions.unshift({ recentReactions.unshift({
userId: currentUserId!, userId: currentUserId!,
reaction, reaction,
addedDate: Math.floor(Date.now() / 1000),
}); });
}); });

View File

@ -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 { function isValidDate(day: number, month: number, year = 2021): boolean {
if (month > (MAX_MONTH_IN_YEAR - 1) || day > MAX_DAY_IN_MONTH) { if (month > (MAX_MONTH_IN_YEAR - 1) || day > MAX_DAY_IN_MONTH) {
return false; return false;