From d1d463c7d2f64d2cfc6e610b9c47990a303463a5 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 31 Dec 2021 18:17:49 +0100 Subject: [PATCH] Support protected ("no forwards") chats and messages (#1602) --- src/api/gramjs/apiBuilders/chats.ts | 3 + src/api/gramjs/apiBuilders/messages.ts | 3 +- src/api/gramjs/methods/chats.ts | 11 +++ src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/updater.ts | 13 ++++ src/api/types/chats.ts | 1 + src/api/types/messages.ts | 1 + src/components/common/EmbeddedMessage.tsx | 25 ++++--- src/components/common/Media.tsx | 20 +++++- src/components/common/WebLink.tsx | 5 +- src/components/left/search/LinkResults.tsx | 2 + src/components/left/search/MediaResults.tsx | 2 + .../search/helpers/createMapStateToProps.ts | 4 +- src/components/main/Main.tsx | 6 +- .../mediaViewer/MediaViewerActions.tsx | 72 +++++++++++-------- .../mediaViewer/MediaViewerContent.tsx | 15 ++-- .../mediaViewer/helpers/ghostAnimation.ts | 3 + src/components/middle/message/Album.tsx | 4 ++ .../middle/message/ContextMenuContainer.tsx | 10 +-- src/components/middle/message/Message.scss | 4 ++ src/components/middle/message/Message.tsx | 11 +++ src/components/middle/message/Photo.tsx | 4 ++ src/components/middle/message/Video.tsx | 5 ++ src/components/middle/message/WebPage.tsx | 4 ++ .../middle/message/hooks/useOuterHandlers.ts | 4 +- src/components/right/Profile.tsx | 5 ++ .../management/ManageChatPrivacyType.tsx | 39 +++++++++- src/config.ts | 2 +- src/global/types.ts | 2 +- src/lib/gramjs/tl/apiTl.js | 1 + src/lib/gramjs/tl/static/api.reduced.tl | 1 + src/modules/actions/api/chats.ts | 11 +++ src/modules/selectors/messages.ts | 4 ++ src/styles/index.scss | 9 +++ src/util/fallbackLangPack.ts | 24 +++++++ src/util/stopEvent.ts | 6 ++ 36 files changed, 274 insertions(+), 64 deletions(-) create mode 100644 src/util/stopEvent.ts diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 40a77fcda..5bdf23bc0 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -49,6 +49,9 @@ function buildApiChatFieldsFromPeerEntity( ...(peerEntity.participantsCount && { membersCount: peerEntity.participantsCount }), joinDate: peerEntity.date, }), + ...((peerEntity instanceof GramJs.Chat || peerEntity instanceof GramJs.Channel) && { + isProtected: Boolean('noforwards' in peerEntity && peerEntity.noforwards), + }), ...(isSupport && { isSupport: true }), ...buildApiChatPermissions(peerEntity), ...(('creator' in peerEntity) && { isCreator: peerEntity.creator }), diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 063540a06..98a614290 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -140,7 +140,7 @@ type UniversalMessage = ( & Pick, ( 'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' | 'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' | - 'replies' | 'fromScheduled' | 'postAuthor' + 'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' )> ); @@ -209,6 +209,7 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM ...(mtpMessage.viaBotId && { viaBotId: buildApiPeerId(mtpMessage.viaBotId, 'user') }), ...(replies?.comments && { threadInfo: buildThreadInfo(replies, mtpMessage.id, chatId) }), ...(postAuthor && { adminTitle: postAuthor }), + ...(mtpMessage.noforwards && { isProtected: true }), }; } diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 2b7621855..5a891c33d 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -1130,3 +1130,14 @@ export async function importChatInvite({ hash }: { hash: string }) { return buildApiChatFromPreview(updates.chats[0]); } + +export function toggleIsProtected({ + chat, isProtected, +}: { chat: ApiChat; isProtected: boolean }) { + const { id, accessHash } = chat; + + return invokeRequest(new GramJs.messages.ToggleNoForwards({ + peer: buildInputPeer(id, accessHash), + enabled: isProtected, + }), true); +} diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 2dbafbecd..e65c49b1b 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -14,7 +14,7 @@ export { fetchChatFolders, editChatFolder, deleteChatFolder, fetchRecommendedChatFolders, getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights, updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup, - migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, + migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected, } from './chats'; export { diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index de57ebea1..e652e5b92 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -61,6 +61,9 @@ export function init(_onUpdate: OnApiUpdate) { const sentMessageIds = new Set(); let serverTimeOffset = 0; +// Workaround for a situation when an incorrect update comes with an undefined property `adminRights` +let shouldIgnoreNextChannelUpdate = false; +const IGNORE_NEXT_CHANNEL_UPDATE_TIMEOUT = 2000; function dispatchUserAndChatUpdates(entities: (GramJs.TypeUser | GramJs.TypeChat)[]) { entities @@ -636,6 +639,16 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { )); if (channel instanceof GramJs.Channel) { + if (shouldIgnoreNextChannelUpdate) { + shouldIgnoreNextChannelUpdate = false; + return; + } + + if (originRequest instanceof GramJs.messages.ToggleNoForwards) { + shouldIgnoreNextChannelUpdate = true; + setTimeout(() => { shouldIgnoreNextChannelUpdate = false; }, IGNORE_NEXT_CHANNEL_UPDATE_TIMEOUT); + } + const chat = buildApiChatFromPreview(channel); if (chat) { onUpdate({ diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 53dc72c7f..69be7ebdb 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -31,6 +31,7 @@ export interface ApiChat { isSupport?: boolean; photos?: ApiPhoto[]; draftDate?: number; + isProtected?: boolean; // Calls isCallActive?: boolean; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 86b5c5ea6..ebb712649 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -265,6 +265,7 @@ export interface ApiMessage { shouldHideKeyboardButtons?: boolean; isFromScheduled?: boolean; seenByUserIds?: string[]; + isProtected?: boolean; } export interface ApiThreadInfo { diff --git a/src/components/common/EmbeddedMessage.tsx b/src/components/common/EmbeddedMessage.tsx index efca04017..8cbb16651 100644 --- a/src/components/common/EmbeddedMessage.tsx +++ b/src/components/common/EmbeddedMessage.tsx @@ -28,6 +28,7 @@ type OwnProps = { sender?: ApiUser | ApiChat; title?: string; customText?: string; + isProtected?: boolean; onClick: NoneToVoidFunction; }; @@ -39,6 +40,7 @@ const EmbeddedMessage: FC = ({ sender, title, customText, + isProtected, observeIntersection, onClick, }) => { @@ -61,7 +63,7 @@ const EmbeddedMessage: FC = ({ className={buildClassName('EmbeddedMessage', className)} onClick={message ? onClick : undefined} > - {mediaThumbnail && renderPictogram(pictogramId, mediaThumbnail, mediaBlobUrl, isRoundVideo)} + {mediaThumbnail && renderPictogram(pictogramId, mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected)}

{!message ? ( @@ -83,18 +85,23 @@ function renderPictogram( thumbDataUri: string, blobUrl?: string, isRoundVideo?: boolean, + isProtected?: boolean, ) { const { width, height } = getPictogramDimensions(); return ( - + <> + + {isProtected && } + ); } diff --git a/src/components/common/Media.tsx b/src/components/common/Media.tsx index 74d25aa33..b3254f1a9 100644 --- a/src/components/common/Media.tsx +++ b/src/components/common/Media.tsx @@ -3,6 +3,7 @@ import React, { FC, memo, useCallback } from '../../lib/teact/teact'; import { ApiMessage } from '../../api/types'; import { formatMediaDuration } from '../../util/dateFormat'; +import stopEvent from '../../util/stopEvent'; import { getMessageMediaHash, getMessageMediaThumbDataUri, @@ -17,10 +18,16 @@ import './Media.scss'; type OwnProps = { message: ApiMessage; idPrefix?: string; + isProtected?: boolean; onClick?: (messageId: number, chatId: string) => void; }; -const Media: FC = ({ message, idPrefix = 'shared-media', onClick }) => { +const Media: FC = ({ + message, + idPrefix = 'shared-media', + isProtected, + onClick, +}) => { const handleClick = useCallback(() => { onClick!(message.id, message.chatId); }, [message.id, message.chatId, onClick]); @@ -33,9 +40,16 @@ const Media: FC = ({ message, idPrefix = 'shared-media', onClick }) => return (

- - + + {video && {video.isGif ? 'GIF' : formatMediaDuration(video.duration)}} + {isProtected && }
); }; diff --git a/src/components/common/WebLink.tsx b/src/components/common/WebLink.tsx index 99ca19340..3401e8dc3 100644 --- a/src/components/common/WebLink.tsx +++ b/src/components/common/WebLink.tsx @@ -20,11 +20,12 @@ const MAX_TEXT_LENGTH = 170; // symbols type OwnProps = { message: ApiMessage; senderTitle?: string; + isProtected?: boolean; onMessageClick: (messageId: number, chatId: string) => void; }; const WebLink: FC = ({ - message, senderTitle, onMessageClick, + message, senderTitle, isProtected, onMessageClick, }) => { const lang = useLang(); @@ -76,7 +77,7 @@ const WebLink: FC = ({ dir={lang.isRtl ? 'rtl' : undefined} > {photo && ( - + )}
diff --git a/src/components/left/search/LinkResults.tsx b/src/components/left/search/LinkResults.tsx index 189bca0c4..a251952d1 100644 --- a/src/components/left/search/LinkResults.tsx +++ b/src/components/left/search/LinkResults.tsx @@ -35,6 +35,7 @@ const LinkResults: FC = ({ globalMessagesByChatId, foundIds, lastSyncTime, + isChatProtected, }) => { const { searchMessagesGlobal, @@ -89,6 +90,7 @@ const LinkResults: FC = ({ key={message.id} message={message} senderTitle={getSenderName(lang, message, chatsById, usersById)} + isProtected={isChatProtected || message.isProtected} onMessageClick={handleMessageFocus} />
diff --git a/src/components/left/search/MediaResults.tsx b/src/components/left/search/MediaResults.tsx index 97055481b..737064cff 100644 --- a/src/components/left/search/MediaResults.tsx +++ b/src/components/left/search/MediaResults.tsx @@ -33,6 +33,7 @@ const MediaResults: FC = ({ globalMessagesByChatId, foundIds, lastSyncTime, + isChatProtected, }) => { const { searchMessagesGlobal, @@ -81,6 +82,7 @@ const MediaResults: FC = ({ key={message.id} idPrefix="search-media" message={message} + isProtected={isChatProtected || message.isProtected} onClick={handleSelectMedia} /> ))} diff --git a/src/components/left/search/helpers/createMapStateToProps.ts b/src/components/left/search/helpers/createMapStateToProps.ts index a07d95014..5c28a6c65 100644 --- a/src/components/left/search/helpers/createMapStateToProps.ts +++ b/src/components/left/search/helpers/createMapStateToProps.ts @@ -4,7 +4,7 @@ import { } from '../../../../api/types'; import { ISettings } from '../../../../types'; -import { selectTheme } from '../../../../modules/selectors'; +import { selectChat, selectTheme } from '../../../../modules/selectors'; export type StateProps = { theme: ISettings['theme']; @@ -16,6 +16,7 @@ export type StateProps = { lastSyncTime?: number; searchChatId?: string; activeDownloads: Record; + isChatProtected?: boolean; }; export function createMapStateToProps(type: ApiGlobalMessageSearchType) { @@ -46,6 +47,7 @@ export function createMapStateToProps(type: ApiGlobalMessageSearchType) { foundIds, searchChatId: chatId, activeDownloads, + isChatProtected: chatId ? selectChat(global, chatId)?.isProtected : undefined, lastSyncTime: global.lastSyncTime, }; }; diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 4a85cb177..328f7fdff 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -23,6 +23,7 @@ import buildClassName from '../../util/buildClassName'; import { fastRaf } from '../../util/schedulers'; import { waitForTransitionEnd } from '../../util/cssAnimationEndListeners'; import { processDeepLink } from '../../util/deeplink'; +import stopEvent from '../../util/stopEvent'; import windowSize from '../../util/windowSize'; import useShowTransition from '../../hooks/useShowTransition'; import useBackgroundMode from '../../hooks/useBackgroundMode'; @@ -273,11 +274,6 @@ const Main: FC = ({ usePreventPinchZoomGesture(isMediaViewerOpen); - function stopEvent(e: React.MouseEvent) { - e.preventDefault(); - e.stopPropagation(); - } - return (
diff --git a/src/components/mediaViewer/MediaViewerActions.tsx b/src/components/mediaViewer/MediaViewerActions.tsx index 20c0ccc6f..c6fba6f11 100644 --- a/src/components/mediaViewer/MediaViewerActions.tsx +++ b/src/components/mediaViewer/MediaViewerActions.tsx @@ -12,7 +12,7 @@ import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { getMessageMediaHash } from '../../modules/helpers'; import useLang from '../../hooks/useLang'; import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; -import { selectIsDownloading } from '../../modules/selectors'; +import { selectIsDownloading, selectIsMessageProtected } from '../../modules/selectors'; import Button from '../ui/Button'; import DropdownMenu from '../ui/DropdownMenu'; @@ -23,6 +23,7 @@ import './MediaViewerActions.scss'; type StateProps = { isDownloading: boolean; + isProtected?: boolean; }; type OwnProps = { @@ -45,6 +46,7 @@ const MediaViewerActions: FC = ({ fileName, isAvatar, isDownloading, + isProtected, onCloseMediaViewer, onForward, onZoomToggle, @@ -84,7 +86,44 @@ const MediaViewerActions: FC = ({ ); }, []); + function renderDownloadButton() { + if (isProtected) { + return undefined; + } + + return isVideo ? ( + + ) : ( + + ); + } + if (IS_SINGLE_COLUMN_LAYOUT) { + if (isProtected) { + return undefined; + } + return (
= ({ return (
- {!isAvatar && ( + {!isAvatar && !isProtected && ( <> )} - {isVideo ? ( - - ) : ( - - )} + {renderDownloadButton()}
); @@ -176,10 +180,11 @@ const MediaViewerContent: FC = (props) => {
+ {isProtected &&
} {isPhoto && renderPhoto( localBlobUrl || fullMediaBlobUrl || previewBlobUrl || pictogramBlobUrl, message && calculateMediaViewerDimensions(dimensions!, hasFooter), - false, + !IS_SINGLE_COLUMN_LAYOUT && !isProtected, )} {isVideo && (isActive ? ( = (props) => { ) : renderVideoPreview( bestImageData, message && calculateMediaViewerDimensions(dimensions!, hasFooter, true), - false, + !IS_SINGLE_COLUMN_LAYOUT && !isProtected, ))} {textParts && ( ( senderId: message.senderId, origin, message, + isProtected: selectIsMessageProtected(global, message), }; } @@ -275,6 +281,7 @@ export default memo(withGlobal( senderId: message.senderId, origin, message, + isProtected: selectIsMessageProtected(global, message), }; }, )(MediaViewerContent)); diff --git a/src/components/mediaViewer/helpers/ghostAnimation.ts b/src/components/mediaViewer/helpers/ghostAnimation.ts index bca1c1d71..77e946e2b 100644 --- a/src/components/mediaViewer/helpers/ghostAnimation.ts +++ b/src/components/mediaViewer/helpers/ghostAnimation.ts @@ -10,6 +10,7 @@ import { REM, } from '../../common/helpers/mediaDimensions'; import windowSize from '../../../util/windowSize'; +import stopEvent from '../../../util/stopEvent'; const ANIMATION_DURATION = 200; @@ -208,6 +209,8 @@ function createGhost(source: string | HTMLImageElement | HTMLVideoElement, origi ghost.classList.add('ghost'); const img = new Image(); + img.draggable = false; + img.oncontextmenu = stopEvent; if (typeof source === 'string') { img.src = source; diff --git a/src/components/middle/message/Album.tsx b/src/components/middle/message/Album.tsx index cd3b66ed5..fd4892a80 100644 --- a/src/components/middle/message/Album.tsx +++ b/src/components/middle/message/Album.tsx @@ -30,6 +30,7 @@ type OwnProps = { hasCustomAppendix?: boolean; lastSyncTime?: number; isOwn: boolean; + isProtected?: boolean; albumLayout: IAlbumLayout; onMediaClick: (messageId: number) => void; }; @@ -46,6 +47,7 @@ const Album: FC = ({ hasCustomAppendix, lastSyncTime, isOwn, + isProtected, albumLayout, onMediaClick, uploadsById, @@ -85,6 +87,7 @@ const Album: FC = ({ shouldAffectAppendix={shouldAffectAppendix} uploadProgress={uploadProgress} dimensions={dimensions} + isProtected={isProtected} onClick={onMediaClick} onCancelUpload={handleCancelUpload} isDownloading={activeDownloadIds.includes(message.id)} @@ -102,6 +105,7 @@ const Album: FC = ({ uploadProgress={uploadProgress} lastSyncTime={lastSyncTime} dimensions={dimensions} + isProtected={isProtected} onClick={onMediaClick} onCancelUpload={handleCancelUpload} isDownloading={activeDownloadIds.includes(message.id)} diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 7c92b03d8..d2e6c3b1c 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -11,6 +11,7 @@ import { selectAllowedMessageActions, selectChat, selectCurrentMessageList, + selectIsMessageProtected, } from '../../../modules/selectors'; import { isChatGroup, isOwnMessage } from '../../../modules/helpers'; import { SEEN_BY_MEMBERS_EXPIRE, SEEN_BY_MEMBERS_CHAT_MAX } from '../../../config'; @@ -366,6 +367,7 @@ export default memo(withGlobal( && chat.membersCount && chat.membersCount < SEEN_BY_MEMBERS_CHAT_MAX && message.date > Date.now() / 1000 - SEEN_BY_MEMBERS_EXPIRE); + const isProtected = selectIsMessageProtected(global, message); return { noOptions, @@ -377,13 +379,13 @@ export default memo(withGlobal( canDelete, canReport, canEdit: !isPinned && canEdit, - canForward: !isScheduled && canForward, + canForward: !isProtected && !isScheduled && canForward, canFaveSticker: !isScheduled && canFaveSticker, canUnfaveSticker: !isScheduled && canUnfaveSticker, - canCopy, - canCopyLink: !isScheduled && canCopyLink, + canCopy: !isProtected && canCopy, + canCopyLink: !isProtected && !isScheduled && canCopyLink, canSelect, - canDownload, + canDownload: !isProtected && canDownload, activeDownloads, canShowSeenBy, }; diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index 9b951cae5..5d9a2f733 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -27,6 +27,10 @@ transform: translateX(-2.5rem) !important; } + &.is-protected { + user-select: none; + } + > .Avatar, > .message-content-wrapper { opacity: 1; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 84e855a48..8fbb71fcb 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -45,6 +45,7 @@ import { selectAllowedMessageActions, selectIsDownloading, selectThreadInfo, + selectIsMessageProtected, } from '../../../modules/selectors'; import { getMessageContent, @@ -138,6 +139,7 @@ type StateProps = { replyMessageSender?: ApiUser | ApiChat; outgoingStatus?: ApiMessageOutgoingStatus; uploadProgress?: number; + isProtected?: boolean; isFocused?: boolean; focusDirection?: FocusDirection; noFocusHighlight?: boolean; @@ -201,6 +203,7 @@ const Message: FC = ({ replyMessageSender, outgoingStatus, uploadProgress, + isProtected, isFocused, focusDirection, noFocusHighlight, @@ -325,6 +328,7 @@ const Message: FC = ({ isAlbum, Boolean(isInSelectMode), Boolean(canReply), + Boolean(isProtected), onContextMenu, handleBeforeContextMenu, ); @@ -364,6 +368,7 @@ const Message: FC = ({ const containerClassName = buildClassName( 'Message message-list-item', isFirstInGroup && 'first-in-group', + isProtected && 'is-protected', isLastInGroup && 'last-in-group', isFirstInDocumentGroup && 'first-in-document-group', isLastInDocumentGroup && 'last-in-document-group', @@ -485,6 +490,7 @@ const Message: FC = ({ {hasReply && ( = ({ albumLayout={albumLayout!} observeIntersection={observeIntersectionForMedia} isOwn={isOwn} + isProtected={isProtected} hasCustomAppendix={hasCustomAppendix} lastSyncTime={lastSyncTime} onMediaClick={handleAlbumMediaClick} @@ -530,6 +537,7 @@ const Message: FC = ({ onClick={handleMediaClick} onCancelUpload={handleCancelUpload} isDownloading={isDownloading} + isProtected={isProtected} theme={theme} /> )} @@ -554,6 +562,7 @@ const Message: FC = ({ onClick={handleMediaClick} onCancelUpload={handleCancelUpload} isDownloading={isDownloading} + isProtected={isProtected} /> )} {(audio || voice) && ( @@ -615,6 +624,7 @@ const Message: FC = ({ onMediaClick={handleMediaClick} onCancelMediaTransfer={handleCancelUpload} isDownloading={isDownloading} + isProtected={isProtected} theme={theme} /> )} @@ -880,6 +890,7 @@ export default memo(withGlobal( isThreadTop, replyMessage, replyMessageSender, + isProtected: selectIsMessageProtected(global, message), isFocused, isForwarding, isChatWithSelf, diff --git a/src/components/middle/message/Photo.tsx b/src/components/middle/message/Photo.tsx index 6ade62676..169283dc1 100644 --- a/src/components/middle/message/Photo.tsx +++ b/src/components/middle/message/Photo.tsx @@ -39,6 +39,7 @@ export type OwnProps = { dimensions?: IMediaDimensions & { isSmall?: boolean }; nonInteractive?: boolean; isDownloading: boolean; + isProtected?: boolean; theme: ISettings['theme']; onClick?: (id: number) => void; onCancelUpload?: (message: ApiMessage) => void; @@ -60,6 +61,7 @@ const Photo: FC = ({ nonInteractive, shouldAffectAppendix, isDownloading, + isProtected, theme, onClick, onCancelUpload, @@ -167,7 +169,9 @@ const Photo: FC = ({ width={width} height={height} alt="" + draggable={!isProtected} /> + {isProtected && } {shouldRenderSpinner && !shouldRenderDownloadButton && (
diff --git a/src/components/middle/message/Video.tsx b/src/components/middle/message/Video.tsx index 4db74b1ab..075d12006 100644 --- a/src/components/middle/message/Video.tsx +++ b/src/components/middle/message/Video.tsx @@ -42,6 +42,7 @@ export type OwnProps = { dimensions?: IMediaDimensions; lastSyncTime?: number; isDownloading: boolean; + isProtected?: boolean; onClick?: (id: number) => void; onCancelUpload?: (message: ApiMessage) => void; }; @@ -59,6 +60,7 @@ const Video: FC = ({ onClick, onCancelUpload, isDownloading, + isProtected, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -173,6 +175,7 @@ const Video: FC = ({ // @ts-ignore teact feature style={`width: ${width}px; height: ${height}px;`} alt="" + draggable={!isProtected} /> {isInline && ( )} + {isProtected && } {shouldRenderPlayButton && } {shouldRenderSpinner && (
diff --git a/src/components/middle/message/WebPage.tsx b/src/components/middle/message/WebPage.tsx index cf7507949..8a9859c54 100644 --- a/src/components/middle/message/WebPage.tsx +++ b/src/components/middle/message/WebPage.tsx @@ -27,6 +27,7 @@ type OwnProps = { inPreview?: boolean; lastSyncTime?: number; isDownloading?: boolean; + isProtected?: boolean; theme: ISettings['theme']; onMediaClick?: () => void; onCancelMediaTransfer?: () => void; @@ -41,6 +42,7 @@ const WebPage: FC = ({ inPreview, lastSyncTime, isDownloading = false, + isProtected, theme, onMediaClick, onCancelMediaTransfer, @@ -97,6 +99,7 @@ const WebPage: FC = ({ onClick={isMediaInteractive ? handleMediaClick : undefined} onCancelUpload={onCancelMediaTransfer} isDownloading={isDownloading} + isProtected={isProtected} theme={theme} /> )} @@ -120,6 +123,7 @@ const WebPage: FC = ({ onClick={isMediaInteractive ? handleMediaClick : undefined} onCancelUpload={onCancelMediaTransfer} isDownloading={isDownloading} + isProtected={isProtected} /> )}
diff --git a/src/components/middle/message/hooks/useOuterHandlers.ts b/src/components/middle/message/hooks/useOuterHandlers.ts index a6edeeddf..d99b194cc 100644 --- a/src/components/middle/message/hooks/useOuterHandlers.ts +++ b/src/components/middle/message/hooks/useOuterHandlers.ts @@ -7,6 +7,7 @@ import windowSize from '../../../../util/windowSize'; import { captureEvents, SwipeDirection } from '../../../../util/captureEvents'; import useFlag from '../../../../hooks/useFlag'; import { preventMessageInputBlur } from '../../helpers/preventMessageInputBlur'; +import stopEvent from '../../../../util/stopEvent'; const ANDROID_KEYBOARD_HIDE_DELAY_MS = 350; const SWIPE_ANIMATION_DURATION = 150; @@ -18,6 +19,7 @@ export default function useOuterHandlers( isAlbum: boolean, isInSelectMode: boolean, canReply: boolean, + isProtected: boolean, onContextMenu: (e: React.MouseEvent) => void, handleBeforeContextMenu: (e: React.MouseEvent) => void, ) { @@ -107,7 +109,7 @@ export default function useOuterHandlers( return { handleMouseDown: !isInSelectMode ? handleMouseDown : undefined, handleClick, - handleContextMenu: !isInSelectMode ? handleContextMenu : undefined, + handleContextMenu: !isInSelectMode ? handleContextMenu : (isProtected ? stopEvent : undefined), handleDoubleClick: !isInSelectMode ? handleContainerDoubleClick : undefined, handleContentDoubleClick: !IS_TOUCH_ENV ? stopPropagation : undefined, isSwiped, diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index b9e907a4f..f708fbfc3 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -92,6 +92,7 @@ type StateProps = { lastSyncTime?: number; serverTimeOffset: number; activeDownloadIds: number[]; + isChatProtected?: boolean; }; const TABS = [ @@ -130,6 +131,7 @@ const Profile: FC = ({ lastSyncTime, activeDownloadIds, serverTimeOffset, + isChatProtected, }) => { const { setLocalMediaSearchType, @@ -322,6 +324,7 @@ const Profile: FC = ({ )) @@ -342,6 +345,7 @@ const Profile: FC = ({ )) @@ -533,6 +537,7 @@ export default memo(withGlobal( usersById, userStatusesById, chatsById, + isChatProtected: chat?.isProtected, ...(hasMembersTab && members && { members }), ...(hasCommonChatsTab && user && { commonChatIds: user.commonChats?.ids }), }; diff --git a/src/components/right/management/ManageChatPrivacyType.tsx b/src/components/right/management/ManageChatPrivacyType.tsx index ff131473a..5b58f28a1 100644 --- a/src/components/right/management/ManageChatPrivacyType.tsx +++ b/src/components/right/management/ManageChatPrivacyType.tsx @@ -21,9 +21,7 @@ import FloatingActionButton from '../../ui/FloatingActionButton'; import UsernameInput from '../../common/UsernameInput'; import ConfirmDialog from '../../ui/ConfirmDialog'; -type PrivacyType = - 'private' - | 'public'; +type PrivacyType = 'private' | 'public'; type OwnProps = { chatId: string; @@ -36,6 +34,7 @@ type StateProps = { isChannel: boolean; progress?: ManagementProgress; isUsernameAvailable?: boolean; + isProtected?: boolean; }; const ManageChatPrivacyType: FC = ({ @@ -45,11 +44,13 @@ const ManageChatPrivacyType: FC = ({ isChannel, progress, isUsernameAvailable, + isProtected, }) => { const { checkPublicLink, updatePublicLink, updatePrivateLink, + toggleIsProtected, } = getDispatch(); const isPublic = Boolean(chat.username); @@ -76,6 +77,13 @@ const ManageChatPrivacyType: FC = ({ setPrivacyType(value as PrivacyType); }, []); + const handleForwardingOptionChange = useCallback((value: string) => { + toggleIsProtected({ + chatId: chat.id, + isProtected: value === 'protected', + }); + }, [chat.id, toggleIsProtected]); + const handleSave = useCallback(() => { updatePublicLink({ username: privacyType === 'public' ? username : '' }); }, [privacyType, updatePublicLink, username]); @@ -94,6 +102,14 @@ const ManageChatPrivacyType: FC = ({ { value: 'public', label: lang(`${langPrefix1}Public`), subLabel: lang(`${langPrefix1}PublicInfo`) }, ]; + const forwardingOptions = [{ + value: 'allowed', + label: lang('ChannelVisibility.Forwarding.Enabled'), + }, { + value: 'protected', + label: lang('ChannelVisibility.Forwarding.Disabled'), + }]; + const isLoading = progress === ManagementProgress.InProgress; return ( @@ -148,6 +164,22 @@ const ManageChatPrivacyType: FC = ({

)} +
+

+ {lang(isChannel ? 'ChannelVisibility.Forwarding.ChannelTitle' : 'ChannelVisibility.Forwarding.GroupTitle')} +

+ +

+ {isChannel + ? lang('ChannelVisibility.Forwarding.ChannelInfo') + : lang('ChannelVisibility.Forwarding.GroupInfo')} +

+
( isChannel: isChatChannel(chat), progress: global.management.progress, isUsernameAvailable, + isProtected: chat?.isProtected, }; }, )(ManageChatPrivacyType)); diff --git a/src/config.ts b/src/config.ts index 7adf1ac83..2165eedf5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,7 +30,7 @@ export const MEDIA_PROGRESSIVE_CACHE_DISABLED = false; export const MEDIA_PROGRESSIVE_CACHE_NAME = 'tt-media-progressive'; export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg'; -export const LANG_CACHE_NAME = 'tt-lang-packs-v6'; +export const LANG_CACHE_NAME = 'tt-lang-packs-v7'; export const ASSET_CACHE_NAME = 'tt-assets'; export const AUTODOWNLOAD_FILESIZE_MB_LIMITS = [1, 5, 10, 50, 100, 500]; diff --git a/src/global/types.ts b/src/global/types.ts index bc5f75b4c..492051671 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -496,7 +496,7 @@ export type ActionTypes = ( 'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' | 'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' | 'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' | - 'addChatMembers' | 'deleteChatMember' | 'openPreviousChat' | 'editChatFolders' | + 'addChatMembers' | 'deleteChatMember' | 'openPreviousChat' | 'editChatFolders' | 'toggleIsProtected' | // messages 'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' | 'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' | diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 95e4e4fbf..bfd9c26f4 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1080,6 +1080,7 @@ messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Boo messages.unpinAllMessages#f025bc8b peer:InputPeer = messages.AffectedHistory; messages.deleteChat#5bd0ee50 chat_id:long = Bool; messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector; +messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; diff --git a/src/lib/gramjs/tl/static/api.reduced.tl b/src/lib/gramjs/tl/static/api.reduced.tl index f4f31f7cc..20433a3d0 100644 --- a/src/lib/gramjs/tl/static/api.reduced.tl +++ b/src/lib/gramjs/tl/static/api.reduced.tl @@ -1081,6 +1081,7 @@ messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Boo messages.unpinAllMessages#f025bc8b peer:InputPeer = messages.AffectedHistory; messages.deleteChat#5bd0ee50 chat_id:long = Bool; messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector; +messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; diff --git a/src/modules/actions/api/chats.ts b/src/modules/actions/api/chats.ts index 1d9c19337..98e75b082 100644 --- a/src/modules/actions/api/chats.ts +++ b/src/modules/actions/api/chats.ts @@ -993,6 +993,17 @@ addReducer('deleteChatMember', (global, actions, payload) => { })(); }); +addReducer('toggleIsProtected', (global, actions, payload) => { + const { chatId, isProtected } = payload; + const chat = selectChat(global, chatId); + + if (!chat) { + return; + } + + void callApi('toggleIsProtected', { chat, isProtected }); +}); + async function loadChats(listType: 'active' | 'archived', offsetId?: string, offsetDate?: number) { let global = getGlobal(); diff --git a/src/modules/selectors/messages.ts b/src/modules/selectors/messages.ts index 826678089..8c0fcbe07 100644 --- a/src/modules/selectors/messages.ts +++ b/src/modules/selectors/messages.ts @@ -856,6 +856,10 @@ export function selectLastServiceNotification(global: GlobalState) { return serviceNotifications.find(({ id }) => id === maxId); } +export function selectIsMessageProtected(global: GlobalState, message?: ApiMessage) { + return message ? message.isProtected || selectChat(global, message.chatId)?.isProtected : false; +} + export function selectSponsoredMessage(global: GlobalState, chatId: string) { const chat = selectChat(global, chatId); const message = chat && isChatChannel(chat) ? global.messages.sponsoredByChatId[chatId] : undefined; diff --git a/src/styles/index.scss b/src/styles/index.scss index e2691f366..3e1fde2d0 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -225,6 +225,15 @@ div[role="button"] { color: var(--color-text-secondary) !important; } +.protector { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + z-index: 2; +} + .for-ios-autocapitalization-fix { position: fixed; font-size: 16px; diff --git a/src/util/fallbackLangPack.ts b/src/util/fallbackLangPack.ts index 1e05956d1..41f13403d 100644 --- a/src/util/fallbackLangPack.ts +++ b/src/util/fallbackLangPack.ts @@ -1821,4 +1821,28 @@ export default { key: 'AutoDownloadFilesTitle', value: 'Auto-download files and music', }, + 'ChannelVisibility.Forwarding.ChannelTitle': { + key: 'ChannelVisibility.Forwarding.ChannelTitle', + value: 'Forwarding From This Channel', + }, + 'ChannelVisibility.Forwarding.GroupTitle': { + key: 'ChannelVisibility.Forwarding.GroupTitle', + value: 'Forwarding From This Group', + }, + 'ChannelVisibility.Forwarding.ChannelInfo': { + key: 'ChannelVisibility.Forwarding.ChannelInfo', + value: 'Subscribers can forward messages from this channel and save media files.', + }, + 'ChannelVisibility.Forwarding.GroupInfo': { + key: 'ChannelVisibility.Forwarding.GroupInfo', + value: 'Members can forward messages from this group and save media files.', + }, + 'ChannelVisibility.Forwarding.Enabled': { + key: 'ChannelVisibility.Forwarding.Enabled', + value: 'Allow Forwarding', + }, + 'ChannelVisibility.Forwarding.Disabled': { + key: 'ChannelVisibility.Forwarding.Disabled', + value: 'Restrict Forwarding', + }, } as ApiLangPack; diff --git a/src/util/stopEvent.ts b/src/util/stopEvent.ts new file mode 100644 index 000000000..3b1ee8a58 --- /dev/null +++ b/src/util/stopEvent.ts @@ -0,0 +1,6 @@ +import React from '../lib/teact/teact'; + +export default (e: React.UIEvent | Event) => { + e.stopPropagation(); + e.preventDefault(); +};