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 {
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,
};

View File

@ -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<string, number>)
: undefined;
}
export async function fetchSendAs({

View File

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

View File

@ -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<OwnProps & StateProps> = ({
typingStatus,
avatarSize = 'medium',
status,
statusIcon,
withDots,
withMediaViewer,
withUsername,
@ -109,7 +111,10 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
return withDots ? (
<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 } 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<string, number>;
};
const CLOSE_ANIMATION_DURATION = 100;
const SeenByModal: FC<OwnProps & StateProps> = ({
function SeenByModal({
isOpen,
memberIds,
}) => {
seenByDates,
}: OwnProps & StateProps) {
const {
openChat,
closeSeenByModal,
@ -32,6 +32,18 @@ const SeenByModal: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
closeSeenByModal();
}, [closeSeenByModal]);
const renderingMemberIds = useCurrentOrPrev(memberIds, true);
return (
<Modal
isOpen={isOpen}
@ -54,14 +64,19 @@ const SeenByModal: FC<OwnProps & StateProps> = ({
title={`Seen by ${memberIds?.length} users`}
>
<div dir={lang.isRtl ? 'rtl' : undefined}>
{renderingMemberIds && renderingMemberIds.map((userId) => (
{memberIds && memberIds.map((userId) => (
<ListItem
key={userId}
className="chat-item-clickable scroll-item small-icon"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleClick(userId)}
>
<PrivateChatInfo userId={userId} noStatusOrTyping />
<PrivateChatInfo
userId={userId}
noStatusOrTyping
status={formatDateAtTime(lang, renderingSeenByDates![userId] * 1000)}
statusIcon="icon-message-read"
/>
</ListItem>
))}
</div>
@ -76,7 +91,7 @@ const SeenByModal: FC<OwnProps & StateProps> = ({
</div>
</Modal>
);
};
}
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
@ -86,7 +101,7 @@ export default memo(withGlobal<OwnProps>(
}
return {
memberIds: selectChatMessage(global, chatId, messageId)?.seenByUserIds,
seenByDates: selectChatMessage(global, chatId, messageId)?.seenByDates,
};
},
)(SeenByModal));

View File

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

View File

@ -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<ApiMessage, 'reactors' | 'reactions' | 'seenByUserIds'> & {
export type StateProps = Pick<ApiMessage, 'reactors' | 'reactions' | 'seenByDates'> & {
chatId?: string;
messageId?: number;
availableReactions?: ApiAvailableReaction[];
@ -49,7 +51,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
reactions,
chatId,
messageId,
seenByUserIds,
seenByDates,
availableReactions,
}) => {
const {
@ -118,8 +120,11 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
.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<OwnProps & StateProps> = ({
onCloseAnimationEnd={handleCloseAnimationEnd}
>
{canShowFilters && (
<div className="Reactions">
<div className="Reactions" dir={lang.isRtl ? 'rtl' : undefined}>
<Button
className={buildClassName(!chosenTab && 'chosen')}
size="tiny"
@ -173,7 +178,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
</div>
)}
<div dir={lang.isRtl ? 'rtl' : undefined}>
<div dir={lang.isRtl ? 'rtl' : undefined} className="reactor-list-wrapper">
{viewportIds?.length ? (
<InfiniteScroll
className="reactor-list custom-scroll"
@ -185,8 +190,11 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
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(
<ListItem
key={`${userId}-${getReactionUniqueKey(r.reaction)}`}
@ -195,7 +203,13 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
onClick={() => handleClick(userId)}
>
<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 && (
<ReactionStaticEmoji
className="reactors-list-emoji"
@ -206,6 +220,24 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
</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;
},
)}
@ -233,7 +265,7 @@ export default memo(withGlobal<OwnProps>(
messageId,
reactions: message?.reactions,
reactors: message?.reactors,
seenByUserIds: message?.seenByUserIds,
seenByDates: message?.seenByDates,
availableReactions: global.availableReactions,
};
},

View File

@ -240,14 +240,14 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
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);

View File

@ -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<OwnProps> = ({
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<OwnProps> = ({
className="MessageContextMenu--seen-by"
icon={canShowReactionsCount ? 'heart-outline' : 'group'}
onClick={canShowReactionsCount ? onShowReactors : onShowSeenBy}
disabled={!canShowReactionsCount && !message.seenByUserIds?.length}
disabled={!canShowReactionsCount && !seenByDatesCount}
>
<span className="MessageContextMenu--seen-by-label">
{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')
)
)}

View File

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

View File

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

View File

@ -80,6 +80,7 @@ export function addMessageReaction<T extends GlobalState>(
recentReactions.unshift({
userId: currentUserId!,
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 {
if (month > (MAX_MONTH_IN_YEAR - 1) || day > MAX_DAY_IN_MONTH) {
return false;