diff --git a/package-lock.json b/package-lock.json index ece4dc8..9672954 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,12 @@ "@tauri-apps/api": "2.9.1", "@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-fs": "2.4.5", + "@tauri-apps/plugin-http": "2.5.7", "@tauri-apps/plugin-notification": "2.3.3", "@tauri-apps/plugin-opener": "2", "@tauri-apps/plugin-process": "2.3.1", "@tauri-apps/plugin-updater": "2.10.0", + "@types/pako": "2.0.4", "@vanilla-extract/css": "1.9.3", "@vanilla-extract/recipes": "0.3.0", "@vanilla-extract/vite-plugin": "3.7.1", @@ -40,6 +42,7 @@ "file-saver": "2.0.5", "focus-trap-react": "10.0.2", "folds": "2.4.0", + "gif.js": "0.2.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -51,8 +54,10 @@ "linkify-react": "4.1.3", "linkifyjs": "4.1.3", "livekit-client": "2.17.0", + "lottie-web": "5.13.0", "matrix-js-sdk": "38.2.0", "millify": "6.1.0", + "pako": "2.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", "react": "18.2.0", @@ -4558,6 +4563,25 @@ "@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": { "version": "2.3.3", "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", "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": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.0.tgz", @@ -7479,6 +7509,12 @@ "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": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8698,6 +8734,12 @@ "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": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -9291,6 +9333,12 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/package.json b/package.json index 9af3353..c0210f6 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,12 @@ "@tauri-apps/api": "2.9.1", "@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-fs": "2.4.5", + "@tauri-apps/plugin-http": "2.5.7", "@tauri-apps/plugin-notification": "2.3.3", "@tauri-apps/plugin-opener": "2", "@tauri-apps/plugin-process": "2.3.1", "@tauri-apps/plugin-updater": "2.10.0", + "@types/pako": "2.0.4", "@vanilla-extract/css": "1.9.3", "@vanilla-extract/recipes": "0.3.0", "@vanilla-extract/vite-plugin": "3.7.1", @@ -51,6 +53,7 @@ "file-saver": "2.0.5", "focus-trap-react": "10.0.2", "folds": "2.4.0", + "gif.js": "0.2.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -62,8 +65,10 @@ "linkify-react": "4.1.3", "linkifyjs": "4.1.3", "livekit-client": "2.17.0", + "lottie-web": "5.13.0", "matrix-js-sdk": "38.2.0", "millify": "6.1.0", + "pako": "2.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", "react": "18.2.0", diff --git a/public/gif.worker.js b/public/gif.worker.js new file mode 100644 index 0000000..269624e --- /dev/null +++ b/public/gif.worker.js @@ -0,0 +1,3 @@ +// gif.worker.js 0.2.0 - https://github.com/jnordberg/gif.js +(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o=ByteArray.pageSize)this.newPage();this.pages[this.page][this.cursor++]=val};ByteArray.prototype.writeUTFBytes=function(string){for(var l=string.length,i=0;i=0)this.dispose=disposalCode};GIFEncoder.prototype.setRepeat=function(repeat){this.repeat=repeat};GIFEncoder.prototype.setTransparent=function(color){this.transparent=color};GIFEncoder.prototype.addFrame=function(imageData){this.image=imageData;this.colorTab=this.globalPalette&&this.globalPalette.slice?this.globalPalette:null;this.getImagePixels();this.analyzePixels();if(this.globalPalette===true)this.globalPalette=this.colorTab;if(this.firstFrame){this.writeLSD();this.writePalette();if(this.repeat>=0){this.writeNetscapeExt()}}this.writeGraphicCtrlExt();this.writeImageDesc();if(!this.firstFrame&&!this.globalPalette)this.writePalette();this.writePixels();this.firstFrame=false};GIFEncoder.prototype.finish=function(){this.out.writeByte(59)};GIFEncoder.prototype.setQuality=function(quality){if(quality<1)quality=1;this.sample=quality};GIFEncoder.prototype.setDither=function(dither){if(dither===true)dither="FloydSteinberg";this.dither=dither};GIFEncoder.prototype.setGlobalPalette=function(palette){this.globalPalette=palette};GIFEncoder.prototype.getGlobalPalette=function(){return this.globalPalette&&this.globalPalette.slice&&this.globalPalette.slice(0)||this.globalPalette};GIFEncoder.prototype.writeHeader=function(){this.out.writeUTFBytes("GIF89a")};GIFEncoder.prototype.analyzePixels=function(){if(!this.colorTab){this.neuQuant=new NeuQuant(this.pixels,this.sample);this.neuQuant.buildColormap();this.colorTab=this.neuQuant.getColormap()}if(this.dither){this.ditherPixels(this.dither.replace("-serpentine",""),this.dither.match(/-serpentine/)!==null)}else{this.indexPixels()}this.pixels=null;this.colorDepth=8;this.palSize=7;if(this.transparent!==null){this.transIndex=this.findClosest(this.transparent,true)}};GIFEncoder.prototype.indexPixels=function(imgq){var nPix=this.pixels.length/3;this.indexedPixels=new Uint8Array(nPix);var k=0;for(var j=0;j=0&&x1+x=0&&y1+y>16,(c&65280)>>8,c&255,used)};GIFEncoder.prototype.findClosestRGB=function(r,g,b,used){if(this.colorTab===null)return-1;if(this.neuQuant&&!used){return this.neuQuant.lookupRGB(r,g,b)}var c=b|g<<8|r<<16;var minpos=0;var dmin=256*256*256;var len=this.colorTab.length;for(var i=0,index=0;i=0){disp=dispose&7}disp<<=2;this.out.writeByte(0|disp|0|transp);this.writeShort(this.delay);this.out.writeByte(this.transIndex);this.out.writeByte(0)};GIFEncoder.prototype.writeImageDesc=function(){this.out.writeByte(44);this.writeShort(0);this.writeShort(0);this.writeShort(this.width);this.writeShort(this.height);if(this.firstFrame||this.globalPalette){this.out.writeByte(0)}else{this.out.writeByte(128|0|0|0|this.palSize)}};GIFEncoder.prototype.writeLSD=function(){this.writeShort(this.width);this.writeShort(this.height);this.out.writeByte(128|112|0|this.palSize);this.out.writeByte(0);this.out.writeByte(0)};GIFEncoder.prototype.writeNetscapeExt=function(){this.out.writeByte(33);this.out.writeByte(255);this.out.writeByte(11);this.out.writeUTFBytes("NETSCAPE2.0");this.out.writeByte(3);this.out.writeByte(1);this.writeShort(this.repeat);this.out.writeByte(0)};GIFEncoder.prototype.writePalette=function(){this.out.writeBytes(this.colorTab);var n=3*256-this.colorTab.length;for(var i=0;i>8&255)};GIFEncoder.prototype.writePixels=function(){var enc=new LZWEncoder(this.width,this.height,this.indexedPixels,this.colorDepth);enc.encode(this.out)};GIFEncoder.prototype.stream=function(){return this.out};module.exports=GIFEncoder},{"./LZWEncoder.js":2,"./TypedNeuQuant.js":3}],2:[function(require,module,exports){var EOF=-1;var BITS=12;var HSIZE=5003;var masks=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];function LZWEncoder(width,height,pixels,colorDepth){var initCodeSize=Math.max(2,colorDepth);var accum=new Uint8Array(256);var htab=new Int32Array(HSIZE);var codetab=new Int32Array(HSIZE);var cur_accum,cur_bits=0;var a_count;var free_ent=0;var maxcode;var clear_flg=false;var g_init_bits,ClearCode,EOFCode;function char_out(c,outs){accum[a_count++]=c;if(a_count>=254)flush_char(outs)}function cl_block(outs){cl_hash(HSIZE);free_ent=ClearCode+2;clear_flg=true;output(ClearCode,outs)}function cl_hash(hsize){for(var i=0;i=0){disp=hsize_reg-i;if(i===0)disp=1;do{if((i-=disp)<0)i+=hsize_reg;if(htab[i]===fcode){ent=codetab[i];continue outer_loop}}while(htab[i]>=0)}output(ent,outs);ent=c;if(free_ent<1<0){outs.writeByte(a_count);outs.writeBytes(accum,0,a_count);a_count=0}}function MAXCODE(n_bits){return(1<0)cur_accum|=code<=8){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}if(free_ent>maxcode||clear_flg){if(clear_flg){maxcode=MAXCODE(n_bits=g_init_bits);clear_flg=false}else{++n_bits;if(n_bits==BITS)maxcode=1<0){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}flush_char(outs)}}this.encode=encode}module.exports=LZWEncoder},{}],3:[function(require,module,exports){var ncycles=100;var netsize=256;var maxnetpos=netsize-1;var netbiasshift=4;var intbiasshift=16;var intbias=1<>betashift;var betagamma=intbias<>3;var radiusbiasshift=6;var radiusbias=1<>3);var i,v;for(i=0;i>=netbiasshift;network[i][1]>>=netbiasshift;network[i][2]>>=netbiasshift;network[i][3]=i}}function altersingle(alpha,i,b,g,r){network[i][0]-=alpha*(network[i][0]-b)/initalpha;network[i][1]-=alpha*(network[i][1]-g)/initalpha;network[i][2]-=alpha*(network[i][2]-r)/initalpha}function alterneigh(radius,i,b,g,r){var lo=Math.abs(i-radius);var hi=Math.min(i+radius,netsize);var j=i+1;var k=i-1;var m=1;var p,a;while(jlo){a=radpower[m++];if(jlo){p=network[k--];p[0]-=a*(p[0]-b)/alpharadbias;p[1]-=a*(p[1]-g)/alpharadbias;p[2]-=a*(p[2]-r)/alpharadbias}}}function contest(b,g,r){var bestd=~(1<<31);var bestbiasd=bestd;var bestpos=-1;var bestbiaspos=bestpos;var i,n,dist,biasdist,betafreq;for(i=0;i>intbiasshift-netbiasshift);if(biasdist>betashift;freq[i]-=betafreq;bias[i]+=betafreq<>1;for(j=previouscol+1;j>1;for(j=previouscol+1;j<256;j++)netindex[j]=maxnetpos}function inxsearch(b,g,r){var a,p,dist;var bestd=1e3;var best=-1;var i=netindex[g];var j=i-1;while(i=0){if(i=bestd)i=netsize;else{i++;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist=0){p=network[j];dist=g-p[1];if(dist>=bestd)j=-1;else{j--;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist>radiusbiasshift;if(rad<=1)rad=0;for(i=0;i=lengthcount)pix-=lengthcount;i++;if(delta===0)delta=1;if(i%delta===0){alpha-=alpha/alphadec;radius-=radius/radiusdec;rad=radius>>radiusbiasshift;if(rad<=1)rad=0;for(j=0;j + diff --git a/src/app/features/settings/emojis-stickers/TelegramImport.tsx b/src/app/features/settings/emojis-stickers/TelegramImport.tsx new file mode 100644 index 0000000..5a0753f --- /dev/null +++ b/src/app/features/settings/emojis-stickers/TelegramImport.tsx @@ -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(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const handleBotTokenChange = useCallback((e: React.ChangeEvent) => { + const token = e.target.value; + setBotToken(token); + localStorage.setItem(TELEGRAM_BOT_TOKEN_KEY, token); + }, []); + + const handlePackUrlChange = useCallback((e: React.ChangeEvent) => { + 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 ( + + Import from Telegram + + + + + + + + + + {error && ( + + {error} + + )} + + {success && ( + + {success} + + )} + + {importing && progress && ( + + + {progress} + + )} + + + + + + + ); +} diff --git a/src/app/utils/animatedStickerConverter.ts b/src/app/utils/animatedStickerConverter.ts new file mode 100644 index 0000000..76e5692 --- /dev/null +++ b/src/app/utils/animatedStickerConverter.ts @@ -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; + +/** 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 { + 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 { + // 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 { + // 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'); +} diff --git a/src/app/utils/telegramStickerImport.ts b/src/app/utils/telegramStickerImport.ts new file mode 100644 index 0000000..8ed269d --- /dev/null +++ b/src/app/utils/telegramStickerImport.ts @@ -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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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 }; +} diff --git a/src/types/gif.js.d.ts b/src/types/gif.js.d.ts new file mode 100644 index 0000000..7d6b041 --- /dev/null +++ b/src/types/gif.js.d.ts @@ -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; +} diff --git a/vite.config.js b/vite.config.js index 30b17ee..eb37b56 100644 --- a/vite.config.js +++ b/vite.config.js @@ -18,6 +18,10 @@ const copyFiles = { dest: '', rename: 'pdf.worker.min.js', }, + { + src: 'node_modules/gif.js/dist/gif.worker.js', + dest: '', + }, { src: 'netlify.toml', 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({ appType: 'spa', publicDir: false, @@ -81,6 +111,7 @@ export default defineConfig({ }, plugins: [ serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'), + serveGifWorker(), topLevelAwait({ // The export name of top-level await promise for each chunk module promiseExportName: '__tla',