Compare commits

...

16 Commits

Author SHA1 Message Date
RoyceDa
9c8d3865a6 1.0.4-1.4.9
Some checks failed
SP Builds / build (push) Has been cancelled
2026-02-19 23:03:16 +02:00
RoyceDa
a8ec08f0f5 Поднятие версии 2026-02-19 22:53:02 +02:00
RoyceDa
c41463d88f Поднятие версии и лог изменений 2026-02-19 22:51:52 +02:00
RoyceDa
66a3beec2c Улучшенный метод отслеживания изменения высоты для скроллинга 2026-02-19 22:45:37 +02:00
RoyceDa
d435809ae8 Улучшенный UI изображений 2026-02-19 22:35:02 +02:00
RoyceDa
b31c757a32 Правильный порядок аргументов в новом воркере 2026-02-19 21:58:02 +02:00
RoyceDa
5741097334 Исправление утечки памяти, оптимизация кода, лучшая читаемость кода 2026-02-19 21:53:55 +02:00
RoyceDa
95a1f57381 Оптимизация читаемости кода, лучшая организация архитектуры 2026-02-19 21:50:53 +02:00
RoyceDa
53535d68e0 Исправлена блокировка потока при вставке изображений, оптимизирован код и ответственность 2026-02-19 21:43:27 +02:00
RoyceDa
a38a331cd1 Оптимизации названий секретов, повышена читаемость кода 2026-02-19 21:30:31 +02:00
RoyceDa
6908dd486c Фикс неправильной сборки в результате кэширования старых зависимостей 2026-02-19 21:27:58 +02:00
RoyceDa
026a3c9520 Фикс дерганого скролла в сообщениях, более логичное поведение при пересылке сообщения 2026-02-19 20:52:27 +02:00
RoyceDa
a0c73c807f Фикс краша приложения при попытке вставки и ответа на сообщения 2026-02-19 19:46:10 +02:00
RoyceDa
ff96dfd204 Фикс краша при попытке ответить на системное сообщение 2026-02-19 19:37:37 +02:00
RoyceDa
f959c6335c Исправление неверного отображения аватара в упоминаниях 2026-02-19 19:31:31 +02:00
RoyceDa
bb55fb47aa Улучшение workflow 2026-02-18 20:41:37 +02:00
28 changed files with 398 additions and 293 deletions

View File

@@ -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 }}'

View File

@@ -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 }}'

View File

@@ -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 }}'
-s "${{ secrets.SDU_SSH_HOST }}" \
-u "${{ secrets.SDU_SSH_USERNAME }}" \
-p '${{ secrets.SDU_SSH_PASSWORD }}'

View File

@@ -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 }}'

View File

@@ -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
}]);

View File

@@ -19,8 +19,8 @@ export function MentionRow(props : MentionRowProps) {
{props.username == 'all' && <Avatar title="@" variant="filled" color={colors.brandColor}>@</Avatar>}
{props.username == 'admin' && <Avatar title="@" variant="filled" color={colors.error}>@</Avatar>}
{props.username != 'all' && props.username != 'admin' && <Avatar
title={props.title}
variant="filled"
name={props.title}
variant="light"
color="initials"
src={avatars.length > 0 ? avatars[0].avatar : null}
></Avatar>}

View File

@@ -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 (
<Paper withBorder p={'sm'}>
<Flex gap={'sm'} direction={'row'}>
@@ -79,7 +87,7 @@ export function MessageAvatar(props: AttachmentProps) {
height: 60,
borderRadius: '50%',
objectFit: 'cover'
}} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}></img>
}} src={blurhashPreview == "" ? undefined : blurhashPreview}></img>
</>
)}
</AspectRatio>

View File

@@ -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)}></img>
}} src={blurhashPreview == "" ? undefined : blurhashPreview}></img>
</>
)}
@@ -102,7 +111,43 @@ export function MessageImage(props: AttachmentProps) {
<AnimatedRoundedProgress size={40} value={uploadedPercentage > 95 ? 95 : uploadedPercentage}></AnimatedRoundedProgress>
</Box>
</Flex>
</Portal>}
</Portal>}
{props.delivered == DeliveredMessageState.WAITING && uploadedPercentage == 0 && isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length) &&
<Portal target={mainRef.current!}>
<Flex direction={'column'} pos={'absolute'} justify={'center'} top={0} h={'100%'} align={'center'} gap={'xs'}>
<Box p={'xs'} style={{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 8,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 4
}}>
<Loader size={15} type={'dots'} color={'white'}></Loader>
<Text size={'xs'} c={'white'}>
Encrypting...
</Text>
</Box>
</Flex>
</Portal>}
{downloadStatus == DownloadStatus.DECRYPTING &&
<Portal target={mainRef.current!}>
<Flex direction={'column'} pos={'absolute'} justify={'center'} top={0} h={'100%'} align={'center'} gap={'xs'}>
<Box p={'xs'} style={{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 8,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 4
}}>
<Loader size={15} type={'dots'} color={'white'}></Loader>
<Text size={'xs'} c={'white'}>
Decrypting...
</Text>
</Box>
</Flex>
</Portal>}
{(props.delivered == DeliveredMessageState.ERROR || (props.delivered != DeliveredMessageState.DELIVERED &&
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
)) && (

View File

@@ -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<HTMLDivElement | null>(null);
const lastMessageRef = useRef<HTMLDivElement | null>(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<string>
@@ -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);
}}

View File

@@ -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<any> => {
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<any> => {
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<any> => {
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<any> => {
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;
}
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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<string> {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
return new Promise<string>((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;
});
}

View File

@@ -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 для взаимодействия с отправкой изображений
- Исправлена блокировка потока при отправке изображений большого размера
- Исправлены проблемы с утечками памяти
- Исправлен вылет из приложения при попытке переслать сообщение
- Исправлены проблемы со скроллам в групповых чатах
- Исправлены проблемы с дерганием скролла в личных сообщениях
- Улучшен наблюдатель за изменениями размера в контенте
- Исправлена проблема с отображением аватара в упоминаниях
- Множественные исправления мелких багов и улучшения производительности
`;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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<number, (res: WorkerRes) => void>();
worker.onmessage = (e: MessageEvent<WorkerRes>) => {
const res = e.data;
const cb = pending.get(res.id);
if (cb) {
pending.delete(res.id);
cb(res);
}
};
function callWorker(req: Omit<WorkerReq, 'id'>): Promise<any> {
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<any> => {
return callWorker({ type: 'encodeWithPassword', payload: { password, data } });
};
export const decodeWithPassword = (password: string, data: any): Promise<any> => {
return callWorker({ type: 'decodeWithPassword', payload: { password, data } });
};
export const encrypt = (data: string, publicKey: string): Promise<any> => {
return callWorker({ type: 'encrypt', payload: { publicKey, data } });
};
export const decrypt = (data: string, privateKey: string): Promise<any> => {
return callWorker({ type: 'decrypt', payload: { privateKey, data } });
};
export const chacha20Encrypt = (data: string): Promise<any> => {
return callWorker({ type: 'chacha20Encrypt', payload: { data } });
};
export const chacha20Decrypt = (ciphertext: string, nonce: string, key: string): Promise<any> => {
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;
}
};

View File

@@ -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) });
}
};

View File

@@ -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<number, (res: WorkerRes) => void>();
worker.onmessage = (e: MessageEvent<WorkerRes>) => {
const res = e.data;
const cb = pending.get(res.id);
if (cb) {
pending.delete(res.id);
cb(res);
}
};
function callWorker(req: Omit<WorkerReq, 'id'>): Promise<string> {
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<string> {
return callWorker({ type: 'blurhashToBase64Image', payload: { blurhash, width, height } });
}
export function base64ImageToBlurhash(base64Image: string): Promise<string> {
return callWorker({ type: 'base64ImageToBlurhash', payload: { base64Image } });
}
// ...existing code...

View File

@@ -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<string> => {
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<string> => {
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<Req>) => {
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) });
}
};