PWA: Support system sharing menu (#2057)

This commit is contained in:
Alexander Zinchuk 2022-11-18 16:05:09 +04:00
parent 0e4f9e5ae6
commit 08ef6130b7
20 changed files with 265 additions and 50 deletions

28
public/share/index.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="3;URL='..'">
<title>Telegram Web</title>
<style>
html, body {
height: 100%;
}
body {
margin: 0;
display: grid;
place-items: center;
text-align: center;
}
</style>
</head>
<body>
<div class="message">
<p>An error occurred during sharing. You will be redirected back to the app in 3 seconds...</p>
<p>If it did not happen automatically, <a href="..">click here</a>.</p>
</div>
</body>
</html>

View File

@ -26,6 +26,22 @@
"sizes": "1280x802", "sizes": "1280x802",
"type": "image/jpeg" "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", "theme_color": "#ffffff",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone" "display": "standalone"

View File

@ -26,6 +26,22 @@
"sizes": "1280x802", "sizes": "1280x802",
"type": "image/jpeg" "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", "theme_color": "#ffffff",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone" "display": "standalone"

View File

@ -26,6 +26,22 @@
"sizes": "1280x802", "sizes": "1280x802",
"type": "image/jpeg" "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", "theme_color": "#ffffff",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone" "display": "standalone"

View File

@ -26,6 +26,22 @@
"sizes": "1280x802", "sizes": "1280x802",
"type": "image/jpeg" "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", "theme_color": "#ffffff",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone" "display": "standalone"

View File

@ -34,7 +34,7 @@ const DraftRecipientPicker: FC<OwnProps> = ({
}, [isOpen, markIsShown]); }, [isOpen, markIsShown]);
const handleSelectRecipient = useCallback((recipientId: string) => { const handleSelectRecipient = useCallback((recipientId: string) => {
openChatWithDraft({ chatId: recipientId, text: requestedDraft!.text }); openChatWithDraft({ chatId: recipientId, text: requestedDraft!.text, files: requestedDraft!.files });
}, [openChatWithDraft, requestedDraft]); }, [openChatWithDraft, requestedDraft]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {

View File

@ -7,7 +7,6 @@ import type { FC } from '../../../lib/teact/teact';
import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types'; import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types';
import { import {
CONTENT_TYPES_WITH_PREVIEW,
EDITABLE_INPUT_MODAL_ID, EDITABLE_INPUT_MODAL_ID,
SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_AUDIO_CONTENT_TYPES,
SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_IMAGE_CONTENT_TYPES,
@ -16,6 +15,7 @@ import {
import { getFileExtension } from '../../common/helpers/documentInfo'; import { getFileExtension } from '../../common/helpers/documentInfo';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems'; import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems';
import { hasPreview } from '../../../util/files';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useMentionTooltip from './hooks/useMentionTooltip'; import useMentionTooltip from './hooks/useMentionTooltip';
@ -185,11 +185,7 @@ const AttachmentModal: FC<OwnProps> = ({
const files = await getFilesFromDataTransferItems(dataTransfer.items); const files = await getFilesFromDataTransferItems(dataTransfer.items);
if (files?.length) { if (files?.length) {
const newFiles = isQuick const newFiles = Array.from(files).filter((file) => !isQuick || hasPreview(file));
? Array.from(files).filter((file) => {
return file.type && CONTENT_TYPES_WITH_PREVIEW.has(file.type);
})
: Array.from(files);
onFileAppend(newFiles, isQuick); onFileAppend(newFiles, isQuick);
} }

View File

@ -50,11 +50,12 @@ import {
selectCanScheduleUntilOnline, selectCanScheduleUntilOnline,
selectEditingScheduledDraft, selectEditingScheduledDraft,
selectEditingDraft, selectEditingDraft,
selectRequestedText, selectRequestedDraftText,
selectTheme, selectTheme,
selectCurrentMessageList, selectCurrentMessageList,
selectIsCurrentUserPremium, selectIsCurrentUserPremium,
selectChatType, selectChatType,
selectRequestedDraftFiles,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { import {
getAllowedAttachmentOptions, getAllowedAttachmentOptions,
@ -75,6 +76,7 @@ import windowSize from '../../../util/windowSize';
import { isSelectionInsideInput } from './helpers/selection'; import { isSelectionInsideInput } from './helpers/selection';
import applyIosAutoCapitalizationFix from './helpers/applyIosAutoCapitalizationFix'; import applyIosAutoCapitalizationFix from './helpers/applyIosAutoCapitalizationFix';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import { hasPreview } from '../../../util/files';
import { selectCurrentLimit } from '../../../global/selectors/limits'; import { selectCurrentLimit } from '../../../global/selectors/limits';
import { buildCustomEmojiHtml } from './helpers/customEmoji'; import { buildCustomEmojiHtml } from './helpers/customEmoji';
import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager'; import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager';
@ -175,7 +177,8 @@ type StateProps =
sendAsChat?: ApiChat; sendAsChat?: ApiChat;
sendAsId?: string; sendAsId?: string;
editingDraft?: ApiFormattedText; editingDraft?: ApiFormattedText;
requestedText?: string; requestedDraftText?: string;
requestedDraftFiles?: File[];
attachBots: GlobalState['attachMenu']['bots']; attachBots: GlobalState['attachMenu']['bots'];
attachMenuPeerType?: ApiAttachMenuPeerType; attachMenuPeerType?: ApiAttachMenuPeerType;
theme: ISettings['theme']; theme: ISettings['theme'];
@ -256,7 +259,8 @@ const Composer: FC<OwnProps & StateProps> = ({
sendAsChat, sendAsChat,
sendAsId, sendAsId,
editingDraft, editingDraft,
requestedText, requestedDraftText,
requestedDraftFiles,
botMenuButton, botMenuButton,
attachBots, attachBots,
attachMenuPeerType, attachMenuPeerType,
@ -795,15 +799,23 @@ const Composer: FC<OwnProps & StateProps> = ({
}, [contentToBeScheduled, handleMessageSchedule, requestCalendar]); }, [contentToBeScheduled, handleMessageSchedule, requestCalendar]);
useEffect(() => { useEffect(() => {
if (requestedText) { if (requestedDraftText) {
setHtml(requestedText); setHtml(requestedDraftText);
resetOpenChatWithDraft(); resetOpenChatWithDraft();
requestAnimationFrame(() => { requestAnimationFrame(() => {
const messageInput = document.getElementById(EDITABLE_INPUT_ID)!; const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
focusEditableElement(messageInput, true); 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) => { const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => {
if (!emoji.isFree && !isCurrentUserPremium && !isChatWithSelf) { if (!emoji.isFree && !isCurrentUserPremium && !isChatWithSelf) {
@ -1417,7 +1429,8 @@ export default memo(withGlobal<OwnProps>(
: (chat?.adminRights?.anonymous ? chat?.id : undefined); : (chat?.adminRights?.anonymous ? chat?.id : undefined);
const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined;
const sendAsChat = !sendAsUser && sendAsId ? selectChat(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 currentMessageList = selectCurrentMessageList(global);
const isForCurrentMessageList = chatId === currentMessageList?.chatId const isForCurrentMessageList = chatId === currentMessageList?.chatId
&& threadId === currentMessageList?.threadId && threadId === currentMessageList?.threadId
@ -1472,7 +1485,8 @@ export default memo(withGlobal<OwnProps>(
sendAsChat, sendAsChat,
sendAsId, sendAsId,
editingDraft, editingDraft,
requestedText, requestedDraftText,
requestedDraftFiles,
attachBots: global.attachMenu.bots, attachBots: global.attachMenu.bots,
attachMenuPeerType: selectChatType(global, chatId), attachMenuPeerType: selectChatType(global, chatId),
theme: selectTheme(global), theme: selectTheme(global),

View File

@ -71,7 +71,7 @@ addActionHandler('openChatWithInfo', (global, actions, payload) => {
}); });
addActionHandler('openChatWithDraft', (global, actions, payload) => { addActionHandler('openChatWithDraft', (global, actions, payload) => {
const { chatId, text } = payload; const { chatId, text, files } = payload;
if (chatId) { if (chatId) {
actions.openChat({ id: chatId }); actions.openChat({ id: chatId });
@ -82,6 +82,7 @@ addActionHandler('openChatWithDraft', (global, actions, payload) => {
requestedDraft: { requestedDraft: {
chatId, chatId,
text, text,
files,
}, },
}; };
}); });

View File

@ -193,9 +193,18 @@ export function selectSendAs(global: GlobalState, chatId: string) {
return selectUser(global, id) || selectChat(global, id); return selectUser(global, id) || selectChat(global, id);
} }
export function selectRequestedText(global: GlobalState, chatId: string) { export function selectRequestedDraftText(global: GlobalState, chatId: string) {
if (global.requestedDraft?.chatId === chatId) { const { requestedDraft } = global;
return global.requestedDraft.text; 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; return undefined;
} }

View File

@ -601,6 +601,7 @@ export type GlobalState = {
requestedDraft?: { requestedDraft?: {
chatId?: string; chatId?: string;
text: string; text: string;
files?: File[];
}; };
pollModal: { pollModal: {
@ -743,6 +744,7 @@ export interface ActionPayloads {
openChatWithDraft: { openChatWithDraft: {
chatId?: string; chatId?: string;
text: string; text: string;
files?: File[];
}; };
resetOpenChatWithDraft: never; resetOpenChatWithDraft: never;
toggleJoinToSend: { toggleJoinToSend: {

View File

@ -340,22 +340,6 @@ function crc32(buf) {
return (crc ^ (-1)) >>> 0; 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 = { module.exports = {
readBigIntFromBuffer, readBigIntFromBuffer,
readBufferFromBigInt, readBufferFromBigInt,
@ -376,5 +360,4 @@ module.exports = {
toSignedLittleBuffer, toSignedLittleBuffer,
convertToLittle, convertToLittle,
bufferXor, bufferXor,
createDeferred,
}; };

View File

@ -1,9 +1,10 @@
import BigInt from 'big-integer'; import BigInt from 'big-integer';
import Api from '../tl/api'; import Api from '../tl/api';
import type TelegramClient from './TelegramClient'; import type TelegramClient from './TelegramClient';
import { sleep, createDeferred } from '../Helpers'; import { sleep } from '../Helpers';
import { getDownloadPartSize } from '../Utils'; import { getDownloadPartSize } from '../Utils';
import errors from '../errors'; import errors from '../errors';
import Deferred from '../../../util/Deferred';
interface OnProgress { interface OnProgress {
isCanceled?: boolean; isCanceled?: boolean;
@ -25,11 +26,6 @@ export interface DownloadFileParams {
progressCallback?: OnProgress; progressCallback?: OnProgress;
} }
interface Deferred {
promise: Promise<any>;
resolve: (value?: any) => void;
}
// Chunk sizes for `upload.getFile` must be multiple of the smallest size // Chunk sizes for `upload.getFile` must be multiple of the smallest size
const MIN_CHUNK_SIZE = 4096; const MIN_CHUNK_SIZE = 4096;
const DEFAULT_CHUNK_SIZE = 64; // kb const DEFAULT_CHUNK_SIZE = 64; // kb
@ -51,7 +47,7 @@ class Foreman {
requestWorker() { requestWorker() {
if (this.activeWorkers === this.maxWorkers) { if (this.activeWorkers === this.maxWorkers) {
const deferred = createDeferred(); const deferred = new Deferred();
this.deferreds.push(deferred); this.deferreds.push(deferred);
return deferred.promise; return deferred.promise;
} else { } else {
@ -225,7 +221,7 @@ async function downloadFile2(
if (deferred) await deferred.promise; if (deferred) await deferred.promise;
if (noParallel) deferred = createDeferred(); if (noParallel) deferred = new Deferred();
if (hasEnded) { if (hasEnded) {
foremans[senderIndex].releaseWorker(); foremans[senderIndex].releaseWorker();

View File

@ -1,4 +1,4 @@
const { createDeferred } = require('../Helpers'); const { default: Deferred } = require('../../../util/Deferred');
class RequestState { class RequestState {
constructor(request, after = undefined, pending = {}) { constructor(request, after = undefined, pending = {}) {
@ -9,7 +9,7 @@ class RequestState {
this.after = after; this.after = after;
this.result = undefined; this.result = undefined;
this.pending = pending; this.pending = pending;
this.deferred = createDeferred(); this.deferred = new Deferred();
this.promise = new Promise((resolve, reject) => { this.promise = new Promise((resolve, reject) => {
this.resolve = resolve; this.resolve = resolve;
this.reject = reject; this.reject = reject;

View File

@ -2,7 +2,13 @@ import { DEBUG } from './config';
import { respondForProgressive } from './serviceWorker/progressive'; import { respondForProgressive } from './serviceWorker/progressive';
import { respondForDownload } from './serviceWorker/download'; import { respondForDownload } from './serviceWorker/download';
import { respondWithCache, clearAssetCache, respondWithCacheNetworkFirst } from './serviceWorker/assetCache'; 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'; import { pause } from './util/schedulers';
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
@ -53,6 +59,10 @@ self.addEventListener('fetch', (e: FetchEvent) => {
return true; return true;
} }
if (url.includes('/share/')) {
e.respondWith(respondForShare(e));
}
if (url.startsWith('http')) { if (url.startsWith('http')) {
if (NETWORK_FIRST_ASSETS.has(new URL(url).pathname)) { if (NETWORK_FIRST_ASSETS.has(new URL(url).pathname)) {
e.respondWith(respondWithCacheNetworkFirst(e)); e.respondWith(respondWithCacheNetworkFirst(e));
@ -70,4 +80,7 @@ self.addEventListener('fetch', (e: FetchEvent) => {
self.addEventListener('push', handlePush); self.addEventListener('push', handlePush);
self.addEventListener('notificationclick', handleNotificationClick); self.addEventListener('notificationclick', handleNotificationClick);
self.addEventListener('message', handleClientMessage); self.addEventListener('message', (event) => {
handleNotificationMessage(event);
handleShareMessage(event);
});

View File

@ -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<void>();
RESOLVED_DEFERRED.resolve();
const READY_CLIENT_DEFERREDS = new Map<string, Deferred<void>>();
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<void>();
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,
};
}

14
src/util/Deferred.ts Normal file
View File

@ -0,0 +1,14 @@
export default class Deferred<T = void> {
promise: Promise<T>;
reject!: (reason?: any) => void;
resolve!: (value: T | PromiseLike<T>) => void;
constructor() {
this.promise = new Promise((resolve, reject) => {
this.reject = reject;
this.resolve = resolve;
});
}
}

View File

@ -132,6 +132,6 @@ export function parseChooseParameter(choose?: string) {
return types.filter((type): type is ApiChatType => API_CHAT_TYPES.includes(type as ApiChatType)); return types.filter((type): type is ApiChatType => API_CHAT_TYPES.includes(type as ApiChatType));
} }
export function formatShareText(url?: string, text?: string) { export function formatShareText(url?: string, text?: string, title?: string): string {
return [url, text].filter(Boolean).join('\n'); return [url, title, text].filter(Boolean).join('\n');
} }

View File

@ -1,3 +1,4 @@
import { CONTENT_TYPES_WITH_PREVIEW } from '../config';
import { pause } from './schedulers'; import { pause } from './schedulers';
// Polyfill for Safari: `File` is not available in web worker // Polyfill for Safari: `File` is not available in web worker
@ -122,3 +123,7 @@ export function imgToCanvas(img: HTMLImageElement) {
return canvas; return canvas;
} }
export function hasPreview(file: File) {
return CONTENT_TYPES_WITH_PREVIEW.has(file.type);
}

View File

@ -1,5 +1,6 @@
import { DEBUG, DEBUG_MORE, IS_TEST } from '../config'; import { DEBUG, DEBUG_MORE, IS_TEST } from '../config';
import { getActions } from '../global'; import { getActions } from '../global';
import { formatShareText } from './deeplink';
import { IS_ANDROID, IS_IOS, IS_SERVICE_WORKER_SUPPORTED } from './environment'; import { IS_ANDROID, IS_IOS, IS_SERVICE_WORKER_SUPPORTED } from './environment';
import { notifyClientReady, playNotifySoundDebounced } from './notifications'; import { notifyClientReady, playNotifySoundDebounced } from './notifications';
@ -26,6 +27,12 @@ function handleWorkerMessage(e: MessageEvent) {
case 'playNotificationSound': case 'playNotificationSound':
playNotifySoundDebounced(action.payload.id); playNotifySoundDebounced(action.payload.id);
break; break;
case 'share':
dispatch.openChatWithDraft({
text: formatShareText(payload.url, payload.text, payload.title),
files: payload.files,
});
break;
} }
} }