diff --git a/.gitea/workflows/darwin.yaml b/.gitea/workflows/darwin.yaml
index 89705c4..8f06623 100644
--- a/.gitea/workflows/darwin.yaml
+++ b/.gitea/workflows/darwin.yaml
@@ -59,14 +59,14 @@ jobs:
chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
-l "$GITHUB_WORKSPACE/dist/builds/darwin/x64/Rosetta-*.pkg" \
- -r "${{ secrets.SSH_TARGET_DIR }}/darwin/x64" \
- -s "${{ secrets.SSH_HOST }}" \
- -u "${{ secrets.SSH_USERNAME }}" \
- -p '${{ secrets.SSH_PASSWORD }}'
+ -r "${{ secrets.SDU_SSH_TARGET_DIR }}/darwin/x64" \
+ -s "${{ secrets.SDU_SSH_HOST }}" \
+ -u "${{ secrets.SDU_SSH_USERNAME }}" \
+ -p '${{ secrets.SDU_SSH_PASSWORD }}'
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
-l "$GITHUB_WORKSPACE/dist/builds/darwin/arm64/Rosetta-*.pkg" \
- -r "${{ secrets.SSH_TARGET_DIR }}/darwin/arm64" \
- -s "${{ secrets.SSH_HOST }}" \
- -u "${{ secrets.SSH_USERNAME }}" \
- -p '${{ secrets.SSH_PASSWORD }}'
+ -r "${{ secrets.SDU_SSH_TARGET_DIR }}/darwin/arm64" \
+ -s "${{ secrets.SDU_SSH_HOST }}" \
+ -u "${{ secrets.SDU_SSH_USERNAME }}" \
+ -p '${{ secrets.SDU_SSH_PASSWORD }}'
\ No newline at end of file
diff --git a/.gitea/workflows/linux.yaml b/.gitea/workflows/linux.yaml
index ed1951a..f57760d 100644
--- a/.gitea/workflows/linux.yaml
+++ b/.gitea/workflows/linux.yaml
@@ -26,9 +26,9 @@ jobs:
uses: actions/cache@v5
with:
path: ${{ env.HOME }}/.npm-cache
- key: ${{ runner.os }}-npm-${{ hashFiles('**/package.json') }}
+ key: ${{ runner.os }}-npm-linux-${{ hashFiles('**/package.json') }}
restore-keys: |
- ${{ runner.os }}-npm-
+ ${{ runner.os }}-npm-linux-
if-no-files-found: ignore
# Кэш для electron-builder
- name: Cache electron-builder
@@ -37,9 +37,9 @@ jobs:
path: |
${{ env.HOME }}/Library/Caches/electron-builder
${{ env.HOME }}/Library/Caches/electron
- key: ${{ runner.os }}-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
+ key: ${{ runner.os }}-electron-linux-builder-${{ hashFiles('**/electron-builder.yml') }}
restore-keys: |
- ${{ runner.os }}-electron-builder-
+ ${{ runner.os }}-electron-linux-builder-
if-no-files-found: ignore
- name: NPM offline setup
shell: bash
@@ -59,14 +59,14 @@ jobs:
chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
-l "$GITHUB_WORKSPACE/dist/builds/linux/x64/Rosetta-*.AppImage" \
- -r "${{ secrets.SSH_TARGET_DIR }}/linux/x64" \
- -s "${{ secrets.SSH_HOST }}" \
- -u "${{ secrets.SSH_USERNAME }}" \
- -p '${{ secrets.SSH_PASSWORD }}'
+ -r "${{ secrets.SDU_SSH_TARGET_DIR }}/linux/x64" \
+ -s "${{ secrets.SDU_SSH_HOST }}" \
+ -u "${{ secrets.SDU_SSH_USERNAME }}" \
+ -p '${{ secrets.SDU_SSH_PASSWORD }}'
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
-l "$GITHUB_WORKSPACE/dist/builds/linux/arm64/Rosetta-*.AppImage" \
- -r "${{ secrets.SSH_TARGET_DIR }}/linux/arm64" \
- -s "${{ secrets.SSH_HOST }}" \
- -u "${{ secrets.SSH_USERNAME }}" \
- -p '${{ secrets.SSH_PASSWORD }}'
+ -r "${{ secrets.SDU_SSH_TARGET_DIR }}/linux/arm64" \
+ -s "${{ secrets.SDU_SSH_HOST }}" \
+ -u "${{ secrets.SDU_SSH_USERNAME }}" \
+ -p '${{ secrets.SDU_SSH_PASSWORD }}'
\ No newline at end of file
diff --git a/.gitea/workflows/service-packs.yaml b/.gitea/workflows/service-packs.yaml
index ab5bf4a..9ce0ca3 100644
--- a/.gitea/workflows/service-packs.yaml
+++ b/.gitea/workflows/service-packs.yaml
@@ -7,7 +7,7 @@ on:
branches:
- main
paths:
- - 'app/**'
+ - 'app/**'
jobs:
build:
@@ -69,6 +69,6 @@ jobs:
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
-l "$GITHUB_WORKSPACE/packs/*" \
-r "${{ secrets.SDU_SSH_PACKS }}" \
- -s "${{ secrets.SSH_HOST }}" \
- -u "${{ secrets.SSH_USERNAME }}" \
- -p '${{ secrets.SSH_PASSWORD }}'
\ No newline at end of file
+ -s "${{ secrets.SDU_SSH_HOST }}" \
+ -u "${{ secrets.SDU_SSH_USERNAME }}" \
+ -p '${{ secrets.SDU_SSH_PASSWORD }}'
\ No newline at end of file
diff --git a/.gitea/workflows/windows.yaml b/.gitea/workflows/windows.yaml
index a42c27b..2fffde3 100644
--- a/.gitea/workflows/windows.yaml
+++ b/.gitea/workflows/windows.yaml
@@ -53,7 +53,7 @@ jobs:
run: |
& "$env:GITHUB_WORKSPACE\.gitea\workflows\sshupload.ps1" `
-LocalFilePath "dist/builds/win/x64/Rosetta-*.exe" `
- -RemoteFolderPath "${{ secrets.SSH_TARGET_DIR }}/win32/x64" `
- -ServerAddress "${{ secrets.SSH_HOST }}" `
- -Username "${{ secrets.SSH_USERNAME }}" `
- -PasswordParam '${{ secrets.SSH_PASSWORD }}'
+ -RemoteFolderPath "${{ secrets.SDU_SSH_TARGET_DIR }}/win32/x64" `
+ -ServerAddress "${{ secrets.SDU_SSH_HOST }}" `
+ -Username "${{ secrets.SDU_SSH_USERNAME }}" `
+ -PasswordParam '${{ secrets.SDU_SSH_PASSWORD }}'
diff --git a/app/components/DialogInput/DialogInput.tsx b/app/components/DialogInput/DialogInput.tsx
index 4f3977c..e529a4e 100644
--- a/app/components/DialogInput/DialogInput.tsx
+++ b/app/components/DialogInput/DialogInput.tsx
@@ -4,7 +4,7 @@ import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorSc
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
-import { base64ImageToBlurhash, filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
+import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
import { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
import { DialogAttachment } from "../DialogAttachment/DialogAttachment";
import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing";
@@ -104,6 +104,12 @@ export function DialogInput() {
}, [dialog, editableDivRef]);
useEffect(() => {
+ if(systemAccounts.find((acc) => acc.publicKey == dialog)){
+ /**
+ * У системных аккаунтов нельзя отвечать на сообщения
+ */
+ return;
+ }
if(replyMessages.inDialogInput && replyMessages.inDialogInput == dialog){
setAttachments([{
type: AttachmentType.MESSAGES,
@@ -111,7 +117,9 @@ export function DialogInput() {
blob: JSON.stringify([...replyMessages.messages]),
preview: ""
}]);
- editableDivRef.current.focus();
+ if(editableDivRef.current){
+ editableDivRef.current.focus();
+ }
}
}, [dialog, replyMessages]);
@@ -208,6 +216,12 @@ export function DialogInput() {
}
const onClickCamera = async () => {
+ if(systemAccounts.find((acc) => acc.publicKey == dialog)){
+ /**
+ * У системных аккаунтов нельзя вызывать вложения
+ */
+ return;
+ }
if(avatars.length == 0){
return;
}
@@ -215,9 +229,11 @@ export function DialogInput() {
blob: avatars[0].avatar,
id: generateRandomKey(8),
type: AttachmentType.AVATAR,
- preview: await base64ImageToBlurhash(avatars[0].avatar)
+ preview: ""
}]);
- editableDivRef.current.focus();
+ if(editableDivRef.current){
+ editableDivRef.current.focus();
+ }
}
const sendTypeingPacket = () => {
@@ -229,6 +245,12 @@ export function DialogInput() {
}
const onPaste = async (event: React.ClipboardEvent) => {
+ if(systemAccounts.find((acc) => acc.publicKey == dialog)){
+ /**
+ * У системных аккаунтов нельзя вызывать вложения
+ */
+ return;
+ }
if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){
return;
}
@@ -242,14 +264,17 @@ export function DialogInput() {
const file = item.getAsFile();
if (file) {
const base64Image = await imagePrepareForNetworkTransfer(file);
+ const attachmentId = generateRandomKey(8);
setAttachments([...attachments, {
blob: base64Image,
- id: generateRandomKey(8),
+ id: attachmentId,
type: AttachmentType.IMAGE,
- preview: await base64ImageToBlurhash(base64Image)
+ preview: ""
}]);
}
- editableDivRef.current.focus();
+ if(editableDivRef.current){
+ editableDivRef.current.focus();
+ }
break;
}
}
@@ -273,9 +298,10 @@ export function DialogInput() {
return;
}
let fileContent = await filePrapareForNetworkTransfer(file);
+ const attachmentId = generateRandomKey(8);
setAttachments([...attachments, {
blob: fileContent,
- id: generateRandomKey(8),
+ id: attachmentId,
type: AttachmentType.FILE,
preview: files[0].size + "::" + files[0].name
}]);
diff --git a/app/components/MentionList/MentionRow.tsx b/app/components/MentionList/MentionRow.tsx
index e51208f..742be56 100644
--- a/app/components/MentionList/MentionRow.tsx
+++ b/app/components/MentionList/MentionRow.tsx
@@ -19,8 +19,8 @@ export function MentionRow(props : MentionRowProps) {
{props.username == 'all' && @}
{props.username == 'admin' && @}
{props.username != 'all' && props.username != 'admin' && 0 ? avatars[0].avatar : null}
>}
diff --git a/app/components/MessageAttachments/MessageAvatar.tsx b/app/components/MessageAttachments/MessageAvatar.tsx
index 5523635..99b1e01 100644
--- a/app/components/MessageAttachments/MessageAvatar.tsx
+++ b/app/components/MessageAttachments/MessageAvatar.tsx
@@ -4,12 +4,12 @@ import { AspectRatio, Button, Flex, Paper, Text } from "@mantine/core";
import { IconArrowDown } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { AttachmentProps } from "./MessageAttachments";
-import { blurhashToBase64Image } from "@/app/utils/utils";
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider";
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
import { PopoverLockIconAvatar } from "../PopoverLockIconAvatar/PopoverLockIconAvatar";
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
+import { blurhashToBase64Image } from "@/app/workers/image/image";
export function MessageAvatar(props: AttachmentProps) {
const colors = useRosettaColors();
@@ -25,10 +25,12 @@ export function MessageAvatar(props: AttachmentProps) {
const preview = getPreview();
const [blob, setBlob] = useState(props.attachment.blob);
const {lg} = useRosettaBreakpoints();
+ const [blurhashPreview, setBlurhashPreview] = useState("");
useEffect(() => {
constructBlob();
+ constructFromBlurhash();
}, [downloadStatus]);
const constructBlob = async () => {
@@ -57,6 +59,12 @@ export function MessageAvatar(props: AttachmentProps) {
}
}
+ const constructFromBlurhash = async () => {
+ if (preview.length < 20) return;
+ const blob = await blurhashToBase64Image(preview, 200, 220);
+ setBlurhashPreview(blob);
+ }
+
return (
@@ -79,7 +87,7 @@ export function MessageAvatar(props: AttachmentProps) {
height: 60,
borderRadius: '50%',
objectFit: 'cover'
- }} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}>
+ }} src={blurhashPreview == "" ? undefined : blurhashPreview}>
>
)}
diff --git a/app/components/MessageAttachments/MessageImage.tsx b/app/components/MessageAttachments/MessageImage.tsx
index 5e22afe..77f8ef2 100644
--- a/app/components/MessageAttachments/MessageImage.tsx
+++ b/app/components/MessageAttachments/MessageImage.tsx
@@ -1,14 +1,15 @@
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
import { useImageViewer } from "@/app/providers/ImageViewerProvider/useImageViewer";
-import { AspectRatio, Box, Flex, Overlay, Portal, Text } from "@mantine/core";
+import { AspectRatio, Box, Flex, Loader, Overlay, Portal, Text } from "@mantine/core";
import { IconArrowDown, IconCircleX, IconFlameFilled } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { AttachmentProps } from "./MessageAttachments";
-import { blurhashToBase64Image, isMessageDeliveredByTime } from "@/app/utils/utils";
+import { isMessageDeliveredByTime } from "@/app/utils/utils";
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider";
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
+import { blurhashToBase64Image } from "@/app/workers/image/image";
export function MessageImage(props: AttachmentProps) {
const colors = useRosettaColors();
@@ -25,9 +26,11 @@ export function MessageImage(props: AttachmentProps) {
const preview = getPreview();
const [blob, setBlob] = useState(props.attachment.blob);
const [loadedImage, setLoadedImage] = useState(false);
+ const [blurhashPreview, setBlurhashPreview] = useState("");
useEffect(() => {
constructBlob();
+ constructFromBlurhash();
}, [downloadStatus]);
const constructBlob = async () => {
@@ -45,6 +48,12 @@ export function MessageImage(props: AttachmentProps) {
open(images, 0);
}
+ const constructFromBlurhash = async () => {
+ if (preview.length < 20) return;
+ const blob = await blurhashToBase64Image(preview, 200, 220);
+ setBlurhashPreview(blob);
+ }
+
const onClick = () => {
if (downloadStatus == DownloadStatus.DOWNLOADED) {
openImageViewer();
@@ -83,7 +92,7 @@ export function MessageImage(props: AttachmentProps) {
borderRadius: 8,
objectFit: 'cover',
border: '1px solid ' + colors.borderColor
- }} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}>
+ }} src={blurhashPreview == "" ? undefined : blurhashPreview}>
>
)}
@@ -102,7 +111,43 @@ export function MessageImage(props: AttachmentProps) {
95 ? 95 : uploadedPercentage}>
- }
+ }
+ {props.delivered == DeliveredMessageState.WAITING && uploadedPercentage == 0 && isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length) &&
+
+
+
+
+
+ Encrypting...
+
+
+
+ }
+ {downloadStatus == DownloadStatus.DECRYPTING &&
+
+
+
+
+
+ Decrypting...
+
+
+
+ }
{(props.delivered == DeliveredMessageState.ERROR || (props.delivered != DeliveredMessageState.DELIVERED &&
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
)) && (
diff --git a/app/components/Messages/Messages.tsx b/app/components/Messages/Messages.tsx
index 44b1a6c..e08df20 100644
--- a/app/components/Messages/Messages.tsx
+++ b/app/components/Messages/Messages.tsx
@@ -7,14 +7,12 @@ import { MessageSkeleton } from "../MessageSkeleton/MessageSkeleton";
import { ScrollArea } from "@mantine/core";
import { MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S, SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX } from "@/app/constants";
import { DialogAffix } from "../DialogAffix/DialogAffix";
-import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
export function Messages() {
const colors = useRosettaColors();
const publicKey = usePublicKey();
const { messages, dialog, loadMessagesToTop, loading } = useDialog();
- const { replyMessages, isSelectionStarted } = useReplyMessages();
const viewportRef = useRef(null);
const lastMessageRef = useRef(null);
@@ -22,6 +20,7 @@ export function Messages() {
const shouldAutoScrollRef = useRef(true);
const isFirstRenderRef = useRef(true);
const previousScrollHeightRef = useRef(0);
+ const distanceFromButtomRef = useRef(0);
const [affix, setAffix] = useState(false);
const [wallpaper] = useSetting
@@ -75,25 +74,25 @@ export function Messages() {
return () => observer.disconnect();
}, [messages.length, loading]);
- // MutationObserver - отслеживаем изменения контента (загрузка картинок, видео)
useEffect(() => {
- if (!contentRef.current) return;
+ if (!contentRef.current || !viewportRef.current) return;
- const observer = new MutationObserver(() => {
- // Скроллим только если нужен авто-скролл
- if (shouldAutoScrollRef.current) {
+ const contentEl = contentRef.current;
+ //const viewportEl = viewportRef.current;
+ let lastHeight = contentEl.scrollHeight;
+
+ const ro = new ResizeObserver(() => {
+ const newHeight = contentEl.scrollHeight;
+ const grew = newHeight > lastHeight;
+ lastHeight = newHeight;
+
+ if (grew && shouldAutoScrollRef.current) {
scrollToBottom(true);
}
});
- observer.observe(contentRef.current, {
- childList: true,
- subtree: true,
- attributes: true,
- attributeFilter: ['src', 'style', 'class']
- });
-
- return () => observer.disconnect();
+ ro.observe(contentEl);
+ return () => ro.disconnect();
}, [scrollToBottom]);
// Первый рендер - скроллим вниз моментально
@@ -121,15 +120,10 @@ export function Messages() {
const lastMessage = messages[messages.length - 1];
// Скроллим если пользователь внизу или это его собственное сообщение
- if ((shouldAutoScrollRef.current || lastMessage.from_me) && !affix) {
- /**
- * Скролл только если пользователь не читает сейчас старую переписку
- * (!affix))
- */
- //console.info("Scroll because", shouldAutoScrollRef.current);
+ if ((shouldAutoScrollRef.current || lastMessage.from_me)) {
scrollToBottom(true);
}
- }, [messages.length, loading, affix, scrollToBottom]);
+ }, [messages.length, loading, scrollToBottom]);
// Восстановление позиции после загрузки старых сообщений
useEffect(() => {
@@ -142,12 +136,6 @@ export function Messages() {
}
}, [messages.length]);
- // Скролл при отправке reply сообщения
- useEffect(() => {
- if (replyMessages.messages.length === 0 || isSelectionStarted()) return;
- scrollToBottom(true);
- }, [replyMessages.messages.length]);
-
const loadMessagesToScrollAreaTop = async () => {
if (!viewportRef.current) return;
@@ -195,6 +183,7 @@ export function Messages() {
// Показываем/скрываем кнопку "вниз"
const distanceFromBottom =
(viewportRef.current.scrollHeight - viewportRef.current.clientHeight) - scroll.y;
+ distanceFromButtomRef.current = distanceFromBottom;
setAffix(distanceFromBottom > SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX);
}}
diff --git a/app/crypto/crypto.ts b/app/crypto/crypto.ts
deleted file mode 100644
index f7913bf..0000000
--- a/app/crypto/crypto.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { sha256, md5 } from "node-forge";
-import { generateRandomKey } from "../utils/utils";
-import * as secp256k1 from '@noble/secp256k1';
-
-const worker = new Worker(new URL('./crypto.worker.ts', import.meta.url), { type: 'module' });
-
-export const encodeWithPassword = async (password : string, data : any) : Promise => {
- let task = generateRandomKey(16);
- return new Promise((resolve, _) => {
- worker.addEventListener('message', (event: MessageEvent) => {
- if (event.data.action === 'encodeWithPasswordResult' && event.data.task === task) {
- resolve(event.data.result);
- }
- });
- worker.postMessage({ action: 'encodeWithPassword', data: { password, payload: data, task } });
- });
-}
-
-export const decodeWithPassword = (password : string, data : any) : Promise => {
- let task = generateRandomKey(16);
- return new Promise((resolve, reject) => {
- worker.addEventListener('message', (event: MessageEvent) => {
- if (event.data.action === 'decodeWithPasswordResult' && event.data.task === task) {
- if(event.data.result === null){
- reject("Decryption failed");
- return;
- }
- resolve(event.data.result);
- }
- });
- worker.postMessage({ action: 'decodeWithPassword', data: { password, payload: data, task } });
- });
-}
-
-export const generateKeyPairFromSeed = async (seed : string) => {
- //generate key pair using secp256k1 includes privatekey from seed
- const privateKey = sha256.create().update(seed).digest().toHex().toString();
- const publicKey = secp256k1.getPublicKey(Buffer.from(privateKey, "hex"), true);
- return {
- privateKey: privateKey,
- publicKey: Buffer.from(publicKey).toString('hex'),
- };
-
-}
-
-export const encrypt = async (data : string, publicKey : string) : Promise => {
- let task = generateRandomKey(16);
- return new Promise((resolve, _) => {
- worker.addEventListener('message', (event: MessageEvent) => {
- if (event.data.action === 'encryptResult' && event.data.task === task) {
- resolve(event.data.result);
- }
- });
- worker.postMessage({ action: 'encrypt', data: { publicKey, payload: data, task } });
- });
-}
-
-export const decrypt = async (data : string, privateKey : string) : Promise => {
- let task = generateRandomKey(16);
- return new Promise((resolve, reject) => {
- worker.addEventListener('message', (event: MessageEvent) => {
- if (event.data.action === 'decryptResult' && event.data.task === task) {
- if(event.data.result === null){
- reject("Decryption failed");
- return;
- }
- resolve(event.data.result);
- }
- });
- worker.postMessage({ action: 'decrypt', data: { privateKey, payload: data, task } });
- });
-}
-
-export const chacha20Encrypt = async (data : string) => {
- let task = generateRandomKey(16);
- return new Promise((resolve, _) => {
- worker.addEventListener('message', (event: MessageEvent) => {
- if (event.data.action === 'chacha20EncryptResult' && event.data.task === task) {
- resolve(event.data.result);
- }
- });
- worker.postMessage({ action: 'chacha20Encrypt', data: { payload: data, task } });
- });
-}
-
-export const chacha20Decrypt = async (ciphertext : string, nonce : string, key : string) => {
- let task = generateRandomKey(16);
- return new Promise((resolve, _) => {
- worker.addEventListener('message', (event: MessageEvent) => {
- if (event.data.action === 'chacha20DecryptResult' && event.data.task === task) {
- resolve(event.data.result);
- }
- });
- worker.postMessage({ action: 'chacha20Decrypt', data: { ciphertext, nonce, key, task } });
- });
-}
-
-export const generateMd5 = async (data : string) => {
- const hash = md5.create();
- hash.update(data);
- return hash.digest().toHex();
-}
-
-export const generateHashFromPrivateKey = async (privateKey : string) => {
- return sha256.create().update(privateKey + "rosetta").digest().toHex().toString();
-}
-
-export const isEncodedWithPassword = (data : string) => {
- try{
- atob(data).split(":");
- return true;
- } catch(e) {
- return false;
- }
-}
diff --git a/app/providers/AttachmentProvider/useAttachment.ts b/app/providers/AttachmentProvider/useAttachment.ts
index f7768f2..d153d60 100644
--- a/app/providers/AttachmentProvider/useAttachment.ts
+++ b/app/providers/AttachmentProvider/useAttachment.ts
@@ -4,7 +4,7 @@ import { useUploadStatus } from "../TransportProvider/useUploadStatus";
import { useFileStorage } from "../../hooks/useFileStorage";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
-import { decodeWithPassword, encodeWithPassword, generateMd5 } from "../../crypto/crypto";
+import { decodeWithPassword, encodeWithPassword, generateMd5 } from "../../workers/crypto/crypto";
import { useTransport } from "../TransportProvider/useTransport";
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
import { useConsoleLogger } from "../../hooks/useConsoleLogger";
diff --git a/app/providers/AvatarProvider/AvatarProvider.tsx b/app/providers/AvatarProvider/AvatarProvider.tsx
index de3e3de..0ef4274 100644
--- a/app/providers/AvatarProvider/AvatarProvider.tsx
+++ b/app/providers/AvatarProvider/AvatarProvider.tsx
@@ -2,7 +2,7 @@ import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { createContext, useEffect, useRef, useState } from "react";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { useFileStorage } from "@/app/hooks/useFileStorage";
-import { decodeWithPassword, encodeWithPassword, generateMd5 } from "@/app/crypto/crypto";
+import { decodeWithPassword, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { useSystemAccounts } from "../SystemAccountsProvider/useSystemAccounts";
import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants";
diff --git a/app/providers/DialogListProvider/useDialogInfo.ts b/app/providers/DialogListProvider/useDialogInfo.ts
index 7684874..cd576bb 100644
--- a/app/providers/DialogListProvider/useDialogInfo.ts
+++ b/app/providers/DialogListProvider/useDialogInfo.ts
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { DialogRow } from "./DialogListProvider";
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { usePublicKey } from "../AccountProvider/usePublicKey";
-import { decodeWithPassword } from "@/app/crypto/crypto";
+import { decodeWithPassword } from "@/app/workers/crypto/crypto";
import { constructLastMessageTextByAttachments } from "@/app/utils/constructLastMessageTextByAttachments";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { DeliveredMessageState, Message } from "../DialogProvider/DialogProvider";
diff --git a/app/providers/DialogProvider/DialogProvider.tsx b/app/providers/DialogProvider/DialogProvider.tsx
index 448edf4..d5fe8b7 100644
--- a/app/providers/DialogProvider/DialogProvider.tsx
+++ b/app/providers/DialogProvider/DialogProvider.tsx
@@ -1,4 +1,4 @@
-import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from '@/app/crypto/crypto';
+import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from '@/app/workers/crypto/crypto';
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
import { createContext, useEffect, useRef, useState } from 'react';
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
@@ -25,6 +25,7 @@ import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
import { useDialogsList } from '../DialogListProvider/useDialogsList';
import { useGroups } from './useGroups';
import { useMentions } from '../DialogStateProvider.tsx/useMentions';
+import { base64ImageToBlurhash } from '@/app/workers/image/image';
export interface DialogContextValue {
loading: boolean;
@@ -923,6 +924,14 @@ export function DialogProvider(props: DialogProviderProps) {
});
continue;
}
+ if((attachment.type == AttachmentType.IMAGE
+ || attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
+ /**
+ * Загружаем превью blurhash для изображения
+ */
+ const blurhash = await base64ImageToBlurhash(attachment.blob);
+ attachment.preview = blurhash;
+ }
doTimestampUpdateImMessageWhileAttachmentsSend(attachments);
const content = await encodeWithPassword(password, attachment.blob);
const upid = attachment.id;
diff --git a/app/providers/DialogProvider/useDialog.ts b/app/providers/DialogProvider/useDialog.ts
index 5ebfcd5..4b54615 100644
--- a/app/providers/DialogProvider/useDialog.ts
+++ b/app/providers/DialogProvider/useDialog.ts
@@ -1,6 +1,6 @@
import { useContext } from "react";
import { useDatabase } from "../DatabaseProvider/useDatabase";
-import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5} from "../../crypto/crypto";
+import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5} from "../../workers/crypto/crypto";
import { AttachmentMeta, DeliveredMessageState, DialogContext, Message } from "./DialogProvider";
import { Attachment, AttachmentType, PacketMessage } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
import { usePublicKey } from "../AccountProvider/usePublicKey";
diff --git a/app/providers/DialogProvider/useDialogFiber.ts b/app/providers/DialogProvider/useDialogFiber.ts
index 0831a27..4ee0a24 100644
--- a/app/providers/DialogProvider/useDialogFiber.ts
+++ b/app/providers/DialogProvider/useDialogFiber.ts
@@ -12,7 +12,7 @@ import { useDialogsCache } from "./useDialogsCache";
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { usePublicKey } from "../AccountProvider/usePublicKey";
-import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from "@/app/crypto/crypto";
+import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
import { DeliveredMessageState, Message } from "./DialogProvider";
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery";
diff --git a/app/providers/DialogProvider/useGroups.ts b/app/providers/DialogProvider/useGroups.ts
index 6f70dc8..78499d6 100644
--- a/app/providers/DialogProvider/useGroups.ts
+++ b/app/providers/DialogProvider/useGroups.ts
@@ -1,6 +1,6 @@
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
-import { decodeWithPassword, encodeWithPassword } from "@/app/crypto/crypto";
+import { decodeWithPassword, encodeWithPassword } from "@/app/workers/crypto/crypto";
import { generateRandomKey } from "@/app/utils/utils";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { usePublicKey } from "../AccountProvider/usePublicKey";
diff --git a/app/providers/SystemAccountsProvider/SystemAccountsProvider.tsx b/app/providers/SystemAccountsProvider/SystemAccountsProvider.tsx
index 9e14da3..8ec8713 100644
--- a/app/providers/SystemAccountsProvider/SystemAccountsProvider.tsx
+++ b/app/providers/SystemAccountsProvider/SystemAccountsProvider.tsx
@@ -3,7 +3,7 @@ import { createContext } from "react";
import { useSystemAccount } from "./useSystemAccount";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
-import { chacha20Encrypt, encodeWithPassword, encrypt } from "@/app/crypto/crypto";
+import { chacha20Encrypt, encodeWithPassword, encrypt } from "@/app/workers/crypto/crypto";
import { generateRandomKey } from "@/app/utils/utils";
import { DeliveredMessageState } from "../DialogProvider/DialogProvider";
import { UserInformation } from "../InformationProvider/InformationProvider";
diff --git a/app/providers/SystemProvider/SystemProvider.tsx b/app/providers/SystemProvider/SystemProvider.tsx
index 906aa06..3ced45c 100644
--- a/app/providers/SystemProvider/SystemProvider.tsx
+++ b/app/providers/SystemProvider/SystemProvider.tsx
@@ -1,4 +1,4 @@
-import { decodeWithPassword, encodeWithPassword } from "@/app/crypto/crypto";
+import { decodeWithPassword, encodeWithPassword } from "@/app/workers/crypto/crypto";
import { useFileStorage } from "@/app/hooks/useFileStorage";
import { generateRandomKey } from "@/app/utils/utils";
import { createContext, useEffect, useState } from "react";
diff --git a/app/utils/utils.ts b/app/utils/utils.ts
index 34b2d40..8278712 100644
--- a/app/utils/utils.ts
+++ b/app/utils/utils.ts
@@ -1,6 +1,5 @@
import { MantineColor } from "@mantine/core";
import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "../constants";
-import { decode, encode } from "blurhash";
export function generateRandomKey(length: number): string {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@@ -243,47 +242,3 @@ export function isImage(blob : string) : boolean {
}
return blob.startsWith('data:image/');
}
-
-export function blurhashToBase64Image(blurhash: string, width: number, height: number): string {
- const pixels = decode(blurhash, width, height);
- const canvas = document.createElement('canvas');
- canvas.width = width;
- canvas.height = height;
- const ctx = canvas.getContext('2d');
- const imageData = ctx?.createImageData(width, height);
- if (imageData) {
- imageData.data.set(pixels);
- ctx?.putImageData(imageData, 0, 0);
- return canvas.toDataURL();
- }
- return '';
-}
-
-export function base64ImageToBlurhash(base64Image: string): Promise {
- const img = new Image();
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
-
- return new Promise((resolve, reject) => {
- img.onload = () => {
- canvas.width = img.width;
- canvas.height = img.height;
- ctx?.drawImage(img, 0, 0);
- const imageData = ctx?.getImageData(0, 0, canvas.width, canvas.height);
- if (imageData) {
- const blurhash = encode(
- imageData.data,
- imageData.width,
- imageData.height,
- 4,
- 4
- );
- resolve(blurhash);
- } else {
- reject('Failed to get image data from canvas.');
- }
- };
- img.onerror = (error) => reject(error);
- img.src = base64Image;
- });
-}
\ No newline at end of file
diff --git a/app/version.ts b/app/version.ts
index 68773cb..a1b3d59 100644
--- a/app/version.ts
+++ b/app/version.ts
@@ -1,8 +1,15 @@
-export const APP_VERSION = "1.0.3";
+export const APP_VERSION = "1.0.4";
export const CORE_MIN_REQUIRED_VERSION = "1.4.9";
export const RELEASE_NOTICE = `
-**Update v1.0.3** :emoji_1f631:
-- Fix kernel update alert
-- Fix UI bugs.
+**Обновление v1.0.4** :emoji_1f631:
+- Улучшеный UI для взаимодействия с отправкой изображений
+- Исправлена блокировка потока при отправке изображений большого размера
+- Исправлены проблемы с утечками памяти
+- Исправлен вылет из приложения при попытке переслать сообщение
+- Исправлены проблемы со скроллам в групповых чатах
+- Исправлены проблемы с дерганием скролла в личных сообщениях
+- Улучшен наблюдатель за изменениями размера в контенте
+- Исправлена проблема с отображением аватара в упоминаниях
+- Множественные исправления мелких багов и улучшения производительности
`;
\ No newline at end of file
diff --git a/app/views/Backup/Backup.tsx b/app/views/Backup/Backup.tsx
index 411aadf..0cb7317 100644
--- a/app/views/Backup/Backup.tsx
+++ b/app/views/Backup/Backup.tsx
@@ -3,7 +3,7 @@ import { InternalScreen } from "@/app/components/InternalScreen/InternalScreen";
import { SettingsAlert } from "@/app/components/SettingsAlert/SettingsAlert";
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
import { TextChain } from "@/app/components/TextChain/TextChain";
-import { decodeWithPassword } from "@/app/crypto/crypto";
+import { decodeWithPassword } from "@/app/workers/crypto/crypto";
import { useAccount } from "@/app/providers/AccountProvider/useAccount";
import { Text } from "@mantine/core";
import { useState } from "react";
diff --git a/app/views/Lockscreen/Lockscreen.tsx b/app/views/Lockscreen/Lockscreen.tsx
index 80697fc..c634c61 100644
--- a/app/views/Lockscreen/Lockscreen.tsx
+++ b/app/views/Lockscreen/Lockscreen.tsx
@@ -3,7 +3,7 @@ import classes from './Lockscreen.module.css'
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import useWindow from "@/app/hooks/useWindow";
-import { decodeWithPassword, generateHashFromPrivateKey } from "@/app/crypto/crypto";
+import { decodeWithPassword, generateHashFromPrivateKey } from "@/app/workers/crypto/crypto";
import { useAccountProvider } from "@/app/providers/AccountProvider/useAccountProvider";
import { Account, AccountBase } from "@/app/providers/AccountProvider/AccountProvider";
import { useUserCache } from "@/app/providers/InformationProvider/useUserCache";
diff --git a/app/views/SetPassword/SetPassword.tsx b/app/views/SetPassword/SetPassword.tsx
index 82e729a..11692ab 100644
--- a/app/views/SetPassword/SetPassword.tsx
+++ b/app/views/SetPassword/SetPassword.tsx
@@ -7,7 +7,7 @@ import { mnemonicToSeed } from "web-bip39";
import { useNavigate } from "react-router-dom";
import { modals } from "@mantine/modals";
import { Buffer } from 'buffer'
-import { encodeWithPassword, generateHashFromPrivateKey, generateKeyPairFromSeed } from "@/app/crypto/crypto";
+import { encodeWithPassword, generateHashFromPrivateKey, generateKeyPairFromSeed } from "@/app/workers/crypto/crypto";
import { useAccountProvider } from "@/app/providers/AccountProvider/useAccountProvider";
import { Account } from "@/app/providers/AccountProvider/AccountProvider";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
diff --git a/app/workers/crypto/crypto.ts b/app/workers/crypto/crypto.ts
new file mode 100644
index 0000000..8c1714d
--- /dev/null
+++ b/app/workers/crypto/crypto.ts
@@ -0,0 +1,88 @@
+import { sha256, md5 } from "node-forge";
+import * as secp256k1 from '@noble/secp256k1';
+
+const worker = new Worker(new URL('./crypto.worker.ts', import.meta.url), { type: 'module' });
+
+type WorkerReq =
+ | { id: number; type: 'encodeWithPassword'; payload: { password: string; data: any } }
+ | { id: number; type: 'decodeWithPassword'; payload: { password: string; data: any } }
+ | { id: number; type: 'encrypt'; payload: { publicKey: string; data: string } }
+ | { id: number; type: 'decrypt'; payload: { privateKey: string; data: string } }
+ | { id: number; type: 'chacha20Encrypt'; payload: { data: string } }
+ | { id: number; type: 'chacha20Decrypt'; payload: { ciphertext: string; nonce: string; key: string } };
+
+type WorkerRes =
+ | { id: number; ok: true; data: any }
+ | { id: number; ok: false; error: string };
+
+let seq = 0;
+const pending = new Map void>();
+
+worker.onmessage = (e: MessageEvent) => {
+ const res = e.data;
+ const cb = pending.get(res.id);
+ if (cb) {
+ pending.delete(res.id);
+ cb(res);
+ }
+};
+
+function callWorker(req: Omit): Promise {
+ return new Promise((resolve, reject) => {
+ const id = ++seq;
+ pending.set(id, (res) => (res.ok ? resolve(res.data) : reject(res.error)));
+ worker.postMessage({ ...req, id });
+ });
+}
+
+export const encodeWithPassword = (password: string, data: any): Promise => {
+ return callWorker({ type: 'encodeWithPassword', payload: { password, data } });
+};
+
+export const decodeWithPassword = (password: string, data: any): Promise => {
+ return callWorker({ type: 'decodeWithPassword', payload: { password, data } });
+};
+
+export const encrypt = (data: string, publicKey: string): Promise => {
+ return callWorker({ type: 'encrypt', payload: { publicKey, data } });
+};
+
+export const decrypt = (data: string, privateKey: string): Promise => {
+ return callWorker({ type: 'decrypt', payload: { privateKey, data } });
+};
+
+export const chacha20Encrypt = (data: string): Promise => {
+ return callWorker({ type: 'chacha20Encrypt', payload: { data } });
+};
+
+export const chacha20Decrypt = (ciphertext: string, nonce: string, key: string): Promise => {
+ return callWorker({ type: 'chacha20Decrypt', payload: { ciphertext, nonce, key } });
+};
+
+export const generateKeyPairFromSeed = async (seed: string) => {
+ const privateKey = sha256.create().update(seed).digest().toHex().toString();
+ const publicKey = secp256k1.getPublicKey(Buffer.from(privateKey, "hex"), true);
+ return {
+ privateKey: privateKey,
+ publicKey: Buffer.from(publicKey).toString('hex'),
+ };
+};
+
+export const generateMd5 = async (data: string) => {
+ const hash = md5.create();
+ hash.update(data);
+ return hash.digest().toHex();
+};
+
+export const generateHashFromPrivateKey = async (privateKey: string) => {
+ return sha256.create().update(privateKey + "rosetta").digest().toHex().toString();
+};
+
+export const isEncodedWithPassword = (data: string) => {
+ try {
+ atob(data).split(":");
+ return true;
+ } catch (e) {
+ return false;
+ }
+};
\ No newline at end of file
diff --git a/app/crypto/crypto.worker.ts b/app/workers/crypto/crypto.worker.ts
similarity index 83%
rename from app/crypto/crypto.worker.ts
rename to app/workers/crypto/crypto.worker.ts
index ed5b8aa..6765fdf 100644
--- a/app/crypto/crypto.worker.ts
+++ b/app/workers/crypto/crypto.worker.ts
@@ -6,53 +6,36 @@ import * as secp256k1 from '@noble/secp256k1';
self.onmessage = async (event: MessageEvent) => {
- const { action, data } = event.data;
+ const { id, type, payload } = event.data;
- switch (action) {
- case 'encodeWithPassword': {
- const { password, payload, task } = data;
- const result = await encodeWithPassword(password, payload);
- self.postMessage({ action: 'encodeWithPasswordResult', result, task });
- break;
+ try {
+ let result;
+ switch (type) {
+ case 'encodeWithPassword':
+ result = await encodeWithPassword(payload.password, payload.data);
+ break;
+ case 'decodeWithPassword':
+ result = await decodeWithPassword(payload.password, payload.data);
+ break;
+ case 'encrypt':
+ result = await encrypt(payload.data, payload.publicKey);
+ break;
+ case 'decrypt':
+ console.info("decrypt", payload.privateKey, payload.data);
+ result = await decrypt(payload.data, payload.privateKey);
+ break;
+ case 'chacha20Encrypt':
+ result = await chacha20Encrypt(payload.data);
+ break;
+ case 'chacha20Decrypt':
+ result = await chacha20Decrypt(payload.ciphertext, payload.nonce, payload.key);
+ break;
+ default:
+ throw new Error(`Unknown action: ${type}`);
}
- case 'chacha20Encrypt': {
- const { payload, task } = data;
- const result = await chacha20Encrypt(payload);
- self.postMessage({ action: 'chacha20EncryptResult', result, task });
- break;
- }
- case 'chacha20Decrypt': {
- const { ciphertext, nonce, key, task } = data;
- const result = await chacha20Decrypt(ciphertext, nonce, key);
- self.postMessage({ action: 'chacha20DecryptResult', result, task });
- break;
- }
- case 'decodeWithPassword': {
- const { password, payload, task } = data;
- try{
- const result = await decodeWithPassword(password, payload);
- self.postMessage({ action: 'decodeWithPasswordResult', result, task });
- return;
- }catch(e){
- const result = null;
- self.postMessage({ action: 'decodeWithPasswordResult', result, task });
- }
- break;
- }
- case 'decrypt': {
- const { payload: encryptedData, privateKey, task } = data;
- const result = await decrypt(encryptedData, privateKey);
- self.postMessage({ action: 'decryptResult', result, task });
- break;
- }
- case 'encrypt': {
- const { payload: plainData, publicKey, task } = data;
- const result = await encrypt(plainData, publicKey);
- self.postMessage({ action: 'encryptResult', result, task });
- break;
- }
- default:
- console.error(`Unknown action: ${action}`);
+ self.postMessage({ id, ok: true, data: result });
+ } catch (error) {
+ self.postMessage({ id, ok: false, error: String(error) });
}
};
diff --git a/app/workers/image/image.ts b/app/workers/image/image.ts
new file mode 100644
index 0000000..33f3651
--- /dev/null
+++ b/app/workers/image/image.ts
@@ -0,0 +1,39 @@
+// ...existing code...
+const worker = new Worker(new URL('./image.worker.ts', import.meta.url), { type: 'module' });
+
+type WorkerReq =
+ | { id: number; type: 'blurhashToBase64Image'; payload: { blurhash: string; width: number; height: number } }
+ | { id: number; type: 'base64ImageToBlurhash'; payload: { base64Image: string } };
+
+type WorkerRes =
+ | { id: number; ok: true; data: string }
+ | { id: number; ok: false; error: string };
+
+let seq = 0;
+const pending = new Map void>();
+
+worker.onmessage = (e: MessageEvent) => {
+ const res = e.data;
+ const cb = pending.get(res.id);
+ if (cb) {
+ pending.delete(res.id);
+ cb(res);
+ }
+};
+
+function callWorker(req: Omit): Promise {
+ return new Promise((resolve, reject) => {
+ const id = ++seq;
+ pending.set(id, (res) => (res.ok ? resolve(res.data) : reject(res.error)));
+ worker.postMessage({ ...req, id });
+ });
+}
+
+export function blurhashToBase64Image(blurhash: string, width: number, height: number): Promise {
+ return callWorker({ type: 'blurhashToBase64Image', payload: { blurhash, width, height } });
+}
+
+export function base64ImageToBlurhash(base64Image: string): Promise {
+ return callWorker({ type: 'base64ImageToBlurhash', payload: { base64Image } });
+}
+// ...existing code...
\ No newline at end of file
diff --git a/app/workers/image/image.worker.ts b/app/workers/image/image.worker.ts
new file mode 100644
index 0000000..6d5842a
--- /dev/null
+++ b/app/workers/image/image.worker.ts
@@ -0,0 +1,71 @@
+import { decode, encode } from 'blurhash';
+
+type Req =
+ | { id: number; type: 'blurhashToBase64Image'; payload: { blurhash: string; width: number; height: number } }
+ | { id: number; type: 'base64ImageToBlurhash'; payload: { base64Image: string } };
+
+type Res =
+ | { id: number; ok: true; data: string }
+ | { id: number; ok: false; error: string };
+
+const toBase64 = async (blurhash: string, width: number, height: number): Promise => {
+ const pixels = decode(blurhash, width, height);
+ const canvas = new OffscreenCanvas(width, height);
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('No 2d context');
+ const imageData = ctx.createImageData(width, height);
+ imageData.data.set(pixels);
+ ctx.putImageData(imageData, 0, 0);
+ const blob = await canvas.convertToBlob({ type: 'image/png' });
+ const buf = new Uint8Array(await blob.arrayBuffer());
+ let bin = '';
+ for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
+ return `data:image/png;base64,${btoa(bin)}`;
+};
+
+const toBlurhash = async (base64Image: string): Promise => {
+ const src = base64Image?.trim();
+ if (!src) throw new Error('Empty image data');
+
+ const resp = await fetch(src);
+ const blob = await resp.blob();
+ if (!blob.size) throw new Error('Image fetch returned empty blob');
+
+ const bitmap = await createImageBitmap(blob);
+ const { width, height } = bitmap;
+ if (!width || !height) {
+ bitmap.close();
+ throw new Error(`Image has invalid size ${width}x${height}`);
+ }
+
+ const canvas = new OffscreenCanvas(width, height);
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ bitmap.close();
+ throw new Error('No 2d context');
+ }
+
+ ctx.drawImage(bitmap, 0, 0, width, height);
+ bitmap.close();
+
+ const imageData = ctx.getImageData(0, 0, width, height);
+ return encode(imageData.data, imageData.width, imageData.height, 4, 4);
+};
+
+self.onmessage = async (e: MessageEvent) => {
+ const { id, type, payload } = e.data;
+ const reply = (res: Res) => (self as unknown as Worker).postMessage(res);
+ try {
+ if (type === 'blurhashToBase64Image') {
+ const data = await toBase64(payload.blurhash, payload.width, payload.height);
+ reply({ id, ok: true, data });
+ } else if (type === 'base64ImageToBlurhash') {
+ const data = await toBlurhash(payload.base64Image);
+ reply({ id, ok: true, data });
+ } else {
+ throw new Error(`Unknown type ${type}`);
+ }
+ } catch (err: any) {
+ reply({ id, ok: false, error: String(err?.message ?? err) });
+ }
+};
\ No newline at end of file