diff --git a/public/share/index.html b/public/share/index.html new file mode 100644 index 000000000..fb9d17ad8 --- /dev/null +++ b/public/share/index.html @@ -0,0 +1,28 @@ + + + + + + + + Telegram Web + + + +
+

An error occurred during sharing. You will be redirected back to the app in 3 seconds...

+

If it did not happen automatically, click here.

+
+ + diff --git a/public/site.webmanifest b/public/site.webmanifest index 0934f0e61..9516da089 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -26,6 +26,22 @@ "sizes": "1280x802", "type": "image/jpeg" }], + "share_target": { + "action": "/share/", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": "*/*" + } + ] + } + }, "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" diff --git a/public/site_apple.webmanifest b/public/site_apple.webmanifest index 658c16d9d..ea66de5e2 100644 --- a/public/site_apple.webmanifest +++ b/public/site_apple.webmanifest @@ -26,6 +26,22 @@ "sizes": "1280x802", "type": "image/jpeg" }], + "share_target": { + "action": "/share/", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": "*/*" + } + ] + } + }, "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" diff --git a/public/site_apple_dev.webmanifest b/public/site_apple_dev.webmanifest index e5763c0a7..b782fa1b9 100644 --- a/public/site_apple_dev.webmanifest +++ b/public/site_apple_dev.webmanifest @@ -26,6 +26,22 @@ "sizes": "1280x802", "type": "image/jpeg" }], + "share_target": { + "action": "/share/", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": "*/*" + } + ] + } + }, "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" diff --git a/public/site_dev.webmanifest b/public/site_dev.webmanifest index 7559baa9b..994e73337 100644 --- a/public/site_dev.webmanifest +++ b/public/site_dev.webmanifest @@ -26,6 +26,22 @@ "sizes": "1280x802", "type": "image/jpeg" }], + "share_target": { + "action": "/share/", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": "*/*" + } + ] + } + }, "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" diff --git a/src/components/main/DraftRecipientPicker.tsx b/src/components/main/DraftRecipientPicker.tsx index 8c3515fc3..604154aee 100644 --- a/src/components/main/DraftRecipientPicker.tsx +++ b/src/components/main/DraftRecipientPicker.tsx @@ -34,7 +34,7 @@ const DraftRecipientPicker: FC = ({ }, [isOpen, markIsShown]); const handleSelectRecipient = useCallback((recipientId: string) => { - openChatWithDraft({ chatId: recipientId, text: requestedDraft!.text }); + openChatWithDraft({ chatId: recipientId, text: requestedDraft!.text, files: requestedDraft!.files }); }, [openChatWithDraft, requestedDraft]); const handleClose = useCallback(() => { diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 34e005c1f..864511393 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -7,7 +7,6 @@ import type { FC } from '../../../lib/teact/teact'; import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types'; import { - CONTENT_TYPES_WITH_PREVIEW, EDITABLE_INPUT_MODAL_ID, SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_IMAGE_CONTENT_TYPES, @@ -16,6 +15,7 @@ import { import { getFileExtension } from '../../common/helpers/documentInfo'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems'; +import { hasPreview } from '../../../util/files'; import usePrevious from '../../../hooks/usePrevious'; import useMentionTooltip from './hooks/useMentionTooltip'; @@ -185,11 +185,7 @@ const AttachmentModal: FC = ({ const files = await getFilesFromDataTransferItems(dataTransfer.items); if (files?.length) { - const newFiles = isQuick - ? Array.from(files).filter((file) => { - return file.type && CONTENT_TYPES_WITH_PREVIEW.has(file.type); - }) - : Array.from(files); + const newFiles = Array.from(files).filter((file) => !isQuick || hasPreview(file)); onFileAppend(newFiles, isQuick); } diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index 417a08197..f2cff7341 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -50,11 +50,12 @@ import { selectCanScheduleUntilOnline, selectEditingScheduledDraft, selectEditingDraft, - selectRequestedText, + selectRequestedDraftText, selectTheme, selectCurrentMessageList, selectIsCurrentUserPremium, selectChatType, + selectRequestedDraftFiles, } from '../../../global/selectors'; import { getAllowedAttachmentOptions, @@ -75,6 +76,7 @@ import windowSize from '../../../util/windowSize'; import { isSelectionInsideInput } from './helpers/selection'; import applyIosAutoCapitalizationFix from './helpers/applyIosAutoCapitalizationFix'; import { getServerTime } from '../../../util/serverTime'; +import { hasPreview } from '../../../util/files'; import { selectCurrentLimit } from '../../../global/selectors/limits'; import { buildCustomEmojiHtml } from './helpers/customEmoji'; import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager'; @@ -175,7 +177,8 @@ type StateProps = sendAsChat?: ApiChat; sendAsId?: string; editingDraft?: ApiFormattedText; - requestedText?: string; + requestedDraftText?: string; + requestedDraftFiles?: File[]; attachBots: GlobalState['attachMenu']['bots']; attachMenuPeerType?: ApiAttachMenuPeerType; theme: ISettings['theme']; @@ -256,7 +259,8 @@ const Composer: FC = ({ sendAsChat, sendAsId, editingDraft, - requestedText, + requestedDraftText, + requestedDraftFiles, botMenuButton, attachBots, attachMenuPeerType, @@ -795,15 +799,23 @@ const Composer: FC = ({ }, [contentToBeScheduled, handleMessageSchedule, requestCalendar]); useEffect(() => { - if (requestedText) { - setHtml(requestedText); + if (requestedDraftText) { + setHtml(requestedDraftText); resetOpenChatWithDraft(); requestAnimationFrame(() => { const messageInput = document.getElementById(EDITABLE_INPUT_ID)!; focusEditableElement(messageInput, true); }); } - }, [requestedText, resetOpenChatWithDraft, setHtml]); + }, [requestedDraftText, resetOpenChatWithDraft, setHtml]); + + useEffect(() => { + if (requestedDraftFiles?.length) { + const isQuick = requestedDraftFiles.every((file) => hasPreview(file)); + handleFileSelect(requestedDraftFiles, isQuick); + resetOpenChatWithDraft(); + } + }, [handleFileSelect, requestedDraftFiles, resetOpenChatWithDraft]); const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => { if (!emoji.isFree && !isCurrentUserPremium && !isChatWithSelf) { @@ -1417,7 +1429,8 @@ export default memo(withGlobal( : (chat?.adminRights?.anonymous ? chat?.id : undefined); const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; - const requestedText = selectRequestedText(global, chatId); + const requestedDraftText = selectRequestedDraftText(global, chatId); + const requestedDraftFiles = selectRequestedDraftFiles(global, chatId); const currentMessageList = selectCurrentMessageList(global); const isForCurrentMessageList = chatId === currentMessageList?.chatId && threadId === currentMessageList?.threadId @@ -1472,7 +1485,8 @@ export default memo(withGlobal( sendAsChat, sendAsId, editingDraft, - requestedText, + requestedDraftText, + requestedDraftFiles, attachBots: global.attachMenu.bots, attachMenuPeerType: selectChatType(global, chatId), theme: selectTheme(global), diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index 88e9e4f65..4fbe1548f 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -71,7 +71,7 @@ addActionHandler('openChatWithInfo', (global, actions, payload) => { }); addActionHandler('openChatWithDraft', (global, actions, payload) => { - const { chatId, text } = payload; + const { chatId, text, files } = payload; if (chatId) { actions.openChat({ id: chatId }); @@ -82,6 +82,7 @@ addActionHandler('openChatWithDraft', (global, actions, payload) => { requestedDraft: { chatId, text, + files, }, }; }); diff --git a/src/global/selectors/chats.ts b/src/global/selectors/chats.ts index 9d16e1e59..99c0e5ca4 100644 --- a/src/global/selectors/chats.ts +++ b/src/global/selectors/chats.ts @@ -193,9 +193,18 @@ export function selectSendAs(global: GlobalState, chatId: string) { return selectUser(global, id) || selectChat(global, id); } -export function selectRequestedText(global: GlobalState, chatId: string) { - if (global.requestedDraft?.chatId === chatId) { - return global.requestedDraft.text; +export function selectRequestedDraftText(global: GlobalState, chatId: string) { + const { requestedDraft } = global; + if (requestedDraft?.chatId === chatId && !requestedDraft.files?.length) { + return requestedDraft.text; + } + return undefined; +} + +export function selectRequestedDraftFiles(global: GlobalState, chatId: string) { + const { requestedDraft } = global; + if (requestedDraft?.chatId === chatId) { + return requestedDraft.files; } return undefined; } diff --git a/src/global/types.ts b/src/global/types.ts index 9ab41d5bf..1364e00f0 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -601,6 +601,7 @@ export type GlobalState = { requestedDraft?: { chatId?: string; text: string; + files?: File[]; }; pollModal: { @@ -743,6 +744,7 @@ export interface ActionPayloads { openChatWithDraft: { chatId?: string; text: string; + files?: File[]; }; resetOpenChatWithDraft: never; toggleJoinToSend: { diff --git a/src/lib/gramjs/Helpers.js b/src/lib/gramjs/Helpers.js index f574d2f7c..de5fd701e 100644 --- a/src/lib/gramjs/Helpers.js +++ b/src/lib/gramjs/Helpers.js @@ -340,22 +340,6 @@ function crc32(buf) { return (crc ^ (-1)) >>> 0; } -/** - * Creates a deferred object - * @return {Deferred} - */ -function createDeferred() { - let resolve; - const promise = new Promise((_resolve) => { - resolve = _resolve; - }); - - return { - promise, - resolve, - }; -} - module.exports = { readBigIntFromBuffer, readBufferFromBigInt, @@ -376,5 +360,4 @@ module.exports = { toSignedLittleBuffer, convertToLittle, bufferXor, - createDeferred, }; diff --git a/src/lib/gramjs/client/downloadFile.ts b/src/lib/gramjs/client/downloadFile.ts index ee38a763d..150b0376b 100644 --- a/src/lib/gramjs/client/downloadFile.ts +++ b/src/lib/gramjs/client/downloadFile.ts @@ -1,9 +1,10 @@ import BigInt from 'big-integer'; import Api from '../tl/api'; import type TelegramClient from './TelegramClient'; -import { sleep, createDeferred } from '../Helpers'; +import { sleep } from '../Helpers'; import { getDownloadPartSize } from '../Utils'; import errors from '../errors'; +import Deferred from '../../../util/Deferred'; interface OnProgress { isCanceled?: boolean; @@ -25,11 +26,6 @@ export interface DownloadFileParams { progressCallback?: OnProgress; } -interface Deferred { - promise: Promise; - resolve: (value?: any) => void; -} - // Chunk sizes for `upload.getFile` must be multiple of the smallest size const MIN_CHUNK_SIZE = 4096; const DEFAULT_CHUNK_SIZE = 64; // kb @@ -51,7 +47,7 @@ class Foreman { requestWorker() { if (this.activeWorkers === this.maxWorkers) { - const deferred = createDeferred(); + const deferred = new Deferred(); this.deferreds.push(deferred); return deferred.promise; } else { @@ -225,7 +221,7 @@ async function downloadFile2( if (deferred) await deferred.promise; - if (noParallel) deferred = createDeferred(); + if (noParallel) deferred = new Deferred(); if (hasEnded) { foremans[senderIndex].releaseWorker(); diff --git a/src/lib/gramjs/network/RequestState.js b/src/lib/gramjs/network/RequestState.js index 70cd79a56..69c74ad17 100644 --- a/src/lib/gramjs/network/RequestState.js +++ b/src/lib/gramjs/network/RequestState.js @@ -1,4 +1,4 @@ -const { createDeferred } = require('../Helpers'); +const { default: Deferred } = require('../../../util/Deferred'); class RequestState { constructor(request, after = undefined, pending = {}) { @@ -9,7 +9,7 @@ class RequestState { this.after = after; this.result = undefined; this.pending = pending; - this.deferred = createDeferred(); + this.deferred = new Deferred(); this.promise = new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts index a43326f07..dabafc826 100644 --- a/src/serviceWorker.ts +++ b/src/serviceWorker.ts @@ -2,7 +2,13 @@ import { DEBUG } from './config'; import { respondForProgressive } from './serviceWorker/progressive'; import { respondForDownload } from './serviceWorker/download'; import { respondWithCache, clearAssetCache, respondWithCacheNetworkFirst } from './serviceWorker/assetCache'; -import { handlePush, handleNotificationClick, handleClientMessage } from './serviceWorker/pushNotification'; +import { + handlePush, + handleNotificationClick, + handleClientMessage as handleNotificationMessage, +} from './serviceWorker/pushNotification'; +import { respondForShare, handleClientMessage as handleShareMessage } from './serviceWorker/share'; + import { pause } from './util/schedulers'; declare const self: ServiceWorkerGlobalScope; @@ -53,6 +59,10 @@ self.addEventListener('fetch', (e: FetchEvent) => { return true; } + if (url.includes('/share/')) { + e.respondWith(respondForShare(e)); + } + if (url.startsWith('http')) { if (NETWORK_FIRST_ASSETS.has(new URL(url).pathname)) { e.respondWith(respondWithCacheNetworkFirst(e)); @@ -70,4 +80,7 @@ self.addEventListener('fetch', (e: FetchEvent) => { self.addEventListener('push', handlePush); self.addEventListener('notificationclick', handleNotificationClick); -self.addEventListener('message', handleClientMessage); +self.addEventListener('message', (event) => { + handleNotificationMessage(event); + handleShareMessage(event); +}); diff --git a/src/serviceWorker/share.ts b/src/serviceWorker/share.ts new file mode 100644 index 000000000..28178ac84 --- /dev/null +++ b/src/serviceWorker/share.ts @@ -0,0 +1,83 @@ +import Deferred from '../util/Deferred'; + +declare const self: ServiceWorkerGlobalScope; + +type ShareData = { + title?: string; + text?: string; + url?: string; + files?: File[]; +}; + +const RESOLVED_DEFERRED = new Deferred(); +RESOLVED_DEFERRED.resolve(); +const READY_CLIENT_DEFERREDS = new Map>(); + +export async function respondForShare(e: FetchEvent) { + if (e.request.method === 'POST') { + try { + const formData = await e.request.formData(); + const data = parseFormData(formData); + requestShare(data, e.resultingClientId); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[SHARE] Failed to parse input data', err); + } + } + + return Response.redirect('..'); +} + +export function handleClientMessage(e: ExtendableMessageEvent) { + const { source, data } = e; + if (!source) return; + + if (data.type === 'clientReady') { + const { id } = (source as Client); + const deferred = READY_CLIENT_DEFERREDS.get(id); + if (deferred) { + deferred.resolve(); + } else { + READY_CLIENT_DEFERREDS.set(id, RESOLVED_DEFERRED); + } + } +} + +async function requestShare(data: ShareData, clientId: string) { + const client = await self.clients.get(clientId); + if (!client) { + return; + } + + await getClientReadyDeferred(clientId); + + client.postMessage({ + type: 'share', + payload: data, + }); +} + +function getClientReadyDeferred(clientId: string) { + const deferred = READY_CLIENT_DEFERREDS.get(clientId); + if (deferred) { + return deferred.promise; + } + + const newDeferred = new Deferred(); + READY_CLIENT_DEFERREDS.set(clientId, newDeferred); + return newDeferred.promise; +} + +function parseFormData(formData: FormData): ShareData { + const files = formData.getAll('files') as File[]; + const title = formData.get('title') as string; + const text = formData.get('text') as string; + const url = formData.get('url') as string; + + return { + title, + text, + url, + files, + }; +} diff --git a/src/util/Deferred.ts b/src/util/Deferred.ts new file mode 100644 index 000000000..2c54832d6 --- /dev/null +++ b/src/util/Deferred.ts @@ -0,0 +1,14 @@ +export default class Deferred { + promise: Promise; + + reject!: (reason?: any) => void; + + resolve!: (value: T | PromiseLike) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.reject = reject; + this.resolve = resolve; + }); + } +} diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index 9f22a4271..088163dee 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -132,6 +132,6 @@ export function parseChooseParameter(choose?: string) { return types.filter((type): type is ApiChatType => API_CHAT_TYPES.includes(type as ApiChatType)); } -export function formatShareText(url?: string, text?: string) { - return [url, text].filter(Boolean).join('\n'); +export function formatShareText(url?: string, text?: string, title?: string): string { + return [url, title, text].filter(Boolean).join('\n'); } diff --git a/src/util/files.ts b/src/util/files.ts index e80aa7d91..d9536d3c1 100644 --- a/src/util/files.ts +++ b/src/util/files.ts @@ -1,3 +1,4 @@ +import { CONTENT_TYPES_WITH_PREVIEW } from '../config'; import { pause } from './schedulers'; // Polyfill for Safari: `File` is not available in web worker @@ -122,3 +123,7 @@ export function imgToCanvas(img: HTMLImageElement) { return canvas; } + +export function hasPreview(file: File) { + return CONTENT_TYPES_WITH_PREVIEW.has(file.type); +} diff --git a/src/util/setupServiceWorker.ts b/src/util/setupServiceWorker.ts index 8749d0944..fef2cbcfd 100644 --- a/src/util/setupServiceWorker.ts +++ b/src/util/setupServiceWorker.ts @@ -1,5 +1,6 @@ import { DEBUG, DEBUG_MORE, IS_TEST } from '../config'; import { getActions } from '../global'; +import { formatShareText } from './deeplink'; import { IS_ANDROID, IS_IOS, IS_SERVICE_WORKER_SUPPORTED } from './environment'; import { notifyClientReady, playNotifySoundDebounced } from './notifications'; @@ -26,6 +27,12 @@ function handleWorkerMessage(e: MessageEvent) { case 'playNotificationSound': playNotifySoundDebounced(action.payload.id); break; + case 'share': + dispatch.openChatWithDraft({ + text: formatShareText(payload.url, payload.text, payload.title), + files: payload.files, + }); + break; } }