Message Context Menu: Support "Seen By" (#1564)

This commit is contained in:
Alexander Zinchuk 2021-12-10 18:32:56 +01:00
parent e81dd51872
commit 559c1c80dd
17 changed files with 256 additions and 10 deletions

View File

@ -22,7 +22,7 @@ export {
markMessageListRead, markMessagesRead, requestThreadInfoUpdate, searchMessagesLocal, searchMessagesGlobal,
fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate,
fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages,
reportMessages,
reportMessages, fetchSeenBy,
} from './messages';
export {

View File

@ -1172,3 +1172,12 @@ export async function fetchPinnedMessages({ chat }: { chat: ApiChat }) {
chats,
};
}
export async function fetchSeenBy({ chat, messageId }: { chat: ApiChat; messageId: number }) {
const result = await invokeRequest(new GramJs.messages.GetMessageReadParticipants({
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId,
}));
return result ? result.map(String) : undefined;
}

View File

@ -262,6 +262,7 @@ export interface ApiMessage {
isScheduled?: boolean;
shouldHideKeyboardButtons?: boolean;
isFromScheduled?: boolean;
seenByUserIds?: string[];
}
export interface ApiThreadInfo {

View File

@ -11,6 +11,7 @@ export { default as DeleteMessageModal } from '../components/common/DeleteMessag
export { default as PinMessageModal } from '../components/common/PinMessageModal';
export { default as UnpinAllMessagesModal } from '../components/common/UnpinAllMessagesModal';
export { default as MessageSelectToolbar } from '../components/middle/MessageSelectToolbar';
export { default as SeenByModal } from '../components/common/SeenByModal';
export { default as LeftSearch } from '../components/left/search/LeftSearch';
export { default as Settings } from '../components/left/settings/Settings';

View File

@ -0,0 +1,15 @@
import React, { FC, memo } from '../../lib/teact/teact';
import { OwnProps } from './SeenByModal';
import { Bundles } from '../../util/moduleLoader';
import useModuleLoader from '../../hooks/useModuleLoader';
const SeenByModalAsync: FC<OwnProps> = (props) => {
const { isOpen } = props;
const SeenByModal = useModuleLoader(Bundles.Extra, 'SeenByModal', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return SeenByModal ? <SeenByModal {...props} /> : undefined;
};
export default memo(SeenByModalAsync);

View File

@ -0,0 +1,87 @@
import React, { FC, useCallback, memo } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types';
import { pick } from '../../util/iteratees';
import useLang from '../../hooks/useLang';
import { selectChatMessage } from '../../modules/selectors';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import PrivateChatInfo from './PrivateChatInfo';
import ListItem from '../ui/ListItem';
export type OwnProps = {
isOpen: boolean;
};
export type StateProps = {
memberIds?: string[];
};
type DispatchProps = Pick<GlobalActions, 'openChat' | 'closeSeenByModal'>;
const CLOSE_ANIMATION_DURATION = 100;
const SeenByModal: FC<OwnProps & StateProps & DispatchProps> = ({
isOpen,
memberIds,
openChat,
closeSeenByModal,
}) => {
const lang = useLang();
const handleClick = useCallback((userId: string) => {
closeSeenByModal();
setTimeout(() => {
openChat({ id: userId });
}, CLOSE_ANIMATION_DURATION);
}, [closeSeenByModal, openChat]);
const renderingMemberIds = useCurrentOrPrev(memberIds, true);
return (
<Modal
isOpen={isOpen}
onClose={closeSeenByModal}
className="narrow"
title="Which users read the message"
>
<div dir={lang.isRtl ? 'rtl' : undefined}>
{renderingMemberIds && renderingMemberIds.map((userId) => (
<ListItem
key={userId}
className="chat-item-clickable scroll-item small-icon"
onClick={() => handleClick(userId)}
>
<PrivateChatInfo userId={userId} />
</ListItem>
))}
</div>
<Button
className="confirm-dialog-button"
isText
onClick={closeSeenByModal}
>
{lang('Close')}
</Button>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { chatId, messageId } = global.seenByModal || {};
if (!chatId || !messageId) {
return {};
}
return {
memberIds: selectChatMessage(global, chatId, messageId)?.seenByUserIds,
};
},
(setGlobal, actions): DispatchProps => pick(actions, ['openChat', 'closeSeenByModal']),
)(SeenByModal));

View File

@ -64,6 +64,7 @@ import MessageSelectToolbar from './MessageSelectToolbar.async';
import UnpinAllMessagesModal from '../common/UnpinAllMessagesModal.async';
import PaymentModal from '../payment/PaymentModal.async';
import ReceiptModal from '../payment/ReceiptModal.async';
import SeenByModal from '../common/SeenByModal.async';
import './MiddleColumn.scss';
@ -90,6 +91,7 @@ type StateProps = {
isSelectModeActive?: boolean;
isPaymentModalOpen?: boolean;
isReceiptModalOpen?: boolean;
isSeenByModalOpen: boolean;
animationLevel?: number;
shouldSkipHistoryAnimations?: boolean;
currentTransitionKey: number;
@ -134,6 +136,7 @@ const MiddleColumn: FC<StateProps & DispatchProps> = ({
isSelectModeActive,
isPaymentModalOpen,
isReceiptModalOpen,
isSeenByModalOpen,
animationLevel,
shouldSkipHistoryAnimations,
currentTransitionKey,
@ -469,6 +472,7 @@ const MiddleColumn: FC<StateProps & DispatchProps> = ({
isOpen={Boolean(isReceiptModalOpen)}
onClose={clearReceipt}
/>
<SeenByModal isOpen={isSeenByModalOpen} />
</div>
</>
)}
@ -519,6 +523,7 @@ export default memo(withGlobal(
isSelectModeActive: selectIsInSelectMode(global),
isPaymentModalOpen: global.payment.isPaymentModalOpen,
isReceiptModalOpen: Boolean(global.payment.receipt),
isSeenByModalOpen: Boolean(global.seenByModal),
animationLevel: global.settings.byKey.animationLevel,
currentTransitionKey: Math.max(0, global.messages.messageLists.length - 1),
};

View File

@ -1,7 +1,7 @@
import React, {
FC, memo, useCallback, useMemo, useState,
FC, memo, useCallback, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import { getGlobal, withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions, MessageListType } from '../../../global/types';
import { ApiMessage } from '../../../api/types';
@ -9,9 +9,14 @@ import { IAlbum, IAnchorPosition } from '../../../types';
import {
selectActiveDownloadIds,
selectAllowedMessageActions,
selectChat,
selectCurrentMessageList,
} from '../../../modules/selectors';
import { isChatGroup, isOwnMessage } from '../../../modules/helpers';
import { SEEN_BY_MEMBERS_EXPIRE, SEEN_BY_MEMBERS_CHAT_MAX } from '../../../config';
import { pick } from '../../../util/iteratees';
import { getDayStartAt } from '../../../util/dateFormat';
import { copyTextToClipboard } from '../../../util/clipboard';
import useShowTransition from '../../../hooks/useShowTransition';
import useFlag from '../../../hooks/useFlag';
@ -20,8 +25,6 @@ import ReportMessageModal from '../../common/ReportMessageModal';
import PinMessageModal from '../../common/PinMessageModal';
import MessageContextMenu from './MessageContextMenu';
import CalendarModal from '../../common/CalendarModal';
import { getDayStartAt } from '../../../util/dateFormat';
import { copyTextToClipboard } from '../../../util/clipboard';
export type OwnProps = {
isOpen: boolean;
@ -52,12 +55,14 @@ type StateProps = {
canSelect?: boolean;
canDownload?: boolean;
activeDownloads: number[];
canShowSeenBy?: boolean;
};
type DispatchProps = Pick<GlobalActions, (
'setReplyingToId' | 'setEditingId' | 'pinMessage' | 'openForwardMenu' |
'faveSticker' | 'unfaveSticker' | 'toggleMessageSelection' | 'sendScheduledMessages' | 'rescheduleMessage' |
'downloadMessageMedia' | 'cancelMessageMediaDownload'
'downloadMessageMedia' | 'cancelMessageMediaDownload' | 'loadSeenBy' |
'openSeenByModal'
)>;
const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
@ -86,6 +91,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
canSelect,
canDownload,
activeDownloads,
canShowSeenBy,
setReplyingToId,
setEditingId,
pinMessage,
@ -97,6 +103,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
rescheduleMessage,
downloadMessageMedia,
cancelMessageMediaDownload,
loadSeenBy,
openSeenByModal,
}) => {
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
const [isMenuOpen, setIsMenuOpen] = useState(true);
@ -105,6 +113,22 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
const [isCalendarOpen, openCalendar, closeCalendar] = useFlag();
useEffect(() => {
if (canShowSeenBy && isOpen) {
loadSeenBy({ chatId: message.chatId, messageId: message.id });
}
}, [loadSeenBy, isOpen, message.chatId, message.id, canShowSeenBy]);
const seenByRecentUsers = useMemo(() => {
if (!message.seenByUserIds) {
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.seenByUserIds]);
const isDownloading = album ? album.messages.some((msg) => activeDownloads.includes(msg.id))
: activeDownloads.includes(message.id);
@ -206,6 +230,11 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
openCalendar();
}, [openCalendar]);
const handleOpenSeenByModal = useCallback(() => {
closeMenu();
openSeenByModal({ chatId: message.chatId, messageId: message.id });
}, [closeMenu, message.chatId, message.id, openSeenByModal]);
const handleRescheduleMessage = useCallback((date: Date) => {
rescheduleMessage({
chatId: message.chatId,
@ -262,7 +291,9 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
canCopyLink={canCopyLink}
canSelect={canSelect}
canDownload={canDownload}
canShowSeenBy={canShowSeenBy}
isDownloading={isDownloading}
seenByRecentUsers={seenByRecentUsers}
onReply={handleReply}
onEdit={handleEdit}
onPin={handlePin}
@ -278,6 +309,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
onClose={closeMenu}
onCopyLink={handleCopyLink}
onDownload={handleDownloadClick}
onShowSeenBy={handleOpenSeenByModal}
/>
<DeleteMessageModal
isOpen={isDeleteModalOpen}
@ -314,6 +346,7 @@ export default memo(withGlobal<OwnProps>(
(global, { message, messageListType }): StateProps => {
const { threadId } = selectCurrentMessageList(global) || {};
const activeDownloads = selectActiveDownloadIds(global, message.chatId);
const chat = selectChat(global, message.chatId);
const {
noOptions,
canReply,
@ -332,6 +365,12 @@ export default memo(withGlobal<OwnProps>(
} = (threadId && selectAllowedMessageActions(global, message, threadId)) || {};
const isPinned = messageListType === 'pinned';
const isScheduled = messageListType === 'scheduled';
const canShowSeenBy = Boolean(chat
&& isChatGroup(chat)
&& isOwnMessage(message)
&& chat.membersCount
&& chat.membersCount < SEEN_BY_MEMBERS_CHAT_MAX
&& message.date > Date.now() / 1000 - SEEN_BY_MEMBERS_EXPIRE);
return {
noOptions,
@ -351,6 +390,7 @@ export default memo(withGlobal<OwnProps>(
canSelect,
canDownload,
activeDownloads,
canShowSeenBy,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [
@ -365,5 +405,7 @@ export default memo(withGlobal<OwnProps>(
'rescheduleMessage',
'downloadMessageMedia',
'cancelMessageMediaDownload',
'loadSeenBy',
'openSeenByModal',
]),
)(ContextMenuContainer));

View File

@ -12,4 +12,21 @@
.backdrop {
touch-action: none;
}
.avatars {
display: flex;
align-self: center;
margin-left: auto;
padding-left: 1rem;
.Avatar {
border: .0625rem solid var(--color-background);
margin-right: 0;
box-sizing: content-box;
&:not(:first-child) {
margin-left: -0.1875rem;
}
}
}
}

View File

@ -1,8 +1,8 @@
import React, {
FC, useCallback, useEffect, useRef,
FC, memo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact';
import { ApiMessage } from '../../../api/types';
import { ApiMessage, ApiUser } from '../../../api/types';
import { IAnchorPosition } from '../../../types';
import { getMessageCopyOptions } from './helpers/copyOptions';
@ -12,6 +12,7 @@ import useLang from '../../../hooks/useLang';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import Avatar from '../../common/Avatar';
import './MessageContextMenu.scss';
@ -35,6 +36,8 @@ type OwnProps = {
canSelect?: boolean;
canDownload?: boolean;
isDownloading?: boolean;
canShowSeenBy?: boolean;
seenByRecentUsers?: ApiUser[];
onReply: () => void;
onEdit: () => void;
onPin: () => void;
@ -51,6 +54,7 @@ type OwnProps = {
onCloseAnimationEnd?: () => void;
onCopyLink?: () => void;
onDownload?: () => void;
onShowSeenBy?: () => void;
};
const SCROLLBAR_WIDTH = 10;
@ -75,6 +79,8 @@ const MessageContextMenu: FC<OwnProps> = ({
canSelect,
canDownload,
isDownloading,
canShowSeenBy,
seenByRecentUsers,
onReply,
onEdit,
onPin,
@ -91,6 +97,7 @@ const MessageContextMenu: FC<OwnProps> = ({
onCloseAnimationEnd,
onCopyLink,
onDownload,
onShowSeenBy,
}) => {
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
@ -166,9 +173,24 @@ const MessageContextMenu: FC<OwnProps> = ({
{canForward && <MenuItem icon="forward" onClick={onForward}>{lang('Forward')}</MenuItem>}
{canSelect && <MenuItem icon="select" onClick={onSelect}>{lang('Common.Select')}</MenuItem>}
{canReport && <MenuItem icon="flag" onClick={onReport}>{lang('lng_context_report_msg')}</MenuItem>}
{canShowSeenBy && (
<MenuItem icon="group" onClick={onShowSeenBy} disabled={!message.seenByUserIds?.length}>
{message.seenByUserIds?.length
? lang('Conversation.ContextMenuSeen', message.seenByUserIds.length, 'i')
: lang('Conversation.ContextMenuNoViews')}
<div className="avatars">
{seenByRecentUsers?.map((user) => (
<Avatar
size="micro"
user={user}
/>
))}
</div>
</MenuItem>
)}
{canDelete && <MenuItem destructive icon="delete" onClick={onDelete}>{lang('Delete')}</MenuItem>}
</Menu>
);
};
export default MessageContextMenu;
export default memo(MessageContextMenu);

View File

@ -124,6 +124,7 @@
flex-grow: 1;
padding: 1rem;
overflow-y: auto;
max-height: 90vh;
b,
strong {

View File

@ -171,6 +171,9 @@ export const LIGHT_THEME_BG_COLOR = '#A2AF8E';
export const DARK_THEME_BG_COLOR = '#0F0F0F';
export const DARK_THEME_PATTERN_COLOR = '#0a0a0a8c';
export const DEFAULT_PATTERN_COLOR = 'rgba(90, 110, 70, 0.6)';
// TODO Get values from `getConfig` method once it's available
export const SEEN_BY_MEMBERS_CHAT_MAX = 50;
export const SEEN_BY_MEMBERS_EXPIRE = 604680; // One week - 2 min
// Group calls
export const GROUP_CALL_VOLUME_MULTIPLIER = 100;

View File

@ -205,6 +205,11 @@ export type GlobalState = {
messageIds: number[];
};
seenByModal?: {
chatId: string;
messageId: number;
};
fileUploads: {
byMessageLocalId: Record<string, {
progress: number;
@ -476,6 +481,7 @@ export type ActionTypes = (
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' |
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' |
'openSeenByModal' | 'closeSeenByModal' |
// auth
'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' |
'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' |
@ -496,7 +502,7 @@ export type ActionTypes = (
'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' |
'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' |
'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' |
'reportMessages' | 'focusNextReply' | 'openChatByInvite' |
'reportMessages' | 'focusNextReply' | 'openChatByInvite' | 'loadSeenBy' |
// downloads
'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' |
// scheduled messages

View File

@ -1054,6 +1054,7 @@ messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.Disc
messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool;
messages.unpinAllMessages#f025bc8b peer:InputPeer = messages.AffectedHistory;
messages.deleteChat#5bd0ee50 chat_id:long = Bool;
messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector<long>;
updates.getState#edd4882a = updates.State;
updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;

View File

@ -1055,6 +1055,7 @@ messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.Disc
messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool;
messages.unpinAllMessages#f025bc8b peer:InputPeer = messages.AffectedHistory;
messages.deleteChat#5bd0ee50 chat_id:long = Bool;
messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector<long>;
updates.getState#edd4882a = updates.State;
updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;

View File

@ -932,6 +932,25 @@ addReducer('loadPinnedMessages', (global, actions, payload) => {
void loadPinnedMessages(chat);
});
addReducer('loadSeenBy', (global, actions, payload) => {
const { chatId, messageId } = payload;
const chat = selectChat(global, chatId);
if (!chat) {
return;
}
(async () => {
const result = await callApi('fetchSeenBy', { chat, messageId });
if (!result) {
return;
}
setGlobal(updateChatMessage(getGlobal(), chatId, messageId, {
seenByUserIds: result,
}));
})();
});
async function loadPinnedMessages(chat: ApiChat) {
const result = await callApi('fetchPinnedMessages', { chat });
if (!result) {

View File

@ -641,3 +641,19 @@ addReducer('createServiceNotification', (global, actions, payload) => {
message,
});
});
addReducer('openSeenByModal', (global, actions, payload) => {
const { chatId, messageId } = payload!;
return {
...global,
seenByModal: { chatId, messageId },
};
});
addReducer('closeSeenByModal', (global) => {
return {
...global,
seenByModal: undefined,
};
});