Seen By Users: Read Time in Small Groups (#3148)
This commit is contained in:
parent
46c6f8fb9b
commit
c8d64dc929
@ -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,
|
||||
};
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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')
|
||||
)
|
||||
)}
|
||||
|
||||
@ -234,6 +234,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
vertical-align: text-bottom;
|
||||
margin-inline-end: 0.125rem;
|
||||
}
|
||||
|
||||
.contact-phone,
|
||||
.contact-username {
|
||||
font-size: 0.875rem;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -80,6 +80,7 @@ export function addMessageReaction<T extends GlobalState>(
|
||||
recentReactions.unshift({
|
||||
userId: currentUserId!,
|
||||
reaction,
|
||||
addedDate: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user