Story Ribbon: Allow opening with Stealth Mode (#6471)

This commit is contained in:
zubiden 2025-11-17 12:18:22 +04:00 committed by Alexander Zinchuk
parent a9e68aa473
commit 880a6c6977
30 changed files with 394 additions and 341 deletions

View File

@ -642,6 +642,7 @@
"AttachmentMenuPhotoOrVideo" = "Photo or Video";
"AttachDocument" = "File";
"Poll" = "Poll";
"SlowModePlaceholder" = "Next message in {timer}";
"SlowModeHint" = "Slow Mode is active. You can send\nyour next message in {time}.";
"SendMessageAsTitle" = "Send message as...";
"Message" = "Message";
@ -2342,3 +2343,27 @@
"InviteRestrictedPremiumReasonMultipleMore_one" = "{list} and **{count} more** person only accept invitations to groups from contacts and **Premium** users.";
"InviteRestrictedPremiumReasonMultipleMore_other" = "{list} and **{count} more** people only accept invitations to groups from contacts and **Premium** users.";
"StoryUnsupported" = "This story is not supported in Telegram Web A. Try viewing it in the Telegram app.";
"StoryRibbonMyStory" = "My Story";
"StoryMenuSavedStories" = "Posted Stories";
"StoryMenuArchivedStories" = "Archived Stories";
"StoryMenuSendMessage" = "Send Message";
"StoryMenuViewProfile" = "Open Profile";
"StoryMenuViewChannel" = "Open Channel";
"StoryMenuOpenStealth" = "View Anonymously";
"StoryMenuArchivePeer" = "Hide Stories";
"StoryMenuUnarchivePeer" = "Unhide Stories";
"StealthModeOnTitle" = "Stealth Mode On";
"StealthModeOnHintEnabled" = "The creators of stories you viewed in the past 5 minutes or will view in the next 25 minutes won't see you in the list of viewers.";
"StealthModeOnHint" = "The creators of stories you view in **{time}** won't see you in their list of viewers.";
"StealthModeTitle" = "Stealth Mode";
"StealthModeDescription" = "Turn Stealth Mode on to watch stories without appearing in the list of viewers.";
"StealthModeDescriptionPremium" = "Subscribe to Telegram Premium to watch stories without appearing in the list of viewers.";
"StealthModeHideRecentTitle" = "Hide Recent Views";
"StealthModeHideRecentDescription" = "Hide my views from the past 5 minutes.";
"StealthModeHideFutureTitle" = "Hide Upcoming Views";
"StealthModeHideFutureDescription" = "Hide my views for the next 25 minutes.";
"StealthModeButtonPremium" = "Unlock Stealth Mode";
"StealthModeButton" = "Enable Stealth Mode";
"StealthModeButtonToStory" = "Enable and Open the Story";
"StealthModeButtonRecharge" = "Available in {timer}";
"StealthModeComposerPlaceholder" = "Stealth Mode active {timer}";

View File

@ -105,3 +105,4 @@ export { default as WebAppsCloseConfirmationModal } from '../components/main/Web
export { default as FrozenAccountModal } from '../components/modals/frozenAccount/FrozenAccountModal';
export { default as ProfileRatingModal } from '../components/modals/profileRating/ProfileRatingModal';
export { default as QuickPreviewModal } from '../components/modals/quickPreview/QuickPreviewModal';
export { default as StealthModeModal } from '../components/modals/storyStealthMode/StealthModeModal';

View File

@ -94,6 +94,7 @@ const AboutMonetizationModal: FC<OwnProps> = ({
isOpen={isOpen}
listItemData={modalData.listItemData}
headerIconName="cash-circle"
headerIconPremiumGradient
withSeparator
header={modalData.header}
footer={modalData.footer}

View File

@ -136,6 +136,7 @@ import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useDerivedState from '../../hooks/useDerivedState';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import useFlag from '../../hooks/useFlag';
import useForceUpdate from '../../hooks/useForceUpdate';
import useGetSelectionRange from '../../hooks/useGetSelectionRange';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
@ -187,6 +188,7 @@ import ReactionSelector from '../middle/message/reactions/ReactionSelector';
import Button from '../ui/Button';
import ResponsiveHoverButton from '../ui/ResponsiveHoverButton';
import Spinner from '../ui/Spinner';
import TextTimer from '../ui/TextTimer';
import Transition from '../ui/Transition';
import AnimatedCounter from './AnimatedCounter';
import Avatar from './Avatar';
@ -484,6 +486,7 @@ const Composer: FC<OwnProps & StateProps> = ({
const lastMessageSendTimeSeconds = useRef<number>();
const prevDropAreaState = usePreviousDeprecated(dropAreaState);
const { width: windowWidth } = windowSize.get();
const forceUpdate = useForceUpdate();
const isInMessageList = type === 'messageList';
const isInStoryViewer = type === 'story';
@ -1662,18 +1665,6 @@ const Composer: FC<OwnProps & StateProps> = ({
&& messageListType === 'thread';
const isBotMenuButtonOpen = withBotMenuButton && !hasText && !activeVoiceRecording;
const [timedPlaceholderLangKey, timedPlaceholderDate] = useMemo(() => {
if (slowMode?.nextSendDate) {
return ['SlowModeWait', slowMode.nextSendDate];
}
if (stealthMode?.activeUntil && isInStoryViewer) {
return ['StealthModeActiveHint', stealthMode.activeUntil];
}
return [];
}, [isInStoryViewer, slowMode?.nextSendDate, stealthMode?.activeUntil]);
const isComposerHasFocus = isBotKeyboardOpen || isSymbolMenuOpen || isEmojiTooltipOpen || isSendAsMenuOpen
|| isMentionTooltipOpen || isInlineBotTooltipOpen || isBotCommandMenuOpen || isAttachMenuOpen
|| isStickerTooltipOpen || isChatCommandTooltipOpen || isCustomEmojiTooltipOpen || isBotMenuButtonOpen
@ -1681,12 +1672,21 @@ const Composer: FC<OwnProps & StateProps> = ({
const isReactionSelectorOpen = isComposerHasFocus && !isReactionPickerOpen && isInStoryViewer && !isAttachMenuOpen
&& !isSymbolMenuOpen;
const slowModePlaceholder = (() => {
if (!slowMode?.nextSendDate || slowMode.nextSendDate < getServerTime()) return undefined;
return lang('SlowModePlaceholder', {
timer: <TextTimer endsAt={slowMode.nextSendDate} onEnd={forceUpdate} />,
}, { withNodes: true });
})();
const placeholder = useMemo(() => {
if (activeVoiceRecording && windowWidth <= SCREEN_WIDTH_TO_HIDE_PLACEHOLDER) {
return '';
}
if (!isComposerBlocked) {
if (slowModePlaceholder) return slowModePlaceholder;
if (botKeyboardPlaceholder) return botKeyboardPlaceholder;
if (inputPlaceholder) return inputPlaceholder;
if (paidMessagesStars) {
@ -1701,6 +1701,12 @@ const Composer: FC<OwnProps & StateProps> = ({
return lang('ComposerPlaceholderCaption');
}
if (stealthMode?.activeUntil && isInStoryViewer && stealthMode.activeUntil > getServerTime()) {
return lang('StealthModeComposerPlaceholder', {
timer: <TextTimer endsAt={stealthMode.activeUntil} onEnd={forceUpdate} />,
}, { withNodes: true });
}
if (chat?.adminRights?.anonymous) {
return lang('ComposerPlaceholderAnonymous');
}
@ -1722,7 +1728,7 @@ const Composer: FC<OwnProps & StateProps> = ({
}, [
activeVoiceRecording, botKeyboardPlaceholder, chat, inputPlaceholder, isChannel, isComposerBlocked,
isInStoryViewer, isSilentPosting, lang, replyToTopic, isReplying, threadId, windowWidth, paidMessagesStars,
hasSuggestedPost,
hasSuggestedPost, slowModePlaceholder, stealthMode?.activeUntil,
]);
useEffect(() => {
@ -2172,8 +2178,6 @@ const Composer: FC<OwnProps & StateProps> = ({
isActive={!hasAttachments}
getHtml={getHtml}
placeholder={placeholder}
timedPlaceholderDate={timedPlaceholderDate}
timedPlaceholderLangKey={timedPlaceholderLangKey}
forcedPlaceholder={inlineBotHelp}
canAutoFocus={isReady && isForCurrentMessageList && !hasAttachments && isInMessageList}
noFocusInterception={hasAttachments}

View File

@ -138,11 +138,9 @@ const PublicPostsSearchLauncher = ({
</Button>
{Boolean(waitTill) && (
<div className={styles.freeSearchUnlock}>
<TextTimer
langKey="UnlockTimerPublicPostsSearch"
endsAt={waitTill + WAIT_DELAY}
onEnd={onCheckFlood}
/>
{lang('UnlockTimerPublicPostsSearch', {
time: <TextTimer endsAt={waitTill + WAIT_DELAY} onEnd={onCheckFlood} />,
}, { withNodes: true })}
</div>
)}
</div>

View File

@ -41,7 +41,6 @@ import useInputCustomEmojis from './hooks/useInputCustomEmojis';
import Icon from '../../common/icons/Icon';
import Button from '../../ui/Button';
import TextTimer from '../../ui/TextTimer';
import TextFormatter from './TextFormatter.async';
const CONTEXT_MENU_CLOSE_DELAY_MS = 100;
@ -63,23 +62,21 @@ type OwnProps = {
isActive: boolean;
getHtml: Signal<string>;
placeholder: TeactNode | string;
timedPlaceholderLangKey?: string;
timedPlaceholderDate?: number;
forcedPlaceholder?: string;
noFocusInterception?: boolean;
canAutoFocus: boolean;
shouldSuppressFocus?: boolean;
shouldSuppressTextFormatter?: boolean;
canSendPlainText?: boolean;
isNeedPremium?: boolean;
messageListType?: MessageListType;
captionLimit?: number;
onUpdate: (html: string) => void;
onSuppressedFocus?: () => void;
onSend: () => void;
onScroll?: (event: React.UIEvent<HTMLElement>) => void;
captionLimit?: number;
onFocus?: NoneToVoidFunction;
onBlur?: NoneToVoidFunction;
isNeedPremium?: boolean;
messageListType?: MessageListType;
};
type StateProps = {
@ -127,8 +124,6 @@ const MessageInput: FC<OwnProps & StateProps> = ({
isActive,
getHtml,
placeholder,
timedPlaceholderLangKey,
timedPlaceholderDate,
forcedPlaceholder,
canSendPlainText,
canAutoFocus,
@ -139,14 +134,14 @@ const MessageInput: FC<OwnProps & StateProps> = ({
isSelectModeActive,
canPlayAnimatedEmojis,
messageSendKeyCombo,
isNeedPremium,
messageListType,
onUpdate,
onSuppressedFocus,
onSend,
onScroll,
onFocus,
onBlur,
isNeedPremium,
messageListType,
}) => {
const {
editLastMessage,
@ -177,16 +172,6 @@ const MessageInput: FC<OwnProps & StateProps> = ({
const { isMobile } = useAppLayout();
const isMobileDevice = isMobile && (IS_IOS || IS_ANDROID);
const [shouldDisplayTimer, setShouldDisplayTimer] = useState(false);
useEffect(() => {
setShouldDisplayTimer(Boolean(timedPlaceholderLangKey && timedPlaceholderDate));
}, [timedPlaceholderDate, timedPlaceholderLangKey]);
const handleTimerEnd = useLastCallback(() => {
setShouldDisplayTimer(false);
});
useInputCustomEmojis(
getHtml,
inputRef,
@ -611,9 +596,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
>
{!isAttachmentModalInput && !canSendPlainText
&& <Icon name="lock-badge" className="placeholder-icon" />}
{shouldDisplayTimer ? (
<TextTimer langKey={timedPlaceholderLangKey!} endsAt={timedPlaceholderDate!} onEnd={handleTimerEnd} />
) : placeholder}
{placeholder}
{isStoryInput && isNeedPremium && (
<Button className="unlock-button" size="tiny" color="adaptive" onClick={handleOpenPremiumModal}>
{oldLang('StoryRepliesLockedButton')}

View File

@ -50,6 +50,7 @@ import StarsBalanceModal from './stars/StarsBalanceModal.async';
import StarsPaymentModal from './stars/StarsPaymentModal.async';
import StarsSubscriptionModal from './stars/subscription/StarsSubscriptionModal.async';
import StarsTransactionInfoModal from './stars/transaction/StarsTransactionModal.async';
import StealthModeModal from './storyStealthMode/StealthModeModal.async';
import SuggestedPostApprovalModal from './suggestedPostApproval/SuggestedPostApprovalModal.async';
import SuggestedStatusModal from './suggestedStatus/SuggestedStatusModal.async';
import SuggestMessageModal from './suggestMessage/SuggestMessageModal.async';
@ -105,7 +106,8 @@ type ModalKey = keyof Pick<TabState,
'deleteAccountModal' |
'isAgeVerificationModalOpen' |
'profileRatingModal' |
'quickPreview'
'quickPreview' |
'storyStealthModal'
>;
type StateProps = {
@ -169,6 +171,7 @@ const MODALS: ModalRegistry = {
isAgeVerificationModalOpen: AgeVerificationModal,
profileRatingModal: ProfileRatingModal,
quickPreview: QuickPreviewModal,
storyStealthModal: StealthModeModal,
};
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];
const MODAL_ENTRIES = Object.entries(MODALS) as Entries<ModalRegistry>;

View File

@ -133,6 +133,7 @@ const AboutAdsModal = ({ modal, minLevelToRestrictAds }: OwnProps & StateProps)
isOpen={isOpen}
listItemData={modalData?.listItemData}
headerIconName="channel"
headerIconPremiumGradient
withSeparator
header={modalData?.header}
footer={modalData?.footer}

View File

@ -12,24 +12,28 @@
}
.topIcon {
--premium-gradient: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%);
display: grid;
flex-shrink: 0;
place-items: center;
width: 6rem;
height: 6rem;
margin-bottom: 1rem;
border-radius: 50%;
font-size: 4rem;
color: white;
color: var(--color-white);
background: var(--color-primary);
}
.premiumGradient {
--premium-gradient: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%);
background: var(--premium-gradient);
}
.listItemIcon {
font-size: 1.875rem !important;
color: var(--accent-color) !important;
}
@ -37,6 +41,7 @@
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
}

View File

@ -15,13 +15,15 @@ import styles from './TableAboutModal.module.scss';
export type TableAboutData = [IconName | undefined, TeactNode, TeactNode][];
type OwnProps = {
className?: string;
contentClassName?: string;
isOpen?: boolean;
listItemData?: TableAboutData;
headerIconName?: IconName;
headerIconPremiumGradient?: boolean;
header?: TeactNode;
footer?: TeactNode;
buttonText?: string;
buttonText?: TeactNode;
hasBackdrop?: boolean;
withSeparator?: boolean;
onClose: NoneToVoidFunction;
@ -29,28 +31,34 @@ type OwnProps = {
};
const TableAboutModal = ({
className,
isOpen,
listItemData,
headerIconName,
headerIconPremiumGradient,
header,
footer,
buttonText,
hasBackdrop,
withSeparator,
contentClassName,
onClose,
onButtonClick,
contentClassName,
}: OwnProps) => {
return (
<Modal
isOpen={isOpen}
className={buildClassName(styles.root, contentClassName)}
contentClassName={styles.content}
className={buildClassName(styles.root, className)}
contentClassName={buildClassName(styles.content, contentClassName)}
hasAbsoluteCloseButton
absoluteCloseButtonColor={hasBackdrop ? 'translucent-white' : undefined}
onClose={onClose}
>
{headerIconName && <div className={styles.topIcon}><Icon name={headerIconName} /></div>}
{headerIconName && (
<div className={buildClassName(styles.topIcon, headerIconPremiumGradient && styles.premiumGradient)}>
<Icon name={headerIconName} />
</div>
)}
{header}
<div>
{listItemData?.map(([icon, title, subtitle]) => {
@ -69,7 +77,7 @@ const TableAboutModal = ({
</div>
{withSeparator && <Separator className={styles.separator} />}
{footer}
{buttonText && (
{Boolean(buttonText) && (
<Button onClick={onButtonClick || onClose}>{buttonText}</Button>
)}
</Modal>

View File

@ -91,27 +91,31 @@ const FrozenAccountModal = ({
);
}, [lang, isOpen]);
if (!freezeUntilDate || !botFreezeAppealUsername) return undefined;
const listItemData = useMemo(() => {
if (!freezeUntilDate || !botFreezeAppealUsername) return undefined;
const date = new Date(freezeUntilDate * 1000);
const date = new Date(freezeUntilDate * 1000);
const botLink = (
<Link onClick={handleAppeal} isPrimary>
{formatUsername(botFreezeAppealUsername)}
</Link>
);
const botLink = (
<Link onClick={handleAppeal} isPrimary>
{formatUsername(botFreezeAppealUsername)}
</Link>
);
const listItemData = [
['hand-stop', lang('FrozenAccountViolationTitle'), lang('FrozenAccountViolationSubtitle')],
['lock', lang('FrozenAccountReadOnlyTitle'), lang('FrozenAccountReadOnlySubtitle')],
['frozen-time', lang('FrozenAccountAppealTitle'),
lang('FrozenAccountAppealSubtitle', {
botLink,
date: formatDateToString(date, lang.code),
}, {
withNodes: true,
})],
] satisfies TableAboutData;
return [
['hand-stop', lang('FrozenAccountViolationTitle'), lang('FrozenAccountViolationSubtitle')],
['lock', lang('FrozenAccountReadOnlyTitle'), lang('FrozenAccountReadOnlySubtitle')],
['frozen-time', lang('FrozenAccountAppealTitle'),
lang('FrozenAccountAppealSubtitle', {
botLink,
date: formatDateToString(date, lang.code),
}, {
withNodes: true,
})],
] satisfies TableAboutData;
}, [lang, botFreezeAppealUsername, freezeUntilDate]);
if (!listItemData) return undefined;
return (
<TableAboutModal

View File

@ -6,10 +6,6 @@
width: 100%;
}
.titleContainer {
padding-bottom: 0.5rem;
}
.profileBlock {
position: relative;
margin-bottom: 0.5rem;
@ -47,7 +43,6 @@
}
.status {
padding-bottom: 1rem;
font-size: 0.875rem;
text-align: center;
}
@ -58,7 +53,6 @@
}
.giftTitle {
padding-bottom: 0.5rem;
font-size: 1.5rem;
font-weight: var(--font-weight-medium);
text-align: center;
@ -71,5 +65,4 @@
.footer {
display: flex;
align-self: stretch;
margin-top: 0.5rem;
}

View File

@ -107,19 +107,13 @@ const GiftStatusInfoModal = ({
{lang('Online')}
</p>
</div>
<div className={styles.titleContainer}>
<div className={styles.giftTitle}>
{
lang('UniqueStatusWearTitle', {
gift: mockPeerWithStatus?.emojiStatus?.title,
})
}
</div>
<div className={styles.infoDescription}>
{
lang('UniqueStatusBenefitsDescription')
}
</div>
<div className={styles.giftTitle}>
{lang('UniqueStatusWearTitle', {
gift: mockPeerWithStatus?.emojiStatus?.title,
})}
</div>
<div className={styles.infoDescription}>
{lang('UniqueStatusBenefitsDescription')}
</div>
</div>
);

View File

@ -0,0 +1,14 @@
import type { OwnProps } from './StealthModeModal';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const StealthModeModalAsync = (props: OwnProps) => {
const { modal } = props;
const StealthModeModal = useModuleLoader(Bundles.Extra, 'StealthModeModal', !modal);
return StealthModeModal ? <StealthModeModal {...props} /> : undefined;
};
export default StealthModeModalAsync;

View File

@ -0,0 +1,12 @@
.title {
margin-block: 0;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.description {
font-size: 0.875rem;
color: var(--color-text-secondary);
text-align: center;
text-wrap: balance;
}

View File

@ -0,0 +1,131 @@
import { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiStealthMode } from '../../../api/types';
import type { TabState } from '../../../global/types';
import { selectIsCurrentUserPremium, selectIsStoryViewerOpen } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { getServerTime } from '../../../util/serverTime';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useForceUpdate from '../../../hooks/useForceUpdate';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import TextTimer from '../../ui/TextTimer';
import TableAboutModal, { type TableAboutData } from '../common/TableAboutModal';
import styles from './StealthModeModal.module.scss';
export type OwnProps = {
modal: TabState['storyStealthModal'];
};
type StateProps = {
isStoryViewerOpen?: boolean;
stealthMode?: ApiStealthMode;
isCurrentUserPremium?: boolean;
};
const StealthModeModal = ({
modal, isStoryViewerOpen, stealthMode, isCurrentUserPremium,
}: OwnProps & StateProps) => {
const {
closeStealthModal,
activateStealthMode,
showNotification,
openPremiumModal,
openStoryViewer,
} = getActions();
const lang = useLang();
const forceUpdate = useForceUpdate();
const isOnCooldown = Boolean(stealthMode?.cooldownUntil && stealthMode.cooldownUntil > getServerTime());
const isOpen = Boolean(modal);
const renderingModal = useCurrentOrPrev(modal);
const { targetPeerId } = renderingModal || {};
const handleTimerEnds = useLastCallback(() => {
forceUpdate();
});
const handleClose = useLastCallback(() => {
closeStealthModal();
});
const handleActivate = useLastCallback(() => {
if (!isCurrentUserPremium) {
openPremiumModal({ initialSection: 'stories' });
handleClose();
return;
}
activateStealthMode();
showNotification({
title: {
key: 'StealthModeOnTitle',
},
message: {
key: 'StealthModeOnHintEnabled',
},
});
if (targetPeerId) {
openStoryViewer({ peerId: targetPeerId });
}
handleClose();
});
const header = useMemo(() => {
return (
<>
<h3 className={styles.title}>{lang('StealthModeTitle')}</h3>
<div className={styles.description}>
{lang(isCurrentUserPremium ? 'StealthModeDescription' : 'StealthModeDescriptionPremium')}
</div>
</>
);
}, [lang, isCurrentUserPremium]);
const listItemData = useMemo(() => {
return [
['stealth-past', lang('StealthModeHideRecentTitle'), lang('StealthModeHideRecentDescription')],
['stealth-future', lang('StealthModeHideFutureTitle'), lang('StealthModeHideFutureDescription')],
] satisfies TableAboutData;
}, [lang]);
const buttonText = useMemo(() => {
if (!isCurrentUserPremium) return lang('StealthModeButtonPremium');
if (isOnCooldown) {
return lang('StealthModeButtonRecharge', {
timer: <TextTimer endsAt={stealthMode!.cooldownUntil!} onEnd={handleTimerEnds} />,
}, { withNodes: true });
}
if (targetPeerId) return lang('StealthModeButtonToStory');
return lang('StealthModeButton');
}, [isCurrentUserPremium, isOnCooldown, lang, stealthMode, targetPeerId]);
return (
<TableAboutModal
isOpen={isOpen}
className={buildClassName(isStoryViewerOpen && 'component-theme-dark')}
header={header}
headerIconName="eye-crossed-outline"
listItemData={listItemData}
buttonText={buttonText}
onButtonClick={handleActivate}
onClose={handleClose}
/>
);
};
export default memo(withGlobal((global): Complete<StateProps> => {
return {
isStoryViewerOpen: selectIsStoryViewerOpen(global),
stealthMode: global.stories.stealthMode,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
};
})(StealthModeModal));

View File

@ -1,55 +0,0 @@
.root {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.closeButton {
position: absolute;
top: 0.5rem;
left: 0.5rem;
}
.stealthIcon {
display: grid;
place-items: center;
width: 5rem;
height: 5rem;
border-radius: 50%;
font-size: 3rem;
background-color: var(--color-primary);
}
.title {
margin-top: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.description {
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-align: center;
}
.listItem {
align-self: stretch;
}
.icon {
margin-right: 1rem !important;
color: var(--color-primary) !important;
}
.button {
margin-top: 1rem;
}
.subtitle {
line-height: 1.25rem !important;
}

View File

@ -1,135 +0,0 @@
import { memo, useEffect, useState } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiStealthMode } from '../../api/types';
import { selectIsCurrentUserPremium, selectTabState } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { getServerTime } from '../../util/serverTime';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import Icon from '../common/icons/Icon';
import Button from '../ui/Button';
import ListItem from '../ui/ListItem';
import Modal from '../ui/Modal';
import TextTimer from '../ui/TextTimer';
import styles from './StealthModeModal.module.scss';
type StateProps = {
isOpen?: boolean;
stealthMode?: ApiStealthMode;
isCurrentUserPremium?: boolean;
};
const StealthModeModal = ({ isOpen, stealthMode, isCurrentUserPremium }: StateProps) => {
const {
toggleStealthModal,
activateStealthMode,
showNotification,
openPremiumModal,
} = getActions();
const [isOnCooldown, setIsOnCooldown] = useState(false);
useEffect(() => {
if (!stealthMode) return;
const serverTime = getServerTime();
if (stealthMode.cooldownUntil && stealthMode.cooldownUntil > serverTime) {
setIsOnCooldown(true);
}
}, [stealthMode, isOpen]);
const lang = useOldLang();
const handleTimerEnds = useLastCallback(() => {
setIsOnCooldown(false);
});
const handleClose = useLastCallback(() => {
toggleStealthModal({ isOpen: false });
});
const handleActivate = useLastCallback(() => {
if (!isCurrentUserPremium) {
openPremiumModal({ initialSection: 'stories' });
return;
}
activateStealthMode();
showNotification({
title: lang('StealthModeOn'),
message: lang('StealthModeOnHint'),
});
toggleStealthModal({ isOpen: false });
});
return (
<Modal
className="component-theme-dark"
contentClassName={styles.root}
isOpen={isOpen}
isSlim
onClose={handleClose}
>
<Button
round
color="translucent"
size="smaller"
className={styles.closeButton}
ariaLabel={lang('Close')}
onClick={handleClose}
>
<Icon name="close" />
</Button>
<div className={styles.stealthIcon}>
<Icon name="eye-crossed-outline" />
</div>
<div className={styles.title}>{lang('StealthMode')}</div>
<div className={styles.description}>
{lang(isCurrentUserPremium ? 'StealthModeHint' : 'StealthModePremiumHint')}
</div>
<ListItem
className={buildClassName(styles.listItem, 'smaller-icon')}
multiline
inactive
leftElement={<Icon name="stealth-past" className={styles.icon} />}
>
<span className="title">{lang('HideRecentViews')}</span>
<span className={buildClassName('subtitle', styles.subtitle)}>{lang('HideRecentViewsDescription')}</span>
</ListItem>
<ListItem
className={buildClassName(styles.listItem, 'smaller-icon')}
multiline
inactive
leftElement={<Icon name="stealth-future" className={styles.icon} aria-hidden />}
>
<span className="title">{lang('HideNextViews')}</span>
<span className={buildClassName('subtitle', styles.subtitle)}>{lang('HideNextViewsDescription')}</span>
</ListItem>
<Button
className={styles.button}
disabled={isOnCooldown}
isShiny={!isCurrentUserPremium}
withPremiumGradient={!isCurrentUserPremium}
onClick={handleActivate}
>
{!isCurrentUserPremium ? lang('UnlockStealthMode')
: isOnCooldown
? (<TextTimer langKey="AvailableIn" endsAt={stealthMode!.cooldownUntil!} onEnd={handleTimerEnds} />)
: lang('EnableStealthMode')}
</Button>
</Modal>
);
};
export default memo(withGlobal((global): Complete<StateProps> => {
const tabState = selectTabState(global);
return {
isOpen: tabState.storyViewer?.isStealthModalOpen,
stealthMode: global.stories.stealthMode,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
};
})(StealthModeModal));

View File

@ -137,11 +137,11 @@ function Story({
isCurrentUserPremium,
stealthMode,
withHeaderAnimation,
paidMessagesStars,
isAccountFrozen,
onDelete,
onClose,
onReport,
paidMessagesStars,
isAccountFrozen,
}: OwnProps & StateProps) {
const {
viewStory,
@ -158,7 +158,7 @@ function Story({
loadPeerSettings,
fetchChat,
loadStoryViews,
toggleStealthModal,
openStealthModal,
} = getActions();
const serverTime = getServerTime();
@ -211,7 +211,7 @@ function Story({
isOut && (story.date + viewersExpirePeriod) < getServerTime(),
);
const forwardSenderTitle = forwardSender ? getPeerTitle(oldLang, forwardSender)
const forwardSenderTitle = forwardSender ? getPeerTitle(lang, forwardSender)
: (isLoadedStory && story.forwardInfo?.fromName);
const canCopyLink = Boolean(
@ -505,7 +505,7 @@ function Story({
: story.isForContacts ? 'contacts' : (story.isForCloseFriends ? 'closeFriends' : 'nobody');
let message;
const myName = getPeerTitle(oldLang, peer);
const myName = getPeerTitle(lang, peer);
switch (visibility) {
case 'nobody':
message = oldLang('StorySelectedContactsHint', myName);
@ -538,14 +538,14 @@ function Story({
if (stealthMode.activeUntil && getServerTime() < stealthMode.activeUntil) {
const diff = stealthMode.activeUntil - getServerTime();
showNotification({
title: oldLang('StealthModeOn'),
message: oldLang('Story.ToastStealthModeActiveText', formatMediaDuration(diff)),
title: lang('StealthModeOnTitle'),
message: lang('StealthModeOnHint', { time: formatMediaDuration(diff) }),
duration: STEALTH_MODE_NOTIFICATION_DURATION,
});
return;
}
toggleStealthModal({ isOpen: true });
openStealthModal({});
});
const handleDownload = useLastCallback(() => {
@ -656,7 +656,7 @@ function Story({
/>
<div className={styles.senderMeta}>
<span onClick={handleOpenChat} className={styles.senderName}>
{renderText(getPeerTitle(oldLang, peer) || '')}
{renderText(getPeerTitle(lang, peer) || '')}
</span>
<div className={styles.storyMetaRow}>
{forwardSenderTitle && (
@ -681,7 +681,7 @@ function Story({
>
<Avatar peer={fromPeer} size="micro" />
<span className={styles.headerTitle}>
{renderText(getPeerTitle(oldLang, fromPeer) || '')}
{renderText(getPeerTitle(lang, fromPeer) || '')}
</span>
</span>
)}
@ -882,7 +882,7 @@ function Story({
withStory
storyViewerMode="disabled"
/>
<div className={styles.name}>{renderText(getPeerTitle(oldLang, peer) || '')}</div>
<div className={styles.name}>{renderText(getPeerTitle(lang, peer) || '')}</div>
</div>
</div>
)}
@ -949,7 +949,6 @@ export default memo(withGlobal<OwnProps>((global, {
isMuted,
viewModal,
isPrivacyModalOpen,
isStealthModalOpen,
storyList,
},
forwardMessages: { storyId: forwardedStoryId },
@ -959,8 +958,10 @@ export default memo(withGlobal<OwnProps>((global, {
reportModal,
giftInfoModal,
isPaymentMessageConfirmDialogOpen,
storyStealthModal,
} = tabState;
const { isOpen: isPremiumModalOpen } = premiumModal || {};
const isStealthModalOpen = Boolean(storyStealthModal);
const story = selectPeerStory(global, peerId, storyId);
const isLoadedStory = story && 'content' in story;
const shouldForcePause = Boolean(

View File

@ -21,6 +21,7 @@ interface OwnProps {
interface StateProps {
orderedPeerIds: string[];
stealthModeActiveUntil?: number;
usersById: Record<string, ApiUser>;
chatsById: Record<string, ApiChat>;
}
@ -29,6 +30,7 @@ function StoryRibbon({
isArchived,
className,
orderedPeerIds,
stealthModeActiveUntil,
usersById,
chatsById,
isClosing,
@ -65,6 +67,7 @@ function StoryRibbon({
key={peerId}
peer={peer}
isArchived={isArchived}
stealthModeActiveUntil={stealthModeActiveUntil}
/>
);
})}
@ -78,8 +81,11 @@ export default memo(withGlobal<OwnProps>(
const usersById = global.users.byId;
const chatsById = global.chats.byId;
const stealthMode = global.stories.stealthMode;
return {
orderedPeerIds: isArchived ? archived : active,
stealthModeActiveUntil: stealthMode.activeUntil,
usersById,
chatsById,
};

View File

@ -1,4 +1,3 @@
import type React from '../../lib/teact/teact';
import { memo, useRef } from '../../lib/teact/teact';
import { getActions } from '../../global';
@ -7,12 +6,14 @@ import { StoryViewerOrigin } from '../../types';
import { getPeerTitle } from '../../global/helpers/peers';
import buildClassName from '../../util/buildClassName';
import { formatMediaDuration } from '../../util/dates/dateFormat';
import { isUserId } from '../../util/entities/ids';
import { getServerTime } from '../../util/serverTime';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import useStoryPreloader from './hooks/useStoryPreloader';
import Avatar from '../common/Avatar';
@ -24,17 +25,22 @@ import styles from './StoryRibbon.module.scss';
interface OwnProps {
peer: ApiPeer;
isArchived?: boolean;
stealthModeActiveUntil?: number;
}
function StoryRibbonButton({ peer, isArchived }: OwnProps) {
const STEALTH_MODE_NOTIFICATION_DURATION = 1000;
function StoryRibbonButton({ peer, isArchived, stealthModeActiveUntil }: OwnProps) {
const {
openChat,
openChatWithInfo,
openStoryViewer,
toggleStoriesHidden,
openStealthModal,
showNotification,
} = getActions();
const lang = useOldLang();
const lang = useLang();
const ref = useRef<HTMLDivElement>();
const isSelf = 'isSelf' in peer && peer.isSelf;
@ -84,6 +90,21 @@ function StoryRibbonButton({ peer, isArchived }: OwnProps) {
toggleStoriesHidden({ peerId: peer.id, isHidden: !isArchived });
});
const handleOpenStealth = useLastCallback(() => {
const diff = stealthModeActiveUntil ? stealthModeActiveUntil - getServerTime() : 0;
if (diff > 0) {
showNotification({
title: lang('StealthModeOnTitle'),
message: lang('StealthModeOnHint', { time: formatMediaDuration(diff) }),
duration: STEALTH_MODE_NOTIFICATION_DURATION,
});
openStoryViewer({ peerId: peer.id, origin: StoryViewerOrigin.StoryRibbon });
return;
}
openStealthModal({ targetPeerId: peer.id });
});
return (
<div
ref={ref}
@ -102,7 +123,7 @@ function StoryRibbonButton({ peer, isArchived }: OwnProps) {
storyViewerMode="full"
/>
<div className={buildClassName(styles.name, peer.hasUnreadStories && styles.name_hasUnreadStory)}>
{isSelf ? lang('MyStory') : getPeerTitle(lang, peer)}
{isSelf ? lang('StoryRibbonMyStory') : getPeerTitle(lang, peer)}
</div>
{contextMenuAnchor !== undefined && (
<Menu
@ -121,33 +142,38 @@ function StoryRibbonButton({ peer, isArchived }: OwnProps) {
{isSelf ? (
<>
<MenuItem onClick={handleSavedStories} icon="play-story">
{lang('StoryList.Context.SavedStories')}
{lang('StoryMenuSavedStories')}
</MenuItem>
<MenuItem onClick={handleArchivedStories} icon="archive">
{lang('StoryList.Context.ArchivedStories')}
{lang('StoryMenuArchivedStories')}
</MenuItem>
</>
) : (
<>
{!isChannel && (
<MenuItem onClick={handleOpenChat} icon="message">
{lang('SendMessageTitle')}
{lang('StoryMenuSendMessage')}
</MenuItem>
)}
{isChannel ? (
<MenuItem onClick={handleOpenProfile} icon="channel">
{lang('ChatList.ContextOpenChannel')}
{lang('StoryMenuViewChannel')}
</MenuItem>
) : (
<MenuItem onClick={handleOpenProfile} icon="user">
{lang('StoryList.Context.ViewProfile')}
{lang('StoryMenuViewProfile')}
</MenuItem>
)}
{!isChannel && (
<MenuItem onClick={handleOpenStealth} icon="eye-crossed-outline">
{lang('StoryMenuOpenStealth')}
</MenuItem>
)}
<MenuItem
onClick={handleArchivePeer}
icon={isArchived ? 'unarchive' : 'archive'}
>
{lang(isArchived ? 'StoryList.Context.Unarchive' : 'StoryList.Context.Archive')}
{lang(isArchived ? 'StoryMenuUnarchivePeer' : 'StoryMenuArchivePeer')}
</MenuItem>
</>
)}

View File

@ -29,7 +29,6 @@ import useStoryProps from './hooks/useStoryProps';
import Icon from '../common/icons/Icon';
import Button from '../ui/Button';
import ShowTransition from '../ui/ShowTransition';
import StealthModeModal from './StealthModeModal';
import StoryDeleteConfirmModal from './StoryDeleteConfirmModal';
import StorySettings from './StorySettings';
import StorySlides from './StorySlides';
@ -175,7 +174,6 @@ function StoryViewer({
onClose={handleCloseDeleteModal}
/>
<StoryViewModal />
<StealthModeModal />
<StorySettings isOpen={isPrivacyModalOpen} onClose={closeStoryPrivacyEditor} />
</ShowTransition>
);

View File

@ -25,7 +25,6 @@
}
.Button {
--premium-gradient: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%);
cursor: var(--custom-cursor, pointer);
@ -56,6 +55,8 @@
transition: background-color 0.15s, color 0.15s, opacity 0.15s;
white-space-collapse: preserve;
// @optimization
&:active,
&.clicked,
@ -67,6 +68,10 @@
text-transform: none;
}
&.inline {
display: inline;
}
&.disabled {
cursor: var(--custom-cursor, default);

View File

@ -35,6 +35,7 @@ export type OwnProps = {
pill?: boolean;
badge?: boolean;
fluid?: boolean;
inline?: boolean;
isText?: boolean;
isLoading?: boolean;
ariaLabel?: string;
@ -88,6 +89,7 @@ const Button: FC<OwnProps> = ({
pill,
badge,
fluid,
inline,
isText,
isLoading,
isShiny,
@ -157,6 +159,7 @@ const Button: FC<OwnProps> = ({
withPremiumGradient && 'premium',
isRectangular && 'rectangular',
noForcedUpperCase && 'no-upper-case',
inline && 'inline',
iconAlignment && iconName && `content-with-icon-${iconAlignment}`,
);

View File

@ -1,26 +1,21 @@
import { type FC, memo, useEffect } from '../../lib/teact/teact';
import { useEffect } from '../../lib/teact/teact';
import { formatMediaDuration } from '../../util/dates/dateFormat';
import { getServerTime } from '../../util/serverTime';
import useInterval from '../../hooks/schedulers/useInterval';
import useForceUpdate from '../../hooks/useForceUpdate';
import useLang from '../../hooks/useLang';
import useOldLang from '../../hooks/useOldLang';
import AnimatedCounter from '../common/AnimatedCounter';
type OwnProps = {
langKey: string;
endsAt: number;
onEnd?: NoneToVoidFunction;
};
const UPDATE_FREQUENCY = 500; // Sometimes second gets skipped if using 1000
const TextTimer: FC<OwnProps> = ({ langKey, endsAt, onEnd }) => {
const lang = useLang();
const oldLang = useOldLang();
const TextTimer = ({ endsAt, onEnd }: OwnProps) => {
const forceUpdate = useForceUpdate();
const serverTime = getServerTime();
@ -50,21 +45,11 @@ const TextTimer: FC<OwnProps> = ({ langKey, endsAt, onEnd }) => {
</span>
);
const isTypedKey = langKey === 'UnlockTimerPublicPostsSearch';
if (isTypedKey) {
return (
<span>
{lang(langKey, { time: timeCounter }, { withNodes: true })}
</span>
);
}
return (
<span>
{oldLang(langKey, time)}
{timeCounter}
</span>
);
};
export default memo(TextTimer);
export default TextTimer;

View File

@ -5,6 +5,7 @@ import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { omit } from '../../../util/iteratees';
import * as langProvider from '../../../util/oldLangProvider';
import { callApi } from '../../../api/gramjs';
import { addTabStateResetterAction } from '../../helpers/meta';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { addStoriesForPeer } from '../../reducers';
import { updateTabState } from '../../reducers/tabs';
@ -352,18 +353,18 @@ addActionHandler('closeStoryPrivacyEditor', (global, actions, payload): ActionRe
}, tabId);
});
addActionHandler('toggleStealthModal', (global, actions, payload): ActionReturnType => {
const { isOpen, tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
addActionHandler('openStealthModal', (global, actions, payload): ActionReturnType => {
const { targetPeerId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
storyViewer: {
...tabState.storyViewer,
isStealthModalOpen: isOpen,
storyStealthModal: {
targetPeerId,
},
}, tabId);
});
addTabStateResetterAction('closeStealthModal', 'storyStealthModal');
addActionHandler('clearStoryViews', (global, actions, payload): ActionReturnType => {
const { isLoading, tabId = getCurrentTabId() } = payload || {};

View File

@ -1700,9 +1700,10 @@ export interface ActionPayloads {
reaction?: ApiReaction;
shouldAddToRecent?: boolean;
} & WithTabId;
toggleStealthModal: {
isOpen: boolean;
} & WithTabId;
openStealthModal: ({
targetPeerId: string;
} | Record<string, never>) & WithTabId;
closeStealthModal: WithTabId | undefined;
activateStealthMode: {
isForPast?: boolean;
isForFuture?: boolean;

View File

@ -335,7 +335,6 @@ export type TabState = {
lastViewedByPeerId?: Record<string, number>;
isPrivacyModalOpen?: boolean;
isPaymentConfirmDialogOpen?: boolean;
isStealthModalOpen?: boolean;
viewModal?: {
storyId: number;
views?: ApiTypeStoryView[];
@ -349,6 +348,9 @@ export type TabState = {
storyIdsByPeerId: Record<string, number[]>;
};
};
storyStealthModal?: {
targetPeerId: string;
} | Record<string, never>;
selectedStoryAlbumId?: number;

View File

@ -1745,6 +1745,27 @@ export interface LangPair {
'InviteRestrictedPremiumReason': undefined;
'InviteViaLinkButton': undefined;
'StoryUnsupported': undefined;
'StoryRibbonMyStory': undefined;
'StoryMenuSavedStories': undefined;
'StoryMenuArchivedStories': undefined;
'StoryMenuSendMessage': undefined;
'StoryMenuViewProfile': undefined;
'StoryMenuViewChannel': undefined;
'StoryMenuOpenStealth': undefined;
'StoryMenuArchivePeer': undefined;
'StoryMenuUnarchivePeer': undefined;
'StealthModeOnTitle': undefined;
'StealthModeOnHintEnabled': undefined;
'StealthModeTitle': undefined;
'StealthModeDescription': undefined;
'StealthModeDescriptionPremium': undefined;
'StealthModeHideRecentTitle': undefined;
'StealthModeHideRecentDescription': undefined;
'StealthModeHideFutureTitle': undefined;
'StealthModeHideFutureDescription': undefined;
'StealthModeButtonPremium': undefined;
'StealthModeButton': undefined;
'StealthModeButtonToStory': undefined;
}
export interface LangPairWithVariables<V = LangVariable> {
@ -1900,6 +1921,9 @@ export interface LangPairWithVariables<V = LangVariable> {
'ReportChat': {
'peer': V;
};
'SlowModePlaceholder': {
'timer': V;
};
'SlowModeHint': {
'time': V;
};
@ -2998,6 +3022,15 @@ export interface LangPairWithVariables<V = LangVariable> {
'InviteRestrictedPremiumReasonMultiple': {
'list': V;
};
'StealthModeOnHint': {
'time': V;
};
'StealthModeButtonRecharge': {
'timer': V;
};
'StealthModeComposerPlaceholder': {
'timer': V;
};
}
export interface LangPairPlural {

View File

@ -369,9 +369,12 @@ function processTranslation(
variables?: Record<string, LangVariable | RegularLangFnParameters>,
options?: LangFnOptions | LangFnOptionsWithPlural,
): string {
const cacheKey = `${langKey}-${JSON.stringify(variables)}-${JSON.stringify(options)}`;
if (TRANSLATION_CACHE.has(cacheKey)) {
return TRANSLATION_CACHE.get(cacheKey)!;
const isCacheable = !options?.withNodes;
const cacheKey = isCacheable ? `${langKey}-${JSON.stringify(variables)}-${JSON.stringify(options)}` : undefined;
if (cacheKey) {
if (TRANSLATION_CACHE.has(cacheKey)) {
return TRANSLATION_CACHE.get(cacheKey)!;
}
}
const pluralValue = options && 'pluralValue' in options ? Number(options.pluralValue) : 0;
@ -390,7 +393,9 @@ function processTranslation(
return result.replaceAll(`{${key}}`, valueAsString);
}, string);
TRANSLATION_CACHE.set(cacheKey, finalString);
if (cacheKey) {
TRANSLATION_CACHE.set(cacheKey, finalString);
}
return finalString;
}