MessageContextMenu: Read time in private chats (#4218)

This commit is contained in:
Alexander Zinchuk 2024-02-23 14:05:43 +01:00
parent 6146cf3a1b
commit 64799a8818
40 changed files with 657 additions and 54 deletions

View File

@ -45,6 +45,7 @@ export interface GramJsAppConfig extends LimitsConfig {
reactions_uniq_max: number;
chat_read_mark_size_threshold: number;
chat_read_mark_expire_period: number;
pm_read_date_expire_period: number;
reactions_user_max_default: number;
reactions_user_max_premium: number;
autologin_domains: string[];
@ -98,6 +99,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
emojiSounds: buildEmojiSounds(appConfig),
seenByMaxChatMembers: appConfig.chat_read_mark_size_threshold,
seenByExpiresAt: appConfig.chat_read_mark_expire_period,
readDateExpiresAt: appConfig.pm_read_date_expire_period,
autologinDomains: appConfig.autologin_domains || [],
urlAuthDomains: appConfig.url_auth_domains || [],
maxUniqueReactions: appConfig.reactions_uniq_max,

View File

@ -108,11 +108,11 @@ export function buildApiUserStatus(mtpStatus?: GramJs.TypeUserStatus): ApiUserSt
} else if (mtpStatus instanceof GramJs.UserStatusOffline) {
return { type: 'userStatusOffline', wasOnline: mtpStatus.wasOnline };
} else if (mtpStatus instanceof GramJs.UserStatusRecently) {
return { type: 'userStatusRecently' };
return { type: 'userStatusRecently', isReadDateRestrictedByMe: mtpStatus.byMe };
} else if (mtpStatus instanceof GramJs.UserStatusLastWeek) {
return { type: 'userStatusLastWeek' };
return { type: 'userStatusLastWeek', isReadDateRestrictedByMe: mtpStatus.byMe };
} else {
return { type: 'userStatusLastMonth' };
return { type: 'userStatusLastMonth', isReadDateRestrictedByMe: mtpStatus.byMe };
}
}

View File

@ -35,6 +35,7 @@ export {
reportMessages, sendMessageAction, fetchSeenBy, fetchSponsoredMessages, viewSponsoredMessage, fetchSendAs,
saveDefaultSendAs, fetchUnreadReactions, readAllReactions, fetchUnreadMentions, readAllMentions, transcribeAudio,
closePoll, fetchExtendedMedia, translateText, fetchMessageViews, fetchDiscussionMessage, clickSponsoredMessage,
fetchOutboxReadDate,
deleteSavedHistory,
} from './messages';

View File

@ -1868,3 +1868,17 @@ function handleLocalMessageUpdate(localMessage: ApiMessage, update: GramJs.TypeU
handleGramJsUpdate(update);
}
export async function fetchOutboxReadDate({ chat, messageId }: { chat: ApiChat; messageId: number }) {
const { id, accessHash } = chat;
const peer = buildInputPeer(id, accessHash);
const result = await invokeRequest(new GramJs.messages.GetOutboxReadDate({
peer: peer as GramJs.TypeInputPeer,
msgId: messageId,
}), { shouldThrow: true });
if (!result) return undefined;
return { date: result.date };
}

View File

@ -614,15 +614,18 @@ export async function fetchGlobalPrivacySettings() {
return {
shouldArchiveAndMuteNewNonContact: Boolean(result.archiveAndMuteNewNoncontactPeers),
shouldHideReadMarks: Boolean(result.hideReadMarks),
};
}
export async function updateGlobalPrivacySettings({ shouldArchiveAndMuteNewNonContact }: {
export async function updateGlobalPrivacySettings({ shouldArchiveAndMuteNewNonContact, shouldHideReadMarks }: {
shouldArchiveAndMuteNewNonContact: boolean;
shouldHideReadMarks: boolean;
}) {
const result = await invokeRequest(new GramJs.account.SetGlobalPrivacySettings({
settings: new GramJs.GlobalPrivacySettings({
...(shouldArchiveAndMuteNewNonContact && { archiveAndMuteNewNoncontactPeers: true }),
...(shouldHideReadMarks && { hideReadMarks: true }),
}),
}));
@ -632,6 +635,7 @@ export async function updateGlobalPrivacySettings({ shouldArchiveAndMuteNewNonCo
return {
shouldArchiveAndMuteNewNonContact: Boolean(result.archiveAndMuteNewNoncontactPeers),
shouldHideReadMarks: Boolean(result.hideReadMarks),
};
}

View File

@ -536,6 +536,7 @@ export interface ApiMessage {
};
reactions?: ApiReactions;
hasComments?: boolean;
readDate?: number;
savedPeerId?: string;
}

View File

@ -178,6 +178,7 @@ export interface ApiAppConfig {
emojiSounds: Record<string, string>;
seenByMaxChatMembers: number;
seenByExpiresAt: number;
readDateExpiresAt: number;
autologinDomains: string[];
urlAuthDomains: string[];
premiumInvoiceSlug: string;

View File

@ -66,6 +66,8 @@ export interface ApiUserStatus {
);
wasOnline?: number;
expires?: number;
isReadDateRestrictedByMe?: boolean;
isReadDateRestricted?: boolean;
}
export interface ApiUsername {

BIN
src/assets/tgs/ReadTime.tgs Normal file

Binary file not shown.

View File

@ -30,6 +30,7 @@ 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 ReadTimeModal } from '../components/common/ReadDateModal';
export { default as ReactorListModal } from '../components/middle/ReactorListModal';
export { default as EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation';
export { default as ChatLanguageModal } from '../components/middle/ChatLanguageModal';

View File

@ -0,0 +1,43 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.icon {
border-radius: 50%;
background-color: var(--color-primary);
margin-bottom: 0.25rem;
}
.header {
margin: 0.5rem;
font-size: 1.25rem;
}
.desc {
font-size: 0.9375rem;
text-align: center;
@media (min-width: 600px) {
margin-left: 0.75rem;
margin-right: 0.75rem;
}
}
.separator {
margin-top: 1.25rem;
margin-bottom: 0.25rem;
width: 80%;
}
.button {
text-transform: none;
border-radius: var(--border-radius-default-tiny);
}
.closeButton {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}

View File

@ -0,0 +1,106 @@
import React, { memo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiUser } from '../../api/types';
import { ANIMATION_END_DELAY } from '../../config';
import { getUserFirstOrLastName } from '../../global/helpers';
import { selectTabState, selectUser } from '../../global/selectors';
import { LOCAL_TGS_URLS } from './helpers/animatedAssets';
import renderText from './helpers/renderText';
import useLang from '../../hooks/useLang';
import Button from '../ui/Button';
import Modal, { ANIMATION_DURATION } from '../ui/Modal';
import Separator from '../ui/Separator';
import AnimatedIconWithPreview from './AnimatedIconWithPreview';
import Icon from './Icon';
import styles from './ReadDateModal.module.scss';
export type OwnProps = {
isOpen: boolean;
};
type StateProps = {
user?: ApiUser;
};
const CLOSE_ANIMATION_DURATION = ANIMATION_DURATION + ANIMATION_END_DELAY;
const ReadDateModal = ({ isOpen, user }: OwnProps & StateProps) => {
const lang = useLang();
const {
updateGlobalPrivacySettings, openPremiumModal, closeGetReadDateModal, showNotification,
} = getActions();
const userName = getUserFirstOrLastName(user);
const handleShowReadTime = () => {
updateGlobalPrivacySettings({ shouldHideReadMarks: false });
closeGetReadDateModal();
setTimeout(() => {
showNotification({ message: lang('PremiumReadSet') });
}, CLOSE_ANIMATION_DURATION);
};
const handleOpenPremium = () => {
closeGetReadDateModal();
setTimeout(() => {
openPremiumModal();
}, CLOSE_ANIMATION_DURATION);
};
return (
<Modal isSlim isOpen={isOpen} onClose={closeGetReadDateModal}>
<div className={styles.container} dir={lang.isRtl ? 'rtl' : undefined}>
<Button
className={styles.closeButton}
color="translucent"
round
size="smaller"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => closeGetReadDateModal()}
ariaLabel="Close"
>
<Icon name="close" />
</Button>
<AnimatedIconWithPreview
tgsUrl={LOCAL_TGS_URLS.ReadTime}
size={84}
className={styles.icon}
nonInteractive
noLoop
/>
<h2 className={styles.header}>{lang('PremiumReadHeader1')}</h2>
<p className={styles.desc}>{renderText(lang('PremiumReadText1', userName), ['simple_markdown'])}</p>
<Button
size="smaller"
// eslint-disable-next-line react/jsx-no-bind
onClick={handleShowReadTime}
className={styles.button}
>
{lang('PremiumReadButton1')}
</Button>
<Separator className={styles.separator}>{lang('PremiumOr')}</Separator>
<h2 className={styles.header}>{lang('PremiumReadHeader2')}</h2>
<p className={styles.desc}>{renderText(lang('PremiumReadText2', userName), ['simple_markdown'])}</p>
{/* eslint-disable-next-line react/jsx-no-bind */}
<Button withPremiumGradient size="smaller" onClick={handleOpenPremium} className={styles.button}>
{lang('PremiumLastSeenButton2')}
</Button>
</div>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { chatId } = selectTabState(global).readDateModal || {};
const user = chatId ? selectUser(global, chatId) : undefined;
return { user };
},
)(ReadDateModal));

View File

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

View File

@ -15,6 +15,7 @@ import MonkeyClose from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyClose.t
import MonkeyIdle from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyIdle.tgs';
import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs';
import MonkeyTracking from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyTracking.tgs';
import ReadTime from '../../../assets/tgs/ReadTime.tgs';
import Congratulations from '../../../assets/tgs/settings/Congratulations.tgs';
import DiscussionGroups from '../../../assets/tgs/settings/DiscussionGroupsDucks.tgs';
import Experimental from '../../../assets/tgs/settings/Experimental.tgs';
@ -48,4 +49,5 @@ export const LOCAL_TGS_URLS = {
Experimental,
PartyPopper,
Flame,
ReadTime,
};

View File

@ -0,0 +1,3 @@
:global(.settings-item-description-larger).premiumInfo {
margin-top: 1rem;
}

View File

@ -0,0 +1,84 @@
import React, { memo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { PrivacyVisibility } from '../../../types';
import { selectIsCurrentUserPremium, selectShouldHideReadMarks } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import PremiumIcon from '../../common/PremiumIcon';
import Checkbox from '../../ui/Checkbox';
import ListItem from '../../ui/ListItem';
import styles from './SettingsPrivacyLastSeen.module.scss';
type OwnProps = {
visibility?: PrivacyVisibility;
};
type StateProps = {
isCurrentUserPremium: boolean;
shouldHideReadMarks: boolean;
};
const SettingsPrivacyLastSeen = ({
isCurrentUserPremium, shouldHideReadMarks, visibility,
}: OwnProps & StateProps) => {
const { updateGlobalPrivacySettings, openPremiumModal } = getActions();
const lang = useLang();
const canShowHideReadTime = visibility === 'nobody' || visibility === 'contacts';
const handleChangeShouldHideReadMarks = useLastCallback(
(isEnabled) => updateGlobalPrivacySettings({ shouldHideReadMarks: isEnabled }),
);
return (
<>
{canShowHideReadTime && (
<div className="settings-item">
<Checkbox
label={lang('HideReadTime')}
checked={shouldHideReadMarks}
onCheck={handleChangeShouldHideReadMarks}
/>
<p className="settings-item-description-larger" dir={lang.isRtl ? 'rtl' : undefined}>
{renderText(lang('HideReadTimeInfo'), ['br'])}
</p>
</div>
)}
<div className="settings-item">
<ListItem
leftElement={<PremiumIcon className="icon" withGradient big />}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openPremiumModal()}
>
{isCurrentUserPremium ? lang('PrivacyLastSeenPremiumForPremium') : lang('PrivacyLastSeenPremium')}
</ListItem>
<p
className={buildClassName(
'settings-item-description-larger',
styles.premiumInfo,
)}
dir={lang.isRtl ? 'rtl' : undefined}
>
{isCurrentUserPremium
? lang('PrivacyLastSeenPremiumInfoForPremium')
: lang('PrivacyLastSeenPremiumInfo')}
</p>
</div>
</>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
return {
isCurrentUserPremium: selectIsCurrentUserPremium(global),
shouldHideReadMarks: Boolean(selectShouldHideReadMarks(global)),
};
},
)(SettingsPrivacyLastSeen));

View File

@ -15,6 +15,7 @@ import useLastCallback from '../../../hooks/useLastCallback';
import ListItem from '../../ui/ListItem';
import RadioGroup from '../../ui/RadioGroup';
import SettingsPrivacyLastSeen from './SettingsPrivacyLastSeen';
import SettingsPrivacyPublicProfilePhoto from './SettingsPrivacyPublicProfilePhoto';
type OwnProps = {
@ -74,6 +75,9 @@ const SettingsPrivacyVisibility: FC<OwnProps & StateProps> = ({
currentUserFallbackPhoto={currentUserFallbackPhoto}
/>
)}
{screen === SettingsScreens.PrivacyLastSeen && (
<SettingsPrivacyLastSeen visibility={primaryPrivacy?.visibility} />
)}
{secondaryScreen && (
<PrivacySubsection
screen={secondaryScreen}

View File

@ -78,6 +78,7 @@ import useWindowSize from '../../hooks/window/useWindowSize';
import usePinnedMessage from './hooks/usePinnedMessage';
import Composer from '../common/Composer';
import ReadTimeModal from '../common/ReadTimeModal.async';
import SeenByModal from '../common/SeenByModal.async';
import UnpinAllMessagesModal from '../common/UnpinAllMessagesModal.async';
import GiftPremiumModal from '../main/premium/GiftPremiumModal.async';
@ -128,6 +129,7 @@ type StateProps = {
hasCurrentTextSearch?: boolean;
isSelectModeActive?: boolean;
isSeenByModalOpen: boolean;
isReadDateModalOpen: boolean;
isReactorListModalOpen: boolean;
isGiftPremiumModalOpen?: boolean;
isChatLanguageModalOpen?: boolean;
@ -186,6 +188,7 @@ function MiddleColumn({
hasCurrentTextSearch,
isSelectModeActive,
isSeenByModalOpen,
isReadDateModalOpen,
isReactorListModalOpen,
isGiftPremiumModalOpen,
isChatLanguageModalOpen,
@ -676,6 +679,7 @@ function MiddleColumn({
canPost={renderingCanPost}
/>
<SeenByModal isOpen={isSeenByModalOpen} />
<ReadTimeModal isOpen={isReadDateModalOpen} />
<ReactorListModal isOpen={isReactorListModalOpen} />
{IS_TRANSLATION_SUPPORTED && <ChatLanguageModal isOpen={isChatLanguageModalOpen} />}
</div>
@ -723,7 +727,7 @@ export default memo(withGlobal<OwnProps>(
const {
messageLists, isLeftColumnShown, activeEmojiInteractions,
seenByModal, giftPremiumModal, reactorModal, audioPlayer, shouldSkipHistoryAnimations,
chatLanguageModal,
chatLanguageModal, readDateModal,
} = selectTabState(global);
const currentMessageList = selectCurrentMessageList(global);
const { leftColumnWidth } = global;
@ -739,6 +743,7 @@ export default memo(withGlobal<OwnProps>(
hasCurrentTextSearch: Boolean(selectCurrentTextSearch(global)),
isSelectModeActive: selectIsInSelectMode(global),
isSeenByModalOpen: Boolean(seenByModal),
isReadDateModalOpen: Boolean(readDateModal),
isReactorListModalOpen: Boolean(reactorModal),
isGiftPremiumModalOpen: giftPremiumModal?.isOpen,
isChatLanguageModalOpen: Boolean(chatLanguageModal),

View File

@ -33,6 +33,7 @@ import {
selectCurrentMessageList,
selectIsCurrentUserPremium,
selectIsMessageProtected,
selectIsMessageUnread,
selectIsPremiumPurchaseBlocked,
selectIsReactionPickerOpen,
selectMessageCustomEmojiSets,
@ -41,6 +42,7 @@ import {
selectRequestedChatTranslationLanguage,
selectRequestedMessageTranslationLanguage,
selectStickerSet,
selectUserStatus,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { copyTextToClipboard } from '../../../util/clipboard';
@ -107,6 +109,8 @@ type StateProps = {
canSaveGif?: boolean;
canRevote?: boolean;
canClosePoll?: boolean;
canLoadReadDate?: boolean;
shouldRenderShowWhen?: boolean;
activeDownloads?: TabState['activeDownloads']['byChatId'][number];
canShowSeenBy?: boolean;
enabledReactions?: ApiChatReactions;
@ -159,6 +163,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canRevote,
canClosePoll,
canPlayAnimatedEmojis,
canLoadReadDate,
shouldRenderShowWhen,
activeDownloads,
noReplies,
canShowSeenBy,
@ -200,6 +206,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
showOriginalMessage,
openChatLanguageModal,
openMessageReactionPicker,
loadOutboxReadDate,
} = getActions();
const lang = useLang();
@ -221,6 +228,12 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
}
}, [loadSeenBy, isOpen, message.chatId, message.id, canShowSeenBy]);
useEffect(() => {
if (canLoadReadDate && isOpen) {
loadOutboxReadDate({ chatId: message.chatId, messageId: message.id });
}
}, [canLoadReadDate, isOpen, message.chatId, message.id, message.readDate]);
useEffect(() => {
if (canShowReactionsCount && isOpen) {
loadReactors({ chatId: message.chatId, messageId: message.id });
@ -554,6 +567,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canShowOriginal={canShowOriginal}
canSelectLanguage={canSelectLanguage}
canPlayAnimatedEmojis={canPlayAnimatedEmojis}
shouldRenderShowWhen={shouldRenderShowWhen}
canLoadReadDate={canLoadReadDate}
hasCustomEmoji={hasCustomEmoji}
customEmojiSets={customEmojiSets}
isDownloading={isDownloading}
@ -623,7 +638,9 @@ export default memo(withGlobal<OwnProps>(
const { threadId } = selectCurrentMessageList(global) || {};
const activeDownloads = selectActiveDownloads(global, message.chatId);
const chat = selectChat(global, message.chatId);
const { seenByExpiresAt, seenByMaxChatMembers, maxUniqueReactions } = global.appConfig || {};
const {
seenByExpiresAt, seenByMaxChatMembers, maxUniqueReactions, readDateExpiresAt,
} = global.appConfig || {};
const {
noOptions,
canReply,
@ -645,7 +662,20 @@ export default memo(withGlobal<OwnProps>(
} = (threadId && selectAllowedMessageActions(global, message, threadId)) || {};
const isPrivate = chat && isUserId(chat.id);
const userStatus = isPrivate ? selectUserStatus(global, chat.id) : undefined;
const isOwn = isOwnMessage(message);
const isMessageUnread = selectIsMessageUnread(global, message);
const canLoadReadDate = Boolean(
isPrivate
&& isOwn
&& !isMessageUnread
&& readDateExpiresAt
&& message.date > Date.now() / 1000 - readDateExpiresAt
&& !userStatus?.isReadDateRestricted,
);
const shouldRenderShowWhen = Boolean(
canLoadReadDate && isPrivate && selectUserStatus(global, chat.id)?.isReadDateRestrictedByMe,
);
const isPinned = messageListType === 'pinned';
const isScheduled = messageListType === 'scheduled';
const isChannel = chat && isChatChannel(chat);
@ -653,6 +683,7 @@ export default memo(withGlobal<OwnProps>(
const hasTtl = hasMessageTtl(message);
const canShowSeenBy = Boolean(!isLocal
&& chat
&& !isMessageUnread
&& seenByMaxChatMembers
&& seenByExpiresAt
&& isChatGroup(chat)
@ -706,6 +737,8 @@ export default memo(withGlobal<OwnProps>(
canClosePoll: !isScheduled && canClosePoll,
activeDownloads,
canShowSeenBy,
canLoadReadDate,
shouldRenderShowWhen,
enabledReactions: chat?.isForbidden ? undefined : chatFullInfo?.enabledReactions,
maxUniqueReactions,
isPrivate,

View File

@ -32,6 +32,7 @@ import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker';
import PickerSelectedItem from '../../common/PickerSelectedItem';
import Button from '../../ui/Button';
import ConfirmDialog from '../../ui/ConfirmDialog';
import Separator from '../../ui/Separator';
import styles from './Giveaway.module.scss';
@ -124,7 +125,7 @@ const Giveaway = ({
['simple_markdown'],
)}
</p>
<div className={styles.separator}>{lang('BoostingGiveawayMsgWithDivider')}</div>
<Separator>{lang('BoostingGiveawayMsgWithDivider')}</Separator>
</>
)}
<p className={styles.description}>

View File

@ -37,6 +37,7 @@ import MenuItem from '../../ui/MenuItem';
import MenuSeparator from '../../ui/MenuSeparator';
import Skeleton from '../../ui/placeholder/Skeleton';
import ReactionSelector from './ReactionSelector';
import ReadTimeMenuItem from './ReadTimeMenuItem';
import './MessageContextMenu.scss';
@ -86,6 +87,8 @@ type OwnProps = {
customEmojiSets?: ApiStickerSet[];
canPlayAnimatedEmojis?: boolean;
noTransition?: boolean;
shouldRenderShowWhen?: boolean;
canLoadReadDate?: boolean;
onReply?: NoneToVoidFunction;
onOpenThread?: VoidFunction;
onEdit?: NoneToVoidFunction;
@ -171,6 +174,8 @@ const MessageContextMenu: FC<OwnProps> = ({
customEmojiSets,
canPlayAnimatedEmojis,
noTransition,
shouldRenderShowWhen,
canLoadReadDate,
onReply,
onOpenThread,
onEdit,
@ -401,42 +406,10 @@ 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 || canShowReactionsCount) && !isSponsoredMessage && (
<MenuItem
icon={canShowReactionsCount ? 'heart-outline' : 'group'}
onClick={canShowReactionsCount ? onShowReactors : onShowSeenBy}
disabled={!canShowReactionsCount && !seenByDatesCount}
>
<span className="MessageContextMenu--seen-by-label-wrapper">
<span className="MessageContextMenu--seen-by-label" dir={lang.isRtl ? 'rtl' : undefined}>
{canShowReactionsCount && message.reactors?.count ? (
canShowSeenBy && seenByDatesCount
? lang(
'Chat.OutgoingContextMixedReactionCount',
[message.reactors.count, seenByDatesCount],
)
: lang('Chat.ContextReactionCount', message.reactors.count, 'i')
) : (
seenByDatesCount === 1 && seenByRecentPeers
? renderText(
isUserId(seenByRecentPeers[0].id)
? getUserFullName(seenByRecentPeers[0] as ApiUser)!
: (seenByRecentPeers[0] as ApiChat).title,
) : (
seenByDatesCount
? lang('Conversation.ContextMenuSeen', seenByDatesCount, 'i')
: lang('Conversation.ContextMenuNoViews')
)
)}
</span>
</span>
<AvatarList className="avatars" size="micro" peers={seenByRecentPeers} />
</MenuItem>
)}
{canDelete && <MenuItem destructive icon="delete" onClick={onDelete}>{lang('Delete')}</MenuItem>}
{hasCustomEmoji && (
<>
<MenuSeparator />
<MenuSeparator size="thick" />
{!customEmojiSets && (
<>
<Skeleton inline className="menu-loading-row" />
@ -462,6 +435,50 @@ const MessageContextMenu: FC<OwnProps> = ({
{isSponsoredMessage && onSponsoredHide && (
<MenuItem icon="stop" onClick={onSponsoredHide}>{lang('HideAd')}</MenuItem>
)}
{(canShowSeenBy || canShowReactionsCount) && !isSponsoredMessage && (
<>
<MenuSeparator size={hasCustomEmoji ? 'thin' : 'thick'} />
<MenuItem
icon={canShowReactionsCount ? 'heart-outline' : 'group'}
onClick={canShowReactionsCount ? onShowReactors : onShowSeenBy}
disabled={!canShowReactionsCount && !seenByDatesCount}
>
<span className="MessageContextMenu--seen-by-label-wrapper">
<span className="MessageContextMenu--seen-by-label" dir={lang.isRtl ? 'rtl' : undefined}>
{canShowReactionsCount && message.reactors?.count ? (
canShowSeenBy && seenByDatesCount
? lang(
'Chat.OutgoingContextMixedReactionCount',
[message.reactors.count, seenByDatesCount],
)
: lang('Chat.ContextReactionCount', message.reactors.count, 'i')
) : (
seenByDatesCount === 1 && seenByRecentPeers
? renderText(
isUserId(seenByRecentPeers[0].id)
? getUserFullName(seenByRecentPeers[0] as ApiUser)!
: (seenByRecentPeers[0] as ApiChat).title,
) : (
seenByDatesCount
? lang('Conversation.ContextMenuSeen', seenByDatesCount, 'i')
: lang('Conversation.ContextMenuNoViews')
)
)}
</span>
</span>
<AvatarList className="avatars" size="micro" peers={seenByRecentPeers} />
</MenuItem>
</>
)}
{!isSponsoredMessage && (canLoadReadDate || shouldRenderShowWhen) && (
<ReadTimeMenuItem
canLoadReadDate={canLoadReadDate}
shouldRenderShowWhen={shouldRenderShowWhen}
message={message}
menuSeparatorSize={hasCustomEmoji ? 'thin' : 'thick'}
closeContextMenu={onClose}
/>
)}
</div>
</Menu>
);

View File

@ -0,0 +1,44 @@
:global(.MenuItem).item {
margin-bottom: 0;
font-size: 0.8125rem;
cursor: var(--custom-cursor, default);
pointer-events: none;
--color-skeleton-background: #2121211a;
&:hover,
&:focus,
&:active {
background: none;
}
:global(.icon) {
margin-left: 0;
margin-right: 0.25rem;
}
&[dir="rtl"] {
:global(.icon) {
margin-left: 0.25rem;
margin-right: 0;
}
}
}
.get {
cursor: var(--custom-cursor, pointer);
margin-left: 0.375rem;
border-radius: 0.5rem;
padding: 0.125rem 0.375rem;
background: var(--color-background-menu-separator);
pointer-events: all;
}
.skeleton {
height: 0.5rem;
width: calc(100% - 2rem);
margin: 0.5rem 0;
border-radius: 0.25rem;
}
.transition {
height: 1.5rem;
}

View File

@ -0,0 +1,62 @@
import React, { memo } from '../../../lib/teact/teact';
import { getActions } from '../../../lib/teact/teactn';
import type { ApiMessage } from '../../../api/types';
import { formatDateAtTime } from '../../../util/dateFormat';
import useLang from '../../../hooks/useLang';
import MenuItem from '../../ui/MenuItem';
import MenuSeparator from '../../ui/MenuSeparator';
import Skeleton from '../../ui/placeholder/Skeleton';
import Transition from '../../ui/Transition';
import styles from './ReadTimeMenuItem.module.scss';
type OwnProps = {
message: ApiMessage;
shouldRenderShowWhen?: boolean;
canLoadReadDate?: boolean;
menuSeparatorSize: 'thin' | 'thick';
closeContextMenu: NoneToVoidFunction;
};
function ReadTimeMenuItem({
message, shouldRenderShowWhen, canLoadReadDate, closeContextMenu, menuSeparatorSize,
}: OwnProps) {
const { openGetReadDateModal } = getActions();
const lang = useLang();
const { readDate } = message;
const shouldRenderSkeleton = canLoadReadDate && !readDate && !shouldRenderShowWhen;
const handleOpenModal = () => {
closeContextMenu();
openGetReadDateModal({ chatId: message.chatId, messageId: message.id });
};
return (
<>
<MenuSeparator size={menuSeparatorSize} />
<MenuItem icon="message-read" className={styles.item}>
<Transition name="fade" activeKey={shouldRenderSkeleton ? 1 : 2} className={styles.transition}>
{shouldRenderSkeleton ? <Skeleton className={styles.skeleton} /> : (
<>
{Boolean(readDate) && lang('PmReadAt', formatDateAtTime(lang, readDate * 1000))}
{!readDate && shouldRenderShowWhen && (
<div>
{lang('PmRead')}
<span className={styles.get} onClick={handleOpenModal}>
{lang('PmReadShowWhen')}
</span>
</div>
)}
</>
)}
</Transition>
</MenuItem>
</>
);
}
export default memo(ReadTimeMenuItem);

View File

@ -1,6 +1,13 @@
.root {
margin: 0.25rem 0;
height: 1px;
border-radius: 1px;
background-color: var(--color-interactive-inactive);
border-radius: 0.0625rem;
background-color: var(--color-background-menu-separator);
}
.thin {
height: 0.0625rem;
}
.thick {
height: 0.375rem;
}

View File

@ -7,11 +7,12 @@ import styles from './MenuSeparator.module.scss';
type OwnProps = {
className?: string;
size?: 'thin' | 'thick';
};
const MenuSeparator: FC<OwnProps> = ({ className }) => {
const MenuSeparator: FC<OwnProps> = ({ className, size = 'thin' }) => {
return (
<div className={buildClassName(styles.root, className)} />
<div className={buildClassName(styles.root, styles[size], className)} />
);
};

View File

@ -21,7 +21,7 @@ import Portal from './Portal';
import './Modal.scss';
const ANIMATION_DURATION = 200;
export const ANIMATION_DURATION = 200;
type OwnProps = {
title?: string | TextPart[];

View File

@ -0,0 +1,33 @@
.separator {
display: flex;
align-items: center;
text-align: center;
color: var(--color-text-secondary);
&::before,
&::after {
content: '';
flex: 1;
border-bottom: 0.0625rem solid var(--color-dividers);
}
&:not(:empty)::before {
margin-right: 0.5rem;
}
&:not(:empty)::after {
margin-left: 0.5rem;
}
&[dir="rtl"] {
&:not(:empty)::before {
margin-left: 0.5rem;
margin-right: 0;
}
&:not(:empty)::after {
margin-right: 0.5rem;
margin-left: 0;
}
}
}

View File

@ -0,0 +1,27 @@
import React from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang';
import styles from './Separator.module.scss';
type OwnProps = {
children: React.ReactNode;
className?: string;
};
function Separator({ children, className }: OwnProps) {
const lang = useLang();
return (
<div
dir={lang.isRtl ? 'rtl' : undefined}
className={buildClassName(styles.separator, className)}
>
{children}
</div>
);
}
export default Separator;

View File

@ -1,6 +1,7 @@
import type {
ApiAttachment,
ApiChat,
ApiError,
ApiInputMessageReplyInfo,
ApiInputReplyInfo,
ApiInputStoryReplyInfo,
@ -66,6 +67,7 @@ import {
replaceScheduledMessages,
replaceSettings,
replaceThreadParam,
replaceUserStatuses,
safeReplacePinnedIds,
safeReplaceViewportIds,
updateChat,
@ -116,6 +118,7 @@ import {
selectTranslationLanguage,
selectUser,
selectUserFullInfo,
selectUserStatus,
selectViewportIds,
} from '../../selectors';
import { deleteMessages } from '../apiUpdaters/messages';
@ -1820,6 +1823,44 @@ addActionHandler('loadMessageViews', async (global, actions, payload): Promise<v
setGlobal(global);
});
addActionHandler('loadOutboxReadDate', async (global, actions, payload): Promise<void> => {
const { chatId, messageId } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
try {
const result = await callApi('fetchOutboxReadDate', { chat, messageId });
if (result?.date) {
global = getGlobal();
global = updateChatMessage(global, chatId, messageId, { readDate: result.date });
setGlobal(global);
}
} catch (error) {
const { message } = error as ApiError;
if (message === 'USER_PRIVACY_RESTRICTED' || message === 'YOUR_PRIVACY_RESTRICTED') {
global = getGlobal();
const user = selectUser(global, chatId);
if (!user) return;
const userStatus = selectUserStatus(global, chatId);
if (!userStatus) return;
const updateStatus = message === 'USER_PRIVACY_RESTRICTED'
? { isReadDateRestricted: true }
: { isReadDateRestrictedByMe: true };
global = replaceUserStatuses(global, {
[chatId]: { ...userStatus, ...updateStatus },
});
// Need to reset `readDate` to `undefined` after click on "Show my Read Time" button
global = updateChatMessage(global, chatId, messageId, { readDate: undefined });
setGlobal(global);
}
}
});
function countSortedIds(ids: number[], from: number, to: number) {
let count = 0;

View File

@ -666,24 +666,29 @@ addActionHandler('loadGlobalPrivacySettings', async (global): Promise<void> => {
}
global = getGlobal();
global = replaceSettings(global, {
shouldArchiveAndMuteNewNonContact: globalSettings.shouldArchiveAndMuteNewNonContact,
});
global = replaceSettings(global, { ...globalSettings });
setGlobal(global);
});
addActionHandler('updateGlobalPrivacySettings', async (global, actions, payload): Promise<void> => {
const { shouldArchiveAndMuteNewNonContact } = payload;
global = replaceSettings(global, { shouldArchiveAndMuteNewNonContact });
const shouldArchiveAndMuteNewNonContact = payload.shouldArchiveAndMuteNewNonContact
?? Boolean(global.settings.byKey.shouldArchiveAndMuteNewNonContact);
const shouldHideReadMarks = payload.shouldHideReadMarks ?? Boolean(global.settings.byKey.shouldHideReadMarks);
global = replaceSettings(global, { shouldArchiveAndMuteNewNonContact, shouldHideReadMarks });
setGlobal(global);
const result = await callApi('updateGlobalPrivacySettings', { shouldArchiveAndMuteNewNonContact });
const result = await callApi('updateGlobalPrivacySettings', {
shouldArchiveAndMuteNewNonContact,
shouldHideReadMarks,
});
global = getGlobal();
global = replaceSettings(global, {
shouldArchiveAndMuteNewNonContact: !result
? !shouldArchiveAndMuteNewNonContact
: result.shouldArchiveAndMuteNewNonContact,
shouldHideReadMarks: !result ? !shouldHideReadMarks : result.shouldHideReadMarks,
});
setGlobal(global);
});

View File

@ -781,6 +781,22 @@ addActionHandler('closeSeenByModal', (global, actions, payload): ActionReturnTyp
}, tabId);
});
addActionHandler('openGetReadDateModal', (global, actions, payload): ActionReturnType => {
const { chatId, messageId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
readDateModal: { chatId, messageId },
}, tabId);
});
addActionHandler('closeGetReadDateModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
readDateModal: undefined,
}, tabId);
});
addActionHandler('openChatLanguageModal', (global, actions, payload): ActionReturnType => {
const { chatId, messageId, tabId = getCurrentTabId() } = payload;

View File

@ -237,6 +237,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
wasTimeFormatSetManually: false,
isConnectionStatusMinimized: true,
shouldArchiveAndMuteNewNonContact: false,
shouldHideReadMarks: false,
canTranslate: false,
canTranslateChats: true,
doNotTranslate: [],

View File

@ -19,3 +19,7 @@ export function selectCanSetPasscode<T extends GlobalState>(global: T) {
export function selectTranslationLanguage<T extends GlobalState>(global: T) {
return global.settings.byKey.translationLanguage || selectLanguageCode(global);
}
export function selectShouldHideReadMarks<T extends GlobalState>(global: T) {
return global.settings.byKey.shouldHideReadMarks;
}

View File

@ -283,6 +283,11 @@ export type TabState = {
messageId: number;
};
readDateModal?: {
chatId: string;
messageId: number;
};
reactorModal?: {
chatId: string;
messageId: number;
@ -1607,6 +1612,11 @@ export interface ActionPayloads {
messageId: number;
} & WithTabId;
closeSeenByModal: WithTabId | undefined;
openGetReadDateModal: {
chatId: string;
messageId: number;
} & WithTabId;
closeGetReadDateModal: WithTabId | undefined;
closeReactorListModal: WithTabId | undefined;
openReactorListModal: {
chatId: string;
@ -2009,6 +2019,10 @@ export interface ActionPayloads {
ids: number[];
shouldIncrement?: boolean;
};
loadOutboxReadDate: {
chatId: string;
messageId: number;
};
animateUnreadReaction: {
messageIds: number[];
} & WithTabId;
@ -2833,7 +2847,7 @@ export interface ActionPayloads {
} & WithTabId;
closeShareChatFolderModal: undefined | WithTabId;
loadGlobalPrivacySettings: undefined;
updateGlobalPrivacySettings: { shouldArchiveAndMuteNewNonContact: boolean };
updateGlobalPrivacySettings: { shouldArchiveAndMuteNewNonContact?: boolean; shouldHideReadMarks?: boolean };
// Premium
openPremiumModal: ({

View File

@ -1412,6 +1412,7 @@ messages.getSavedHistory#3d9a414d peer:InputPeer offset_id:int offset_date:int a
messages.deleteSavedHistory#6e98102b flags:# peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory;
messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs;
messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool;
messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate;
updates.getState#edd4882a = updates.State;
updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;

View File

@ -168,6 +168,7 @@
"messages.getBotApp",
"messages.requestAppWebView",
"messages.togglePeerTranslations",
"messages.getOutboxReadDate",
"updates.getState",
"updates.getDifference",
"updates.getChannelDifference",

View File

@ -62,6 +62,7 @@ $color-message-story-mention-to: #74bcff;
--color-background-compact-menu: #FFFFFFBB;
--color-background-compact-menu-reactions: #FFFFFFEB;
--color-background-compact-menu-hover: #000000B2;
--color-background-menu-separator: #0000001a;
--color-background-selected: #f4f4f5;
--color-background-secondary: #f4f4f5;
--color-background-secondary-accent: #e4e4e5;

View File

@ -324,6 +324,7 @@ body:not(.is-ios) {
--color-background-compact-menu: rgb(33, 33, 33, 0.867);
--color-background-compact-menu-reactions: rgb(33, 33, 33, 0.867);
--color-background-compact-menu-hover: rgb(0, 0, 0, 0.4);
--color-background-menu-separator: rgba(255, 255, 255, 0.102);
--color-background-secondary: rgb(15, 15, 15);
--color-background-secondary-accent: rgb(16, 15, 16);
--color-background-own: rgb(118, 106, 200);

View File

@ -66,5 +66,6 @@
"--color-forum-unread-topic-hover": ["#e9e9e9", "#363636"],
"--color-forum-hover-unread-topic-hover": ["#e2e2e2", "#3f3f3f"],
"--color-chat-username": ["#3C7EB0", "#E9EEF4"],
"--color-borders-read-story": ["#C4C9CC", "#737373"]
"--color-borders-read-story": ["#C4C9CC", "#737373"],
"--color-background-menu-separator": ["#0000001a", "#ffffff1a"]
}

View File

@ -101,6 +101,7 @@ export interface ISettings extends NotifySettings, Record<string, any> {
wasTimeFormatSetManually: boolean;
isConnectionStatusMinimized: boolean;
shouldArchiveAndMuteNewNonContact?: boolean;
shouldHideReadMarks?: boolean;
canTranslate: boolean;
canTranslateChats: boolean;
translationLanguage?: string;