feat: add Telegram sticker import functionality
- Implemented TelegramImport component for importing sticker packs from Telegram using a bot token. - Added utility functions for animated sticker conversion (TGS and WEBM to GIF). - Created telegramStickerImport module to handle fetching and processing of Telegram sticker sets. - Introduced GIF.js type definitions for TypeScript support.
This commit is contained in:
48
package-lock.json
generated
48
package-lock.json
generated
@@ -19,10 +19,12 @@
|
|||||||
"@tauri-apps/api": "2.9.1",
|
"@tauri-apps/api": "2.9.1",
|
||||||
"@tauri-apps/plugin-dialog": "2.6.0",
|
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "2.4.5",
|
"@tauri-apps/plugin-fs": "2.4.5",
|
||||||
|
"@tauri-apps/plugin-http": "2.5.7",
|
||||||
"@tauri-apps/plugin-notification": "2.3.3",
|
"@tauri-apps/plugin-notification": "2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "2",
|
"@tauri-apps/plugin-opener": "2",
|
||||||
"@tauri-apps/plugin-process": "2.3.1",
|
"@tauri-apps/plugin-process": "2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "2.10.0",
|
"@tauri-apps/plugin-updater": "2.10.0",
|
||||||
|
"@types/pako": "2.0.4",
|
||||||
"@vanilla-extract/css": "1.9.3",
|
"@vanilla-extract/css": "1.9.3",
|
||||||
"@vanilla-extract/recipes": "0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.4.0",
|
"folds": "2.4.0",
|
||||||
|
"gif.js": "0.2.0",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
"i18next": "23.12.2",
|
"i18next": "23.12.2",
|
||||||
@@ -51,8 +54,10 @@
|
|||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.1.3",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.1.3",
|
||||||
"livekit-client": "2.17.0",
|
"livekit-client": "2.17.0",
|
||||||
|
"lottie-web": "5.13.0",
|
||||||
"matrix-js-sdk": "38.2.0",
|
"matrix-js-sdk": "38.2.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
|
"pako": "2.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
@@ -4558,6 +4563,25 @@
|
|||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-http": {
|
||||||
|
"version": "2.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
|
||||||
|
"integrity": "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-http/node_modules/@tauri-apps/api": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-notification": {
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
@@ -4693,6 +4717,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
|
||||||
"integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA=="
|
"integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pako": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/prismjs": {
|
"node_modules/@types/prismjs": {
|
||||||
"version": "1.26.0",
|
"version": "1.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.0.tgz",
|
||||||
@@ -7479,6 +7509,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gif.js": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gif.js/-/gif.js-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-bYxCoT8OZKmbxY8RN4qDiYuj4nrQDTzgLRcFVovyona1PTWNePzI4nzOmotnlOFIzTk/ZxAHtv+TfVLiBWj/hw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "7.2.3",
|
"version": "7.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||||
@@ -8698,6 +8734,12 @@
|
|||||||
"loose-envify": "cli.js"
|
"loose-envify": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lottie-web": {
|
||||||
|
"version": "5.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
||||||
|
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -9291,6 +9333,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|||||||
@@ -30,10 +30,12 @@
|
|||||||
"@tauri-apps/api": "2.9.1",
|
"@tauri-apps/api": "2.9.1",
|
||||||
"@tauri-apps/plugin-dialog": "2.6.0",
|
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "2.4.5",
|
"@tauri-apps/plugin-fs": "2.4.5",
|
||||||
|
"@tauri-apps/plugin-http": "2.5.7",
|
||||||
"@tauri-apps/plugin-notification": "2.3.3",
|
"@tauri-apps/plugin-notification": "2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "2",
|
"@tauri-apps/plugin-opener": "2",
|
||||||
"@tauri-apps/plugin-process": "2.3.1",
|
"@tauri-apps/plugin-process": "2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "2.10.0",
|
"@tauri-apps/plugin-updater": "2.10.0",
|
||||||
|
"@types/pako": "2.0.4",
|
||||||
"@vanilla-extract/css": "1.9.3",
|
"@vanilla-extract/css": "1.9.3",
|
||||||
"@vanilla-extract/recipes": "0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.4.0",
|
"folds": "2.4.0",
|
||||||
|
"gif.js": "0.2.0",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
"i18next": "23.12.2",
|
"i18next": "23.12.2",
|
||||||
@@ -62,8 +65,10 @@
|
|||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.1.3",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.1.3",
|
||||||
"livekit-client": "2.17.0",
|
"livekit-client": "2.17.0",
|
||||||
|
"lottie-web": "5.13.0",
|
||||||
"matrix-js-sdk": "38.2.0",
|
"matrix-js-sdk": "38.2.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
|
"pako": "2.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
|||||||
3
public/gif.worker.js
Normal file
3
public/gif.worker.js
Normal file
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
|||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { GlobalPacks } from './GlobalPacks';
|
import { GlobalPacks } from './GlobalPacks';
|
||||||
import { UserPack } from './UserPack';
|
import { UserPack } from './UserPack';
|
||||||
|
import { TelegramImport } from './TelegramImport';
|
||||||
import { ImagePack } from '../../../plugins/custom-emoji';
|
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||||
import { ImagePackView } from '../../../components/image-pack-view';
|
import { ImagePackView } from '../../../components/image-pack-view';
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
|||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<UserPack onViewPack={setImagePack} />
|
<UserPack onViewPack={setImagePack} />
|
||||||
<GlobalPacks onViewPack={setImagePack} />
|
<GlobalPacks onViewPack={setImagePack} />
|
||||||
|
<TelegramImport onViewPack={setImagePack} />
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
167
src/app/features/settings/emojis-stickers/TelegramImport.tsx
Normal file
167
src/app/features/settings/emojis-stickers/TelegramImport.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
color,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import {
|
||||||
|
extractPackName,
|
||||||
|
importTelegramStickerPack,
|
||||||
|
} from '../../../utils/telegramStickerImport';
|
||||||
|
import { getUserImagePack, ImagePack } from '../../../plugins/custom-emoji';
|
||||||
|
|
||||||
|
const TELEGRAM_BOT_TOKEN_KEY = 'cinny_telegram_bot_token';
|
||||||
|
|
||||||
|
type TelegramImportProps = {
|
||||||
|
onViewPack?: (pack: ImagePack) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for importing Telegram sticker packs
|
||||||
|
*/
|
||||||
|
export function TelegramImport({ onViewPack }: TelegramImportProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [botToken, setBotToken] = useState(() =>
|
||||||
|
localStorage.getItem(TELEGRAM_BOT_TOKEN_KEY) || ''
|
||||||
|
);
|
||||||
|
const [packUrl, setPackUrl] = useState('');
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [success, setSuccess] = useState<string>('');
|
||||||
|
|
||||||
|
const handleBotTokenChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const token = e.target.value;
|
||||||
|
setBotToken(token);
|
||||||
|
localStorage.setItem(TELEGRAM_BOT_TOKEN_KEY, token);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePackUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPackUrl(e.target.value);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImport = useCallback(async () => {
|
||||||
|
if (!botToken.trim()) {
|
||||||
|
setError('Please enter your Telegram Bot token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packName = extractPackName(packUrl);
|
||||||
|
if (!packName) {
|
||||||
|
setError('Invalid Telegram sticker pack URL. Use format: t.me/addstickers/PackName');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
setProgress('Starting import...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await importTelegramStickerPack(
|
||||||
|
mx,
|
||||||
|
botToken.trim(),
|
||||||
|
packUrl.trim(),
|
||||||
|
(current, total, status) => {
|
||||||
|
setProgress(status);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const count = Object.keys(result.content.images || {}).length;
|
||||||
|
setSuccess(`Successfully imported ${count} stickers!`);
|
||||||
|
setPackUrl('');
|
||||||
|
|
||||||
|
// Show the user's pack with the newly imported stickers
|
||||||
|
const userPack = getUserImagePack(mx);
|
||||||
|
if (userPack && onViewPack) {
|
||||||
|
onViewPack(userPack);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to import sticker pack');
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
setProgress('');
|
||||||
|
}
|
||||||
|
}, [mx, botToken, packUrl, onViewPack]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Import from Telegram</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Telegram Bot Token"
|
||||||
|
description="Create a bot via @BotFather on Telegram to get a token. Your token is stored locally."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={botToken}
|
||||||
|
onChange={handleBotTokenChange}
|
||||||
|
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
|
style={{ width: '100%', marginTop: toRem(8) }}
|
||||||
|
disabled={importing}
|
||||||
|
/>
|
||||||
|
</SettingTile>
|
||||||
|
|
||||||
|
<SettingTile
|
||||||
|
title="Sticker Pack URL"
|
||||||
|
description="Paste a Telegram sticker pack link (e.g., t.me/addstickers/PackName)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={packUrl}
|
||||||
|
onChange={handlePackUrlChange}
|
||||||
|
placeholder="https://t.me/addstickers/YourPackName"
|
||||||
|
style={{ width: '100%', marginTop: toRem(8) }}
|
||||||
|
disabled={importing}
|
||||||
|
/>
|
||||||
|
</SettingTile>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Text size="T200" style={{ color: color.Success.Main }}>
|
||||||
|
{success}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importing && progress && (
|
||||||
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Spinner size="200" variant="Secondary" />
|
||||||
|
<Text size="T200" priority="300">{progress}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box justifyContent="End">
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importing || !packUrl.trim()}
|
||||||
|
>
|
||||||
|
<Text size="B300">{importing ? 'Importing...' : 'Import Pack'}</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
src/app/utils/animatedStickerConverter.ts
Normal file
344
src/app/utils/animatedStickerConverter.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import lottie from 'lottie-web/build/player/lottie_light';
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import pako from 'pako';
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import GIF from 'gif.js';
|
||||||
|
|
||||||
|
/** Animation item type from lottie-web */
|
||||||
|
type AnimationItem = ReturnType<typeof lottie.loadAnimation>;
|
||||||
|
|
||||||
|
/** Default GIF frame rate */
|
||||||
|
const GIF_FPS = 15;
|
||||||
|
|
||||||
|
/** Default GIF quality (1-20, lower is better but slower) */
|
||||||
|
const GIF_QUALITY = 10;
|
||||||
|
|
||||||
|
/** Maximum dimension for GIF output */
|
||||||
|
const MAX_GIF_SIZE = 256;
|
||||||
|
|
||||||
|
/** Transparency key color (magenta) - this color becomes transparent in the GIF */
|
||||||
|
const TRANSPARENT_COLOR = 0xFF00FF;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompress a TGS file (gzipped Lottie JSON)
|
||||||
|
* @param tgsData - The compressed TGS data
|
||||||
|
* @returns The Lottie animation JSON object
|
||||||
|
*/
|
||||||
|
function decompressTgs(tgsData: ArrayBuffer): object {
|
||||||
|
const decompressed = pako.inflate(new Uint8Array(tgsData), { to: 'string' });
|
||||||
|
return JSON.parse(decompressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a Lottie animation to GIF
|
||||||
|
* @param animationData - The Lottie JSON animation data
|
||||||
|
* @param width - Output width
|
||||||
|
* @param height - Output height
|
||||||
|
* @returns Promise resolving to GIF blob
|
||||||
|
*/
|
||||||
|
async function renderLottieToGif(
|
||||||
|
animationData: object,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Create a hidden container for rendering
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
width: ${width}px;
|
||||||
|
height: ${height}px;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
// Create canvas for capturing frames
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
document.body.removeChild(container);
|
||||||
|
reject(new Error('Failed to get canvas context'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Lottie animation
|
||||||
|
let animation: AnimationItem;
|
||||||
|
try {
|
||||||
|
animation = lottie.loadAnimation({
|
||||||
|
container,
|
||||||
|
renderer: 'svg',
|
||||||
|
loop: false,
|
||||||
|
autoplay: false,
|
||||||
|
animationData,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
document.body.removeChild(container);
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const workerScript = (GIF as any).getWorkerScript?.() || '/gif.worker.js';
|
||||||
|
|
||||||
|
// Initialize GIF encoder with transparency support
|
||||||
|
// Use magenta as the transparent color key
|
||||||
|
const gif = new GIF({
|
||||||
|
workers: 2,
|
||||||
|
quality: GIF_QUALITY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
workerScript,
|
||||||
|
transparent: TRANSPARENT_COLOR,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { totalFrames } = animation;
|
||||||
|
const duration = animation.getDuration(false) || 3; // Default 3 seconds if duration unknown
|
||||||
|
const frameDelay = 1000 / GIF_FPS;
|
||||||
|
const framesToCapture = Math.ceil(duration * GIF_FPS);
|
||||||
|
|
||||||
|
let capturedFrames = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and render the GIF
|
||||||
|
*/
|
||||||
|
const finishGif = () => {
|
||||||
|
gif.on('finished', (gifBlob: Blob) => {
|
||||||
|
animation.destroy();
|
||||||
|
document.body.removeChild(container);
|
||||||
|
resolve(gifBlob);
|
||||||
|
});
|
||||||
|
|
||||||
|
gif.on('error', (error: Error) => {
|
||||||
|
animation.destroy();
|
||||||
|
document.body.removeChild(container);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
gif.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a single frame from the animation
|
||||||
|
*/
|
||||||
|
const captureFrame = (frameIndex: number) => {
|
||||||
|
// Calculate the animation frame to seek to
|
||||||
|
const progress = frameIndex / framesToCapture;
|
||||||
|
const animFrame = Math.floor(progress * totalFrames);
|
||||||
|
|
||||||
|
animation.goToAndStop(animFrame, true);
|
||||||
|
|
||||||
|
// Get the SVG content and render to canvas
|
||||||
|
const svgElement = container.querySelector('svg');
|
||||||
|
if (svgElement) {
|
||||||
|
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||||
|
const img = new Image();
|
||||||
|
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(svgBlob);
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Fill with transparent key color first
|
||||||
|
ctx.fillStyle = '#FF00FF';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
gif.addFrame(ctx, { copy: true, delay: frameDelay, dispose: 2 });
|
||||||
|
capturedFrames += 1;
|
||||||
|
|
||||||
|
if (capturedFrames < framesToCapture) {
|
||||||
|
captureFrame(capturedFrames);
|
||||||
|
} else {
|
||||||
|
finishGif();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
capturedFrames += 1;
|
||||||
|
if (capturedFrames < framesToCapture) {
|
||||||
|
captureFrame(capturedFrames);
|
||||||
|
} else {
|
||||||
|
finishGif();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = url;
|
||||||
|
} else {
|
||||||
|
capturedFrames += 1;
|
||||||
|
if (capturedFrames < framesToCapture) {
|
||||||
|
captureFrame(capturedFrames);
|
||||||
|
} else {
|
||||||
|
finishGif();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start capturing frames
|
||||||
|
captureFrame(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a TGS (Telegram animated sticker) to GIF
|
||||||
|
* @param tgsBlob - The TGS file blob
|
||||||
|
* @param targetWidth - Desired output width (will maintain aspect ratio)
|
||||||
|
* @param targetHeight - Desired output height (will maintain aspect ratio)
|
||||||
|
* @returns Promise resolving to GIF blob
|
||||||
|
*/
|
||||||
|
export async function tgsToGif(
|
||||||
|
tgsBlob: Blob,
|
||||||
|
targetWidth = 256,
|
||||||
|
targetHeight = 256
|
||||||
|
): Promise<Blob> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('tgsToGif: Starting conversion, blob size:', tgsBlob.size);
|
||||||
|
const arrayBuffer = await tgsBlob.arrayBuffer();
|
||||||
|
const animationData = decompressTgs(arrayBuffer);
|
||||||
|
|
||||||
|
// Calculate output dimensions (maintain aspect ratio, cap at MAX_GIF_SIZE)
|
||||||
|
const width = Math.min(targetWidth, MAX_GIF_SIZE);
|
||||||
|
const height = Math.min(targetHeight, MAX_GIF_SIZE);
|
||||||
|
|
||||||
|
return renderLottieToGif(animationData, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a WEBM video sticker to GIF
|
||||||
|
* @param webmBlob - The WEBM file blob
|
||||||
|
* @param targetWidth - Desired output width
|
||||||
|
* @param targetHeight - Desired output height
|
||||||
|
* @returns Promise resolving to GIF blob
|
||||||
|
*/
|
||||||
|
export async function webmToGif(
|
||||||
|
webmBlob: Blob,
|
||||||
|
targetWidth = 256,
|
||||||
|
targetHeight = 256
|
||||||
|
): Promise<Blob> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('webmToGif: Starting conversion, blob size:', webmBlob.size);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const width = Math.min(targetWidth, MAX_GIF_SIZE);
|
||||||
|
const height = Math.min(targetHeight, MAX_GIF_SIZE);
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('Failed to get canvas context'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const workerScript = (GIF as any).getWorkerScript?.() || '/gif.worker.js';
|
||||||
|
|
||||||
|
// Initialize GIF encoder with transparency support
|
||||||
|
// Use magenta as the transparent color key
|
||||||
|
const gif = new GIF({
|
||||||
|
workers: 2,
|
||||||
|
quality: GIF_QUALITY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
workerScript,
|
||||||
|
transparent: TRANSPARENT_COLOR,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(webmBlob);
|
||||||
|
video.src = url;
|
||||||
|
video.load();
|
||||||
|
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('webmToGif: Video metadata loaded, duration:', video.duration);
|
||||||
|
const { duration } = video;
|
||||||
|
const frameDelay = 1000 / GIF_FPS;
|
||||||
|
const totalFrames = Math.ceil(duration * GIF_FPS);
|
||||||
|
let currentFrame = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture frames from video
|
||||||
|
*/
|
||||||
|
const captureNextFrame = () => {
|
||||||
|
if (currentFrame >= totalFrames) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('webmToGif: All frames captured, rendering GIF');
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
gif.on('finished', (blob: Blob) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('webmToGif: GIF rendered, size:', blob.size);
|
||||||
|
resolve(blob);
|
||||||
|
});
|
||||||
|
gif.on('error', (err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('webmToGif: GIF render error:', err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
gif.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (currentFrame / totalFrames) * duration;
|
||||||
|
video.currentTime = time;
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onseeked = () => {
|
||||||
|
// Fill with transparent key color first
|
||||||
|
ctx.fillStyle = '#FF00FF';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.drawImage(video, 0, 0, width, height);
|
||||||
|
gif.addFrame(ctx, { copy: true, delay: frameDelay, dispose: 2 });
|
||||||
|
currentFrame += 1;
|
||||||
|
captureNextFrame();
|
||||||
|
};
|
||||||
|
|
||||||
|
captureNextFrame();
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onerror = (e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('webmToGif: Video load error:', e, video.error);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error(`Failed to load video: ${video.error?.message || 'unknown error'}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add timeout in case video never loads
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!video.readyState) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('webmToGif: Video load timeout');
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Video load timeout'));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is a TGS (animated Telegram sticker)
|
||||||
|
* @param mimeType - The file MIME type
|
||||||
|
* @param filePath - The file path
|
||||||
|
* @returns True if TGS
|
||||||
|
*/
|
||||||
|
export function isTgsFile(mimeType: string, filePath: string): boolean {
|
||||||
|
return mimeType === 'application/x-tgsticker' || filePath.endsWith('.tgs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is a WEBM video sticker
|
||||||
|
* @param mimeType - The file MIME type
|
||||||
|
* @param filePath - The file path
|
||||||
|
* @returns True if WEBM
|
||||||
|
*/
|
||||||
|
export function isWebmFile(mimeType: string, filePath: string): boolean {
|
||||||
|
return mimeType === 'video/webm' || filePath.endsWith('.webm');
|
||||||
|
}
|
||||||
332
src/app/utils/telegramStickerImport.ts
Normal file
332
src/app/utils/telegramStickerImport.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
import { ImageUsage, PackContent, PackImages } from '../plugins/custom-emoji/types';
|
||||||
|
import { getAccountData } from './room';
|
||||||
|
import { tgsToGif, webmToGif } from './animatedStickerConverter';
|
||||||
|
|
||||||
|
/** Telegram Bot API base URL */
|
||||||
|
const TELEGRAM_API = 'https://api.telegram.org/bot';
|
||||||
|
|
||||||
|
/** Telegram file download base URL */
|
||||||
|
const TELEGRAM_FILE_API = 'https://api.telegram.org/file/bot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram sticker info from Bot API
|
||||||
|
*/
|
||||||
|
interface TelegramSticker {
|
||||||
|
file_id: string;
|
||||||
|
file_unique_id: string;
|
||||||
|
type: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
is_animated: boolean;
|
||||||
|
is_video: boolean;
|
||||||
|
emoji?: string;
|
||||||
|
set_name?: string;
|
||||||
|
thumbnail?: {
|
||||||
|
file_id: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram sticker set info from Bot API
|
||||||
|
*/
|
||||||
|
interface TelegramStickerSet {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
sticker_type: string;
|
||||||
|
contains_masks: boolean;
|
||||||
|
stickers: TelegramSticker[];
|
||||||
|
thumbnail?: {
|
||||||
|
file_id: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram API response wrapper
|
||||||
|
*/
|
||||||
|
interface TelegramResponse<T> {
|
||||||
|
ok: boolean;
|
||||||
|
result?: T;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress callback for import operation
|
||||||
|
*/
|
||||||
|
export type ImportProgressCallback = (current: number, total: number, status: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract pack name from a Telegram sticker URL
|
||||||
|
* Supports: t.me/addstickers/PackName, https://t.me/addstickers/PackName
|
||||||
|
* @param url - The Telegram sticker pack URL
|
||||||
|
* @returns The pack name, or null if invalid
|
||||||
|
*/
|
||||||
|
export function extractPackName(url: string): string | null {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
|
||||||
|
// Direct pack name (no URL)
|
||||||
|
if (/^[a-zA-Z0-9_]+$/.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL format: t.me/addstickers/PackName or https://t.me/addstickers/PackName
|
||||||
|
const match = trimmed.match(/(?:https?:\/\/)?t\.me\/addstickers\/([a-zA-Z0-9_]+)/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch sticker set info from Telegram Bot API
|
||||||
|
* @param botToken - Telegram Bot API token
|
||||||
|
* @param setName - Name of the sticker set
|
||||||
|
* @returns Sticker set info
|
||||||
|
*/
|
||||||
|
export async function fetchStickerSet(
|
||||||
|
botToken: string,
|
||||||
|
setName: string
|
||||||
|
): Promise<TelegramStickerSet> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${TELEGRAM_API}${botToken}/getStickerSet?name=${encodeURIComponent(setName)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch sticker set: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TelegramResponse<TelegramStickerSet> = await response.json();
|
||||||
|
|
||||||
|
if (!data.ok || !data.result) {
|
||||||
|
throw new Error(data.description || 'Failed to fetch sticker set');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file path for a Telegram file
|
||||||
|
* @param botToken - Telegram Bot API token
|
||||||
|
* @param fileId - Telegram file ID
|
||||||
|
* @returns File path for download
|
||||||
|
*/
|
||||||
|
async function getFilePath(botToken: string, fileId: string): Promise<string> {
|
||||||
|
const response = await fetch(`${TELEGRAM_API}${botToken}/getFile?file_id=${fileId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get file path: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TelegramResponse<{ file_path: string }> = await response.json();
|
||||||
|
|
||||||
|
if (!data.ok || !data.result) {
|
||||||
|
throw new Error(data.description || 'Failed to get file path');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.result.file_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from Telegram
|
||||||
|
* Uses Tauri HTTP plugin to bypass CORS restrictions
|
||||||
|
* @param botToken - Telegram Bot API token
|
||||||
|
* @param filePath - File path from getFile
|
||||||
|
* @returns File blob
|
||||||
|
*/
|
||||||
|
async function downloadFile(botToken: string, filePath: string): Promise<Blob> {
|
||||||
|
const response = await tauriFetch(`${TELEGRAM_FILE_API}${botToken}/${filePath}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download file: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a blob to Matrix and get the mxc:// URL
|
||||||
|
* @param mx - Matrix client
|
||||||
|
* @param blob - File blob to upload
|
||||||
|
* @param filename - Filename for the upload
|
||||||
|
* @returns mxc:// URL
|
||||||
|
*/
|
||||||
|
async function uploadToMatrix(
|
||||||
|
mx: MatrixClient,
|
||||||
|
blob: Blob,
|
||||||
|
filename: string
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await mx.uploadContent(blob, {
|
||||||
|
name: filename,
|
||||||
|
type: blob.type || 'image/webp',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.content_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine MIME type from file path
|
||||||
|
* @param filePath - Telegram file path
|
||||||
|
* @returns MIME type string
|
||||||
|
*/
|
||||||
|
function getMimeType(filePath: string): string {
|
||||||
|
if (filePath.endsWith('.webp')) return 'image/webp';
|
||||||
|
if (filePath.endsWith('.webm')) return 'video/webm';
|
||||||
|
if (filePath.endsWith('.tgs')) return 'application/x-tgsticker';
|
||||||
|
if (filePath.endsWith('.png')) return 'image/png';
|
||||||
|
if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) return 'image/jpeg';
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a Telegram sticker pack to Matrix
|
||||||
|
* @param mx - Matrix client
|
||||||
|
* @param botToken - Telegram Bot API token
|
||||||
|
* @param packUrl - Telegram sticker pack URL or name
|
||||||
|
* @param onProgress - Progress callback
|
||||||
|
* @returns The created pack content
|
||||||
|
*/
|
||||||
|
export async function importTelegramStickerPack(
|
||||||
|
mx: MatrixClient,
|
||||||
|
botToken: string,
|
||||||
|
packUrl: string,
|
||||||
|
onProgress?: ImportProgressCallback
|
||||||
|
): Promise<{ packId: string; content: PackContent }> {
|
||||||
|
// Extract pack name from URL
|
||||||
|
const packName = extractPackName(packUrl);
|
||||||
|
if (!packName) {
|
||||||
|
throw new Error('Invalid Telegram sticker pack URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.(0, 1, 'Fetching sticker pack info...');
|
||||||
|
|
||||||
|
// Fetch sticker set from Telegram
|
||||||
|
const stickerSet = await fetchStickerSet(botToken, packName);
|
||||||
|
|
||||||
|
const total = stickerSet.stickers.length;
|
||||||
|
const images: PackImages = {};
|
||||||
|
|
||||||
|
// Download and upload each sticker sequentially
|
||||||
|
// (Using sequential await is intentional to avoid overwhelming APIs)
|
||||||
|
for (let i = 0; i < stickerSet.stickers.length; i += 1) {
|
||||||
|
const sticker = stickerSet.stickers[i];
|
||||||
|
const isAnimated = sticker.is_animated;
|
||||||
|
const isVideo = sticker.is_video;
|
||||||
|
|
||||||
|
let stickerType = 'static';
|
||||||
|
if (isAnimated) stickerType = 'animated';
|
||||||
|
else if (isVideo) stickerType = 'video';
|
||||||
|
onProgress?.(i + 1, total, `Importing ${stickerType} sticker ${i + 1}/${total}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get file path from Telegram
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const filePath = await getFilePath(botToken, sticker.file_id);
|
||||||
|
|
||||||
|
// Download the sticker
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
let blob = await downloadFile(botToken, filePath);
|
||||||
|
|
||||||
|
// Generate a shortcode from emoji or index
|
||||||
|
const shortcode = sticker.emoji
|
||||||
|
? `${packName}_${sticker.emoji.codePointAt(0)?.toString(16) || i}`
|
||||||
|
: `${packName}_${i}`;
|
||||||
|
|
||||||
|
let finalMimeType = getMimeType(filePath);
|
||||||
|
let extension = 'webp';
|
||||||
|
|
||||||
|
// Convert animated stickers to GIF
|
||||||
|
if (isAnimated && filePath.endsWith('.tgs')) {
|
||||||
|
onProgress?.(i + 1, total, `Converting animated sticker ${i + 1}/${total} to GIF...`);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
blob = await tgsToGif(blob, sticker.width, sticker.height);
|
||||||
|
finalMimeType = 'image/gif';
|
||||||
|
extension = 'gif';
|
||||||
|
} else if (isVideo && filePath.endsWith('.webm')) {
|
||||||
|
onProgress?.(i + 1, total, `Converting video sticker ${i + 1}/${total} to GIF...`);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
blob = await webmToGif(blob, sticker.width, sticker.height);
|
||||||
|
finalMimeType = 'image/gif';
|
||||||
|
extension = 'gif';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to Matrix
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const mxcUrl = await uploadToMatrix(
|
||||||
|
mx,
|
||||||
|
blob,
|
||||||
|
`${shortcode}.${extension}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to images
|
||||||
|
images[shortcode] = {
|
||||||
|
url: mxcUrl,
|
||||||
|
body: sticker.emoji || shortcode,
|
||||||
|
usage: [ImageUsage.Emoticon, ImageUsage.Sticker],
|
||||||
|
info: {
|
||||||
|
w: sticker.width,
|
||||||
|
h: sticker.height,
|
||||||
|
mimetype: finalMimeType,
|
||||||
|
size: blob.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Log error and continue with other stickers
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`Failed to import sticker ${i + 1}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(images).length === 0) {
|
||||||
|
throw new Error('Failed to import any stickers from the pack');
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Successfully imported ${Object.keys(images).length} stickers`);
|
||||||
|
|
||||||
|
// Create pack content
|
||||||
|
const packContent: PackContent = {
|
||||||
|
pack: {
|
||||||
|
display_name: stickerSet.title,
|
||||||
|
usage: [ImageUsage.Emoticon, ImageUsage.Sticker],
|
||||||
|
},
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a unique pack ID
|
||||||
|
const packId = `telegram_${packName}`;
|
||||||
|
|
||||||
|
// Get existing user emotes or create new
|
||||||
|
const existingEvent = getAccountData(mx, AccountDataEvent.PoniesUserEmotes);
|
||||||
|
const existingEmotes = existingEvent?.getContent() as PackContent | undefined;
|
||||||
|
|
||||||
|
// Merge with existing pack if any
|
||||||
|
const mergedContent: PackContent = {
|
||||||
|
pack: existingEmotes?.pack || { usage: [ImageUsage.Emoticon, ImageUsage.Sticker] },
|
||||||
|
images: {
|
||||||
|
...existingEmotes?.images,
|
||||||
|
...images,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to user account data
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Saving merged pack to account data:', AccountDataEvent.PoniesUserEmotes);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Merged content:', JSON.stringify(mergedContent, null, 2));
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await mx.setAccountData(AccountDataEvent.PoniesUserEmotes as any, mergedContent as any);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Pack saved successfully');
|
||||||
|
|
||||||
|
// Force the account data event to be emitted for UI refresh
|
||||||
|
const updatedEvent = getAccountData(mx, AccountDataEvent.PoniesUserEmotes);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Updated account data after save:', updatedEvent?.getContent());
|
||||||
|
|
||||||
|
onProgress?.(total, total, 'Import complete!');
|
||||||
|
|
||||||
|
return { packId, content: packContent };
|
||||||
|
}
|
||||||
36
src/types/gif.js.d.ts
vendored
Normal file
36
src/types/gif.js.d.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
declare module 'gif.js' {
|
||||||
|
interface GIFOptions {
|
||||||
|
workers?: number;
|
||||||
|
quality?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
workerScript?: string;
|
||||||
|
repeat?: number;
|
||||||
|
background?: string;
|
||||||
|
transparent?: string | number | null;
|
||||||
|
dither?: boolean | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddFrameOptions {
|
||||||
|
delay?: number;
|
||||||
|
copy?: boolean;
|
||||||
|
dispose?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GIF {
|
||||||
|
constructor(options?: GIFOptions);
|
||||||
|
addFrame(
|
||||||
|
element: HTMLCanvasElement | CanvasRenderingContext2D | ImageData,
|
||||||
|
options?: AddFrameOptions
|
||||||
|
): void;
|
||||||
|
on(event: 'finished', callback: (blob: Blob) => void): void;
|
||||||
|
on(event: 'error', callback: (error: Error) => void): void;
|
||||||
|
on(event: 'progress', callback: (progress: number) => void): void;
|
||||||
|
on(event: 'start' | 'abort', callback: () => void): void;
|
||||||
|
render(): void;
|
||||||
|
abort(): void;
|
||||||
|
static getWorkerScript?(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GIF;
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ const copyFiles = {
|
|||||||
dest: '',
|
dest: '',
|
||||||
rename: 'pdf.worker.min.js',
|
rename: 'pdf.worker.min.js',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
src: 'node_modules/gif.js/dist/gif.worker.js',
|
||||||
|
dest: '',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
src: 'netlify.toml',
|
src: 'netlify.toml',
|
||||||
dest: '',
|
dest: '',
|
||||||
@@ -67,6 +71,32 @@ function serverMatrixSdkCryptoWasm(wasmFilePath) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serveGifWorker() {
|
||||||
|
return {
|
||||||
|
name: 'vite-plugin-serve-gif-worker',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
if (req.url === '/gif.worker.js') {
|
||||||
|
const resolvedPath = path.join(path.resolve(), '/node_modules/gif.js/dist/gif.worker.js');
|
||||||
|
|
||||||
|
if (fs.existsSync(resolvedPath)) {
|
||||||
|
res.setHeader('Content-Type', 'application/javascript');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
|
||||||
|
const fileStream = fs.createReadStream(resolvedPath);
|
||||||
|
fileStream.pipe(res);
|
||||||
|
} else {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('File not found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
appType: 'spa',
|
appType: 'spa',
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
@@ -81,6 +111,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
|
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
|
||||||
|
serveGifWorker(),
|
||||||
topLevelAwait({
|
topLevelAwait({
|
||||||
// The export name of top-level await promise for each chunk module
|
// The export name of top-level await promise for each chunk module
|
||||||
promiseExportName: '__tla',
|
promiseExportName: '__tla',
|
||||||
|
|||||||
Reference in New Issue
Block a user