diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts
index ffc39247e..7e723b251 100644
--- a/src/api/gramjs/apiBuilders/appConfig.ts
+++ b/src/api/gramjs/apiBuilders/appConfig.ts
@@ -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,
diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts
index e4ca27ea2..e42696f67 100644
--- a/src/api/gramjs/apiBuilders/users.ts
+++ b/src/api/gramjs/apiBuilders/users.ts
@@ -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 };
}
}
diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts
index 9206476a4..f3584a061 100644
--- a/src/api/gramjs/methods/index.ts
+++ b/src/api/gramjs/methods/index.ts
@@ -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';
diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts
index c5c04a936..d9d055efd 100644
--- a/src/api/gramjs/methods/messages.ts
+++ b/src/api/gramjs/methods/messages.ts
@@ -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 };
+}
diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts
index 6931335eb..d69d35fb9 100644
--- a/src/api/gramjs/methods/settings.ts
+++ b/src/api/gramjs/methods/settings.ts
@@ -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),
};
}
diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts
index 3e2950e23..893e0c445 100644
--- a/src/api/types/messages.ts
+++ b/src/api/types/messages.ts
@@ -536,6 +536,7 @@ export interface ApiMessage {
};
reactions?: ApiReactions;
hasComments?: boolean;
+ readDate?: number;
savedPeerId?: string;
}
diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts
index aa6f22940..931f20647 100644
--- a/src/api/types/misc.ts
+++ b/src/api/types/misc.ts
@@ -178,6 +178,7 @@ export interface ApiAppConfig {
emojiSounds: Record;
seenByMaxChatMembers: number;
seenByExpiresAt: number;
+ readDateExpiresAt: number;
autologinDomains: string[];
urlAuthDomains: string[];
premiumInvoiceSlug: string;
diff --git a/src/api/types/users.ts b/src/api/types/users.ts
index d820d9678..2d96f80f3 100644
--- a/src/api/types/users.ts
+++ b/src/api/types/users.ts
@@ -66,6 +66,8 @@ export interface ApiUserStatus {
);
wasOnline?: number;
expires?: number;
+ isReadDateRestrictedByMe?: boolean;
+ isReadDateRestricted?: boolean;
}
export interface ApiUsername {
diff --git a/src/assets/tgs/ReadTime.tgs b/src/assets/tgs/ReadTime.tgs
new file mode 100644
index 000000000..ceabee86d
Binary files /dev/null and b/src/assets/tgs/ReadTime.tgs differ
diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts
index 52652ded5..32682d19b 100644
--- a/src/bundles/extra.ts
+++ b/src/bundles/extra.ts
@@ -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';
diff --git a/src/components/common/ReadDateModal.module.scss b/src/components/common/ReadDateModal.module.scss
new file mode 100644
index 000000000..aa86b73d5
--- /dev/null
+++ b/src/components/common/ReadDateModal.module.scss
@@ -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;
+}
diff --git a/src/components/common/ReadDateModal.tsx b/src/components/common/ReadDateModal.tsx
new file mode 100644
index 000000000..429cfcb09
--- /dev/null
+++ b/src/components/common/ReadDateModal.tsx
@@ -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 (
+
+
+
+
+
{lang('PremiumReadHeader1')}
+
{renderText(lang('PremiumReadText1', userName), ['simple_markdown'])}
+
+
{lang('PremiumOr')}
+
{lang('PremiumReadHeader2')}
+
{renderText(lang('PremiumReadText2', userName), ['simple_markdown'])}
+ {/* eslint-disable-next-line react/jsx-no-bind */}
+
+
+
+ );
+};
+
+export default memo(withGlobal(
+ (global): StateProps => {
+ const { chatId } = selectTabState(global).readDateModal || {};
+ const user = chatId ? selectUser(global, chatId) : undefined;
+
+ return { user };
+ },
+)(ReadDateModal));
diff --git a/src/components/common/ReadTimeModal.async.tsx b/src/components/common/ReadTimeModal.async.tsx
new file mode 100644
index 000000000..a1410967d
--- /dev/null
+++ b/src/components/common/ReadTimeModal.async.tsx
@@ -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 = (props) => {
+ const { isOpen } = props;
+ const ReadTimeModal = useModuleLoader(Bundles.Extra, 'ReadTimeModal', !isOpen);
+
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ return ReadTimeModal ? : undefined;
+};
+
+export default ReadTimeModalAsync;
diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts
index 54350bfba..1079ead2d 100644
--- a/src/components/common/helpers/animatedAssets.ts
+++ b/src/components/common/helpers/animatedAssets.ts
@@ -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,
};
diff --git a/src/components/left/settings/SettingsPrivacyLastSeen.module.scss b/src/components/left/settings/SettingsPrivacyLastSeen.module.scss
new file mode 100644
index 000000000..898838f0b
--- /dev/null
+++ b/src/components/left/settings/SettingsPrivacyLastSeen.module.scss
@@ -0,0 +1,3 @@
+:global(.settings-item-description-larger).premiumInfo {
+ margin-top: 1rem;
+}
diff --git a/src/components/left/settings/SettingsPrivacyLastSeen.tsx b/src/components/left/settings/SettingsPrivacyLastSeen.tsx
new file mode 100644
index 000000000..96964dbeb
--- /dev/null
+++ b/src/components/left/settings/SettingsPrivacyLastSeen.tsx
@@ -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 && (
+
+
+
+ {renderText(lang('HideReadTimeInfo'), ['br'])}
+
+
+ )}
+
+
}
+ // eslint-disable-next-line react/jsx-no-bind
+ onClick={() => openPremiumModal()}
+ >
+ {isCurrentUserPremium ? lang('PrivacyLastSeenPremiumForPremium') : lang('PrivacyLastSeenPremium')}
+
+
+ {isCurrentUserPremium
+ ? lang('PrivacyLastSeenPremiumInfoForPremium')
+ : lang('PrivacyLastSeenPremiumInfo')}
+
+
+ >
+ );
+};
+
+export default memo(withGlobal(
+ (global): StateProps => {
+ return {
+ isCurrentUserPremium: selectIsCurrentUserPremium(global),
+ shouldHideReadMarks: Boolean(selectShouldHideReadMarks(global)),
+ };
+ },
+)(SettingsPrivacyLastSeen));
diff --git a/src/components/left/settings/SettingsPrivacyVisibility.tsx b/src/components/left/settings/SettingsPrivacyVisibility.tsx
index dee894146..16556552a 100644
--- a/src/components/left/settings/SettingsPrivacyVisibility.tsx
+++ b/src/components/left/settings/SettingsPrivacyVisibility.tsx
@@ -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 = ({
currentUserFallbackPhoto={currentUserFallbackPhoto}
/>
)}
+ {screen === SettingsScreens.PrivacyLastSeen && (
+
+ )}
{secondaryScreen && (
+
{IS_TRANSLATION_SUPPORTED && }
@@ -723,7 +727,7 @@ export default memo(withGlobal(
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(
hasCurrentTextSearch: Boolean(selectCurrentTextSearch(global)),
isSelectModeActive: selectIsInSelectMode(global),
isSeenByModalOpen: Boolean(seenByModal),
+ isReadDateModalOpen: Boolean(readDateModal),
isReactorListModalOpen: Boolean(reactorModal),
isGiftPremiumModalOpen: giftPremiumModal?.isOpen,
isChatLanguageModalOpen: Boolean(chatLanguageModal),
diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx
index 8f09e5fac..6370fb2fe 100644
--- a/src/components/middle/message/ContextMenuContainer.tsx
+++ b/src/components/middle/message/ContextMenuContainer.tsx
@@ -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 = ({
canRevote,
canClosePoll,
canPlayAnimatedEmojis,
+ canLoadReadDate,
+ shouldRenderShowWhen,
activeDownloads,
noReplies,
canShowSeenBy,
@@ -200,6 +206,7 @@ const ContextMenuContainer: FC = ({
showOriginalMessage,
openChatLanguageModal,
openMessageReactionPicker,
+ loadOutboxReadDate,
} = getActions();
const lang = useLang();
@@ -221,6 +228,12 @@ const ContextMenuContainer: FC = ({
}
}, [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 = ({
canShowOriginal={canShowOriginal}
canSelectLanguage={canSelectLanguage}
canPlayAnimatedEmojis={canPlayAnimatedEmojis}
+ shouldRenderShowWhen={shouldRenderShowWhen}
+ canLoadReadDate={canLoadReadDate}
hasCustomEmoji={hasCustomEmoji}
customEmojiSets={customEmojiSets}
isDownloading={isDownloading}
@@ -623,7 +638,9 @@ export default memo(withGlobal(
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(
} = (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(
const hasTtl = hasMessageTtl(message);
const canShowSeenBy = Boolean(!isLocal
&& chat
+ && !isMessageUnread
&& seenByMaxChatMembers
&& seenByExpiresAt
&& isChatGroup(chat)
@@ -706,6 +737,8 @@ export default memo(withGlobal(
canClosePoll: !isScheduled && canClosePoll,
activeDownloads,
canShowSeenBy,
+ canLoadReadDate,
+ shouldRenderShowWhen,
enabledReactions: chat?.isForbidden ? undefined : chatFullInfo?.enabledReactions,
maxUniqueReactions,
isPrivate,
diff --git a/src/components/middle/message/Giveaway.tsx b/src/components/middle/message/Giveaway.tsx
index 17e560c7d..8495385a7 100644
--- a/src/components/middle/message/Giveaway.tsx
+++ b/src/components/middle/message/Giveaway.tsx
@@ -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'],
)}
- {lang('BoostingGiveawayMsgWithDivider')}
+ {lang('BoostingGiveawayMsgWithDivider')}
>
)}
diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx
index c28e3698c..89022872c 100644
--- a/src/components/middle/message/MessageContextMenu.tsx
+++ b/src/components/middle/message/MessageContextMenu.tsx
@@ -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 = ({
customEmojiSets,
canPlayAnimatedEmojis,
noTransition,
+ shouldRenderShowWhen,
+ canLoadReadDate,
onReply,
onOpenThread,
onEdit,
@@ -401,42 +406,10 @@ const MessageContextMenu: FC = ({
{canForward && }
{canSelect && }
{canReport && }
- {(canShowSeenBy || canShowReactionsCount) && !isSponsoredMessage && (
-
- )}
{canDelete && }
{hasCustomEmoji && (
<>
-
+
{!customEmojiSets && (
<>
@@ -462,6 +435,50 @@ const MessageContextMenu: FC = ({
{isSponsoredMessage && onSponsoredHide && (
)}
+ {(canShowSeenBy || canShowReactionsCount) && !isSponsoredMessage && (
+ <>
+
+
+ >
+ )}
+ {!isSponsoredMessage && (canLoadReadDate || shouldRenderShowWhen) && (
+
+ )}
);
diff --git a/src/components/middle/message/ReadTimeMenuItem.module.scss b/src/components/middle/message/ReadTimeMenuItem.module.scss
new file mode 100644
index 000000000..c273c15b7
--- /dev/null
+++ b/src/components/middle/message/ReadTimeMenuItem.module.scss
@@ -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;
+}
diff --git a/src/components/middle/message/ReadTimeMenuItem.tsx b/src/components/middle/message/ReadTimeMenuItem.tsx
new file mode 100644
index 000000000..4aa962fa4
--- /dev/null
+++ b/src/components/middle/message/ReadTimeMenuItem.tsx
@@ -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 (
+ <>
+
+
+ >
+ );
+}
+
+export default memo(ReadTimeMenuItem);
diff --git a/src/components/ui/MenuSeparator.module.scss b/src/components/ui/MenuSeparator.module.scss
index bc934d91c..d32c6d361 100644
--- a/src/components/ui/MenuSeparator.module.scss
+++ b/src/components/ui/MenuSeparator.module.scss
@@ -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;
}
diff --git a/src/components/ui/MenuSeparator.tsx b/src/components/ui/MenuSeparator.tsx
index b178b90f3..b8d069dd2 100644
--- a/src/components/ui/MenuSeparator.tsx
+++ b/src/components/ui/MenuSeparator.tsx
@@ -7,11 +7,12 @@ import styles from './MenuSeparator.module.scss';
type OwnProps = {
className?: string;
+ size?: 'thin' | 'thick';
};
-const MenuSeparator: FC = ({ className }) => {
+const MenuSeparator: FC = ({ className, size = 'thin' }) => {
return (
-
+
);
};
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
index e750c74ce..9a5943c47 100644
--- a/src/components/ui/Modal.tsx
+++ b/src/components/ui/Modal.tsx
@@ -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[];
diff --git a/src/components/ui/Separator.module.scss b/src/components/ui/Separator.module.scss
new file mode 100644
index 000000000..f68f95086
--- /dev/null
+++ b/src/components/ui/Separator.module.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/components/ui/Separator.tsx b/src/components/ui/Separator.tsx
new file mode 100644
index 000000000..a7675591b
--- /dev/null
+++ b/src/components/ui/Separator.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export default Separator;
diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts
index f1673e65a..6f25531d7 100644
--- a/src/global/actions/api/messages.ts
+++ b/src/global/actions/api/messages.ts
@@ -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 => {
+ 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;
diff --git a/src/global/actions/api/settings.ts b/src/global/actions/api/settings.ts
index 89ad56248..5f4d93024 100644
--- a/src/global/actions/api/settings.ts
+++ b/src/global/actions/api/settings.ts
@@ -666,24 +666,29 @@ addActionHandler('loadGlobalPrivacySettings', async (global): Promise => {
}
global = getGlobal();
- global = replaceSettings(global, {
- shouldArchiveAndMuteNewNonContact: globalSettings.shouldArchiveAndMuteNewNonContact,
- });
+ global = replaceSettings(global, { ...globalSettings });
setGlobal(global);
});
addActionHandler('updateGlobalPrivacySettings', async (global, actions, payload): Promise => {
- 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);
});
diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts
index c32fb04cb..3144d7f0d 100644
--- a/src/global/actions/ui/messages.ts
+++ b/src/global/actions/ui/messages.ts
@@ -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;
diff --git a/src/global/initialState.ts b/src/global/initialState.ts
index fb9722283..9e878aac1 100644
--- a/src/global/initialState.ts
+++ b/src/global/initialState.ts
@@ -237,6 +237,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
wasTimeFormatSetManually: false,
isConnectionStatusMinimized: true,
shouldArchiveAndMuteNewNonContact: false,
+ shouldHideReadMarks: false,
canTranslate: false,
canTranslateChats: true,
doNotTranslate: [],
diff --git a/src/global/selectors/settings.ts b/src/global/selectors/settings.ts
index 0ea135cd4..d93b6c7b9 100644
--- a/src/global/selectors/settings.ts
+++ b/src/global/selectors/settings.ts
@@ -19,3 +19,7 @@ export function selectCanSetPasscode(global: T) {
export function selectTranslationLanguage(global: T) {
return global.settings.byKey.translationLanguage || selectLanguageCode(global);
}
+
+export function selectShouldHideReadMarks(global: T) {
+ return global.settings.byKey.shouldHideReadMarks;
+}
diff --git a/src/global/types.ts b/src/global/types.ts
index 67ae69797..ecfaa4988 100644
--- a/src/global/types.ts
+++ b/src/global/types.ts
@@ -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: ({
diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js
index 2a117d367..6909bf9b0 100644
--- a/src/lib/gramjs/tl/apiTl.js
+++ b/src/lib/gramjs/tl/apiTl.js
@@ -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;
diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json
index f8fbe5a2c..fbc1c1340 100644
--- a/src/lib/gramjs/tl/static/api.json
+++ b/src/lib/gramjs/tl/static/api.json
@@ -168,6 +168,7 @@
"messages.getBotApp",
"messages.requestAppWebView",
"messages.togglePeerTranslations",
+ "messages.getOutboxReadDate",
"updates.getState",
"updates.getDifference",
"updates.getChannelDifference",
diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss
index cbc2f713b..81c0e70de 100644
--- a/src/styles/_variables.scss
+++ b/src/styles/_variables.scss
@@ -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;
diff --git a/src/styles/index.scss b/src/styles/index.scss
index b7317472a..4488b4032 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -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);
diff --git a/src/styles/themes.json b/src/styles/themes.json
index 9ded169b9..1278b2a59 100644
--- a/src/styles/themes.json
+++ b/src/styles/themes.json
@@ -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"]
}
diff --git a/src/types/index.ts b/src/types/index.ts
index 424357f58..d36e1bced 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -101,6 +101,7 @@ export interface ISettings extends NotifySettings, Record {
wasTimeFormatSetManually: boolean;
isConnectionStatusMinimized: boolean;
shouldArchiveAndMuteNewNonContact?: boolean;
+ shouldHideReadMarks?: boolean;
canTranslate: boolean;
canTranslateChats: boolean;
translationLanguage?: string;