Support protected ("no forwards") chats and messages (#1602)

This commit is contained in:
Alexander Zinchuk 2021-12-31 18:17:49 +01:00
parent 72ae33139a
commit d1d463c7d2
36 changed files with 274 additions and 64 deletions

View File

@ -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 }),

View File

@ -140,7 +140,7 @@ type UniversalMessage = (
& Pick<Partial<GramJs.Message & GramJs.MessageService>, (
'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 }),
};
}

View File

@ -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);
}

View File

@ -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 {

View File

@ -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({

View File

@ -31,6 +31,7 @@ export interface ApiChat {
isSupport?: boolean;
photos?: ApiPhoto[];
draftDate?: number;
isProtected?: boolean;
// Calls
isCallActive?: boolean;

View File

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

View File

@ -28,6 +28,7 @@ type OwnProps = {
sender?: ApiUser | ApiChat;
title?: string;
customText?: string;
isProtected?: boolean;
onClick: NoneToVoidFunction;
};
@ -39,6 +40,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
sender,
title,
customText,
isProtected,
observeIntersection,
onClick,
}) => {
@ -61,7 +63,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
className={buildClassName('EmbeddedMessage', className)}
onClick={message ? onClick : undefined}
>
{mediaThumbnail && renderPictogram(pictogramId, mediaThumbnail, mediaBlobUrl, isRoundVideo)}
{mediaThumbnail && renderPictogram(pictogramId, mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected)}
<div className="message-text">
<p dir="auto">
{!message ? (
@ -83,18 +85,23 @@ function renderPictogram(
thumbDataUri: string,
blobUrl?: string,
isRoundVideo?: boolean,
isProtected?: boolean,
) {
const { width, height } = getPictogramDimensions();
return (
<img
id={id}
src={blobUrl || thumbDataUri}
width={width}
height={height}
alt=""
className={isRoundVideo ? 'round' : ''}
/>
<>
<img
id={id}
src={blobUrl || thumbDataUri}
width={width}
height={height}
alt=""
className={isRoundVideo ? 'round' : ''}
draggable={!isProtected}
/>
{isProtected && <span className="protector" />}
</>
);
}

View File

@ -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<OwnProps> = ({ message, idPrefix = 'shared-media', onClick }) => {
const Media: FC<OwnProps> = ({
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<OwnProps> = ({ message, idPrefix = 'shared-media', onClick }) =>
return (
<div id={`${idPrefix}${message.id}`} className="Media scroll-item" onClick={onClick ? handleClick : undefined}>
<img src={thumbDataUri} alt="" />
<img src={mediaBlobUrl} className={buildClassName('full-media', transitionClassNames)} alt="" />
<img src={thumbDataUri} alt="" draggable={!isProtected} onContextMenu={isProtected ? stopEvent : undefined} />
<img
src={mediaBlobUrl}
className={buildClassName('full-media', transitionClassNames)}
alt=""
draggable={!isProtected}
onContextMenu={isProtected ? stopEvent : undefined}
/>
{video && <span className="video-duration">{video.isGif ? 'GIF' : formatMediaDuration(video.duration)}</span>}
{isProtected && <span className="protector" />}
</div>
);
};

View File

@ -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<OwnProps> = ({
message, senderTitle, onMessageClick,
message, senderTitle, isProtected, onMessageClick,
}) => {
const lang = useLang();
@ -76,7 +77,7 @@ const WebLink: FC<OwnProps> = ({
dir={lang.isRtl ? 'rtl' : undefined}
>
{photo && (
<Media message={message} />
<Media message={message} isProtected={isProtected} />
)}
<div className="content">
<Link isRtl={lang.isRtl} className="site-title" onClick={handleMessageClick}>

View File

@ -35,6 +35,7 @@ const LinkResults: FC<OwnProps & StateProps> = ({
globalMessagesByChatId,
foundIds,
lastSyncTime,
isChatProtected,
}) => {
const {
searchMessagesGlobal,
@ -89,6 +90,7 @@ const LinkResults: FC<OwnProps & StateProps> = ({
key={message.id}
message={message}
senderTitle={getSenderName(lang, message, chatsById, usersById)}
isProtected={isChatProtected || message.isProtected}
onMessageClick={handleMessageFocus}
/>
</div>

View File

@ -33,6 +33,7 @@ const MediaResults: FC<OwnProps & StateProps> = ({
globalMessagesByChatId,
foundIds,
lastSyncTime,
isChatProtected,
}) => {
const {
searchMessagesGlobal,
@ -81,6 +82,7 @@ const MediaResults: FC<OwnProps & StateProps> = ({
key={message.id}
idPrefix="search-media"
message={message}
isProtected={isChatProtected || message.isProtected}
onClick={handleSelectMedia}
/>
))}

View File

@ -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<string, number[]>;
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,
};
};

View File

@ -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<StateProps> = ({
usePreventPinchZoomGesture(isMediaViewerOpen);
function stopEvent(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
e.preventDefault();
e.stopPropagation();
}
return (
<div id="Main" className={className} onDrop={stopEvent} onDragOver={stopEvent}>
<LeftColumn />

View File

@ -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<OwnProps & StateProps> = ({
fileName,
isAvatar,
isDownloading,
isProtected,
onCloseMediaViewer,
onForward,
onZoomToggle,
@ -84,7 +86,44 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
);
}, []);
function renderDownloadButton() {
if (isProtected) {
return undefined;
}
return isVideo ? (
<Button
round
size="smaller"
color="translucent-white"
ariaLabel={lang('AccActionDownload')}
onClick={handleDownloadClick}
>
{isDownloading ? (
<ProgressSpinner progress={downloadProgress} size="s" onClick={handleDownloadClick} />
) : (
<i className="icon-download" />
)}
</Button>
) : (
<Button
href={mediaData}
download={fileName}
round
size="smaller"
color="translucent-white"
ariaLabel={lang('AccActionDownload')}
>
<i className="icon-download" />
</Button>
);
}
if (IS_SINGLE_COLUMN_LAYOUT) {
if (isProtected) {
return undefined;
}
return (
<div className="MediaViewerActions-mobile">
<DropdownMenu
@ -123,7 +162,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
return (
<div className="MediaViewerActions">
{!isAvatar && (
{!isAvatar && !isProtected && (
<>
<Button
round
@ -136,32 +175,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
</Button>
</>
)}
{isVideo ? (
<Button
round
size="smaller"
color="translucent-white"
ariaLabel={lang('AccActionDownload')}
onClick={handleDownloadClick}
>
{isDownloading ? (
<ProgressSpinner progress={downloadProgress} size="s" onClick={handleDownloadClick} />
) : (
<i className="icon-download" />
)}
</Button>
) : (
<Button
href={mediaData}
download={fileName}
round
size="smaller"
color="translucent-white"
ariaLabel={lang('AccActionDownload')}
>
<i className="icon-download" />
</Button>
)}
{renderDownloadButton()}
<Button
round
size="smaller"
@ -187,9 +201,11 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, { message }): StateProps => {
const isDownloading = message ? selectIsDownloading(global, message) : false;
const isProtected = selectIsMessageProtected(global, message);
return {
isDownloading,
isProtected,
};
},
)(MediaViewerActions));

View File

@ -6,6 +6,7 @@ import {
} from '../../api/types';
import { MediaViewerOrigin } from '../../types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import useBlurSync from '../../hooks/useBlurSync';
import useMedia from '../../hooks/useMedia';
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
@ -26,10 +27,11 @@ import {
isMessageDocumentVideo,
} from '../../modules/helpers';
import {
selectChat, selectChatMessage, selectScheduledMessage, selectUser,
selectChat, selectChatMessage, selectIsMessageProtected, selectScheduledMessage, selectUser,
} from '../../modules/selectors';
import { AVATAR_FULL_DIMENSIONS, calculateMediaViewerDimensions } from '../common/helpers/mediaDimensions';
import { renderMessageText } from '../common/helpers/renderMessageText';
import stopEvent from '../../util/stopEvent';
import Spinner from '../ui/Spinner';
import MediaViewerFooter from './MediaViewerFooter';
@ -60,6 +62,7 @@ type StateProps = {
profilePhotoIndex?: number;
message?: ApiMessage;
origin?: MediaViewerOrigin;
isProtected?: boolean;
};
const ANIMATION_DURATION = 350;
@ -77,6 +80,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
onClose,
onFooterClick,
isFooterHidden,
isProtected,
} = props;
/* Content */
const photo = message ? getMessagePhoto(message) : undefined;
@ -163,7 +167,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
{renderPhoto(
fullMediaBlobUrl || previewBlobUrl,
calculateMediaViewerDimensions(AVATAR_FULL_DIMENSIONS, false),
false,
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
)}
</div>
);
@ -176,10 +180,11 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
<div
className={`MediaViewerContent ${hasFooter ? 'has-footer' : ''}`}
>
{isProtected && <div onContextMenu={stopEvent} className="protector" />}
{isPhoto && renderPhoto(
localBlobUrl || fullMediaBlobUrl || previewBlobUrl || pictogramBlobUrl,
message && calculateMediaViewerDimensions(dimensions!, hasFooter),
false,
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
)}
{isVideo && (isActive ? (
<VideoPlayer
@ -197,7 +202,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
) : renderVideoPreview(
bestImageData,
message && calculateMediaViewerDimensions(dimensions!, hasFooter, true),
false,
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
))}
{textParts && (
<MediaViewerFooter
@ -238,6 +243,7 @@ export default memo(withGlobal<OwnProps>(
senderId: message.senderId,
origin,
message,
isProtected: selectIsMessageProtected(global, message),
};
}
@ -275,6 +281,7 @@ export default memo(withGlobal<OwnProps>(
senderId: message.senderId,
origin,
message,
isProtected: selectIsMessageProtected(global, message),
};
},
)(MediaViewerContent));

View File

@ -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;

View File

@ -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<OwnProps & StateProps> = ({
hasCustomAppendix,
lastSyncTime,
isOwn,
isProtected,
albumLayout,
onMediaClick,
uploadsById,
@ -85,6 +87,7 @@ const Album: FC<OwnProps & StateProps> = ({
shouldAffectAppendix={shouldAffectAppendix}
uploadProgress={uploadProgress}
dimensions={dimensions}
isProtected={isProtected}
onClick={onMediaClick}
onCancelUpload={handleCancelUpload}
isDownloading={activeDownloadIds.includes(message.id)}
@ -102,6 +105,7 @@ const Album: FC<OwnProps & StateProps> = ({
uploadProgress={uploadProgress}
lastSyncTime={lastSyncTime}
dimensions={dimensions}
isProtected={isProtected}
onClick={onMediaClick}
onCancelUpload={handleCancelUpload}
isDownloading={activeDownloadIds.includes(message.id)}

View File

@ -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<OwnProps>(
&& 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<OwnProps>(
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,
};

View File

@ -27,6 +27,10 @@
transform: translateX(-2.5rem) !important;
}
&.is-protected {
user-select: none;
}
> .Avatar,
> .message-content-wrapper {
opacity: 1;

View File

@ -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<OwnProps & StateProps> = ({
replyMessageSender,
outgoingStatus,
uploadProgress,
isProtected,
isFocused,
focusDirection,
noFocusHighlight,
@ -325,6 +328,7 @@ const Message: FC<OwnProps & StateProps> = ({
isAlbum,
Boolean(isInSelectMode),
Boolean(canReply),
Boolean(isProtected),
onContextMenu,
handleBeforeContextMenu,
);
@ -364,6 +368,7 @@ const Message: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
{hasReply && (
<EmbeddedMessage
message={replyMessage}
isProtected={isProtected}
sender={replyMessageSender}
observeIntersection={observeIntersectionForMedia}
onClick={handleReplyClick}
@ -514,6 +520,7 @@ const Message: FC<OwnProps & StateProps> = ({
albumLayout={albumLayout!}
observeIntersection={observeIntersectionForMedia}
isOwn={isOwn}
isProtected={isProtected}
hasCustomAppendix={hasCustomAppendix}
lastSyncTime={lastSyncTime}
onMediaClick={handleAlbumMediaClick}
@ -530,6 +537,7 @@ const Message: FC<OwnProps & StateProps> = ({
onClick={handleMediaClick}
onCancelUpload={handleCancelUpload}
isDownloading={isDownloading}
isProtected={isProtected}
theme={theme}
/>
)}
@ -554,6 +562,7 @@ const Message: FC<OwnProps & StateProps> = ({
onClick={handleMediaClick}
onCancelUpload={handleCancelUpload}
isDownloading={isDownloading}
isProtected={isProtected}
/>
)}
{(audio || voice) && (
@ -615,6 +624,7 @@ const Message: FC<OwnProps & StateProps> = ({
onMediaClick={handleMediaClick}
onCancelMediaTransfer={handleCancelUpload}
isDownloading={isDownloading}
isProtected={isProtected}
theme={theme}
/>
)}
@ -880,6 +890,7 @@ export default memo(withGlobal<OwnProps>(
isThreadTop,
replyMessage,
replyMessageSender,
isProtected: selectIsMessageProtected(global, message),
isFocused,
isForwarding,
isChatWithSelf,

View File

@ -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<OwnProps> = ({
nonInteractive,
shouldAffectAppendix,
isDownloading,
isProtected,
theme,
onClick,
onCancelUpload,
@ -167,7 +169,9 @@ const Photo: FC<OwnProps> = ({
width={width}
height={height}
alt=""
draggable={!isProtected}
/>
{isProtected && <span className="protector" />}
{shouldRenderSpinner && !shouldRenderDownloadButton && (
<div className={`media-loading ${spinnerClassNames}`}>
<ProgressSpinner progress={transferProgress} onClick={isUploading ? handleClick : undefined} />

View File

@ -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<OwnProps> = ({
onClick,
onCancelUpload,
isDownloading,
isProtected,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
@ -173,6 +175,7 @@ const Video: FC<OwnProps> = ({
// @ts-ignore teact feature
style={`width: ${width}px; height: ${height}px;`}
alt=""
draggable={!isProtected}
/>
{isInline && (
<video
@ -186,11 +189,13 @@ const Video: FC<OwnProps> = ({
playsInline
// eslint-disable-next-line react/jsx-props-no-spreading
{...bufferingHandlers}
draggable={!isProtected}
onTimeUpdate={handleTimeUpdate}
>
<source src={fullMediaData} />
</video>
)}
{isProtected && <span className="protector" />}
{shouldRenderPlayButton && <i className={buildClassName('icon-large-play', playButtonClassNames)} />}
{shouldRenderSpinner && (
<div className={buildClassName('media-loading', spinnerClassNames)}>

View File

@ -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<OwnProps> = ({
inPreview,
lastSyncTime,
isDownloading = false,
isProtected,
theme,
onMediaClick,
onCancelMediaTransfer,
@ -97,6 +99,7 @@ const WebPage: FC<OwnProps> = ({
onClick={isMediaInteractive ? handleMediaClick : undefined}
onCancelUpload={onCancelMediaTransfer}
isDownloading={isDownloading}
isProtected={isProtected}
theme={theme}
/>
)}
@ -120,6 +123,7 @@ const WebPage: FC<OwnProps> = ({
onClick={isMediaInteractive ? handleMediaClick : undefined}
onCancelUpload={onCancelMediaTransfer}
isDownloading={isDownloading}
isProtected={isProtected}
/>
)}
</div>

View File

@ -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,

View File

@ -92,6 +92,7 @@ type StateProps = {
lastSyncTime?: number;
serverTimeOffset: number;
activeDownloadIds: number[];
isChatProtected?: boolean;
};
const TABS = [
@ -130,6 +131,7 @@ const Profile: FC<OwnProps & StateProps> = ({
lastSyncTime,
activeDownloadIds,
serverTimeOffset,
isChatProtected,
}) => {
const {
setLocalMediaSearchType,
@ -322,6 +324,7 @@ const Profile: FC<OwnProps & StateProps> = ({
<Media
key={id}
message={chatMessages[id]}
isProtected={isChatProtected || chatMessages[id].isProtected}
onClick={handleSelectMedia}
/>
))
@ -342,6 +345,7 @@ const Profile: FC<OwnProps & StateProps> = ({
<WebLink
key={id}
message={chatMessages[id]}
isProtected={isChatProtected || chatMessages[id].isProtected}
onMessageClick={handleMessageFocus}
/>
))
@ -533,6 +537,7 @@ export default memo(withGlobal<OwnProps>(
usersById,
userStatusesById,
chatsById,
isChatProtected: chat?.isProtected,
...(hasMembersTab && members && { members }),
...(hasCommonChatsTab && user && { commonChatIds: user.commonChats?.ids }),
};

View File

@ -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<OwnProps & StateProps> = ({
@ -45,11 +44,13 @@ const ManageChatPrivacyType: FC<OwnProps & StateProps> = ({
isChannel,
progress,
isUsernameAvailable,
isProtected,
}) => {
const {
checkPublicLink,
updatePublicLink,
updatePrivateLink,
toggleIsProtected,
} = getDispatch();
const isPublic = Boolean(chat.username);
@ -76,6 +77,13 @@ const ManageChatPrivacyType: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
{ 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<OwnProps & StateProps> = ({
</p>
</div>
)}
<div className="section" dir={lang.isRtl ? 'rtl' : undefined}>
<h3 className="section-heading">
{lang(isChannel ? 'ChannelVisibility.Forwarding.ChannelTitle' : 'ChannelVisibility.Forwarding.GroupTitle')}
</h3>
<RadioGroup
selected={isProtected ? 'protected' : 'allowed'}
name="channel-type"
options={forwardingOptions}
onChange={handleForwardingOptionChange}
/>
<p className="section-info">
{isChannel
? lang('ChannelVisibility.Forwarding.ChannelInfo')
: lang('ChannelVisibility.Forwarding.GroupInfo')}
</p>
</div>
</div>
<FloatingActionButton
isShown={canUpdate}
@ -175,6 +207,7 @@ export default memo(withGlobal<OwnProps>(
isChannel: isChatChannel(chat),
progress: global.management.progress,
isUsernameAvailable,
isProtected: chat?.isProtected,
};
},
)(ManageChatPrivacyType));

View File

@ -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];

View File

@ -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' |

View File

@ -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<long>;
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;

View File

@ -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<long>;
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;

View File

@ -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();

View File

@ -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;

View File

@ -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;

View File

@ -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;

6
src/util/stopEvent.ts Normal file
View File

@ -0,0 +1,6 @@
import React from '../lib/teact/teact';
export default (e: React.UIEvent | Event) => {
e.stopPropagation();
e.preventDefault();
};