Compare commits

..

227 Commits

Author SHA1 Message Date
RoyceDa
f4592d03b0 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m41s
2026-04-10 18:56:13 +02:00
RoyceDa
6554483939 С binary на hex 2026-04-10 18:52:58 +02:00
RoyceDa
ba12db3c72 OPUS сборка 2026-04-10 17:54:48 +02:00
RoyceDa
b596d36543 Базовая версия голосовых сообщений и аудиоплеер. Кодирование OPUS 2026-04-10 17:20:44 +02:00
RoyceDa
93ef692eb5 Подготовка голосовых сообщений 2026-04-09 16:53:57 +02:00
RoyceDa
8fdfe9b786 Исправдение failed to decompress data 2026-04-09 16:36:23 +02:00
RoyceDa
547ac89987 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 4m4s
2026-04-08 23:05:03 +02:00
RoyceDa
130ad9c35a Исправление парсинга emoji и составных emoji (skin tones) 2026-04-08 22:58:49 +02:00
RoyceDa
adfc6add6f Исправление битых вложений в группах (на декодинг) 2026-04-08 21:55:59 +02:00
RoyceDa
cf29cecfd6 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 5m58s
2026-04-04 18:59:52 +02:00
RoyceDa
779c265851 Поднятие версии, outline
All checks were successful
SP Builds / build (push) Successful in 4m25s
2026-04-04 18:34:18 +02:00
RoyceDa
8ac952071d Фикс протокола 2026-04-04 18:23:24 +02:00
RoyceDa
e1f5cb7eb8 Перевод звонка в активную стадию 2026-04-04 18:18:05 +02:00
RoyceDa
30f2c90015 Фикс ассиметричного обмена 2026-04-04 18:07:24 +02:00
RoyceDa
a341aedd8d Фикс бесконечного обмена ключами 2026-04-04 18:04:11 +02:00
RoyceDa
a9164c7087 Фикс обмена ключами 2026-04-04 18:01:52 +02:00
RoyceDa
04dd23dd5c Фикс обмена ключами 2026-04-04 18:01:09 +02:00
RoyceDa
5979c31120 Таймаут вызова 2026-04-04 17:46:38 +02:00
RoyceDa
c8c85991c7 Исправление протокола для правильного END_CALL 2026-04-04 17:19:43 +02:00
RoyceDa
c052fdae41 Реализация нового протокола звонков 2026-04-04 16:48:26 +02:00
RoyceDa
3492a881cc Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m36s
2026-04-02 18:31:46 +02:00
RoyceDa
febeb58778 Фикс ICE кандидатов
Some checks failed
SP Builds / build (push) Has been cancelled
2026-04-02 17:57:38 +02:00
RoyceDa
93e4898bec Правильный deviceId 2026-04-02 17:38:34 +02:00
RoyceDa
de7a00f37a Обновление протокола звонков без авторизации с сохранением защиты 2026-04-02 17:26:38 +02:00
RoyceDa
7b3dd6c566 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m38s
2026-04-01 16:31:31 +02:00
RoyceDa
70af076248 Исправлен цвет аватарки при ее отсутствии в профиле 2026-04-01 16:31:27 +02:00
RoyceDa
92c9dc03c9 Время звонка начинается тогда, когда начинается аудио-дорожка, а не тогда, когда установлено соединение с SFU 2026-04-01 16:28:41 +02:00
RoyceDa
7e8d086a74 Исправление встречных звонков 2026-04-01 14:39:45 +02:00
RoyceDa
0a0c810105 Защита от принятия звонка принятого на другом устройстве 2026-04-01 14:21:11 +02:00
RoyceDa
8fbfb4fa5c Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m43s
2026-03-30 19:53:08 +02:00
RoyceDa
2b9e28ee4a Исправление системных звуков в звонке
Some checks failed
SP Builds / build (push) Has been cancelled
2026-03-30 19:41:06 +02:00
RoyceDa
d2a506119c Исправление невозможности выбора сообщений в диалоге 2026-03-30 19:37:17 +02:00
RoyceDa
269f66fdc5 Улучшение CI/CD 2026-03-29 17:03:30 +02:00
RoyceDa
5113d18d70 Фикс дергания при старте интерфейса
Some checks failed
Linux Kernel Build / build (arm64, arm64) (push) Failing after 2m40s
Linux Kernel Build / build (x64, x86_64) (push) Successful in 3m4s
MacOS Kernel Build / build (arm64) (push) Successful in 6m9s
SP Builds / build (push) Successful in 3m53s
Windows Kernel Build / build (push) Successful in 12m15s
MacOS Kernel Build / build (x64) (push) Successful in 6m56s
2026-03-29 16:09:28 +02:00
RoyceDa
cd2dee21ab Поднятие версии kernel 2026-03-29 16:07:02 +02:00
RoyceDa
1b14463dbb Поднятие версии kernel 2026-03-29 16:06:44 +02:00
RoyceDa
d23ca97be9 Поднятие версии.
Some checks failed
Linux Kernel Build / build (x64, x86_64) (push) Has been cancelled
Linux Kernel Build / build (arm64, arm64) (push) Has been cancelled
MacOS Kernel Build / build (x64) (push) Has been cancelled
MacOS Kernel Build / build (arm64) (push) Has been cancelled
SP Builds / build (push) Has been cancelled
Windows Kernel Build / build (push) Has been cancelled
2026-03-29 15:58:19 +02:00
RoyceDa
519aa8802f Улучшение протокола, чистка кода 2026-03-29 15:45:08 +02:00
RoyceDa
2f2a0b5376 Передача транспортного сервера в контекст, в базу, и в кэш 2026-03-29 14:58:52 +02:00
RoyceDa
61e83bdd43 Унификация кодирования chacha_key_plain теперь он в hex, а не в utf8 с потерей данных 2026-03-28 17:52:13 +02:00
RoyceDa
f5bfa153b6 Приведение chacha_key_plain к hex для транспортировки 2026-03-28 17:20:03 +02:00
RoyceDa
81f5e66c56 Дополнительные доменные зоны 2026-03-28 17:07:51 +02:00
RoyceDa
aaa4b4283a Новая система пересылки сообщений без трансляции и транскодирования вложений 2026-03-28 17:07:43 +02:00
RoyceDa
c9cff515e5 Передача chachakey и начало нового протокола вложений 2026-03-27 20:07:40 +02:00
RoyceDa
94ba139541 Обновление протокола вложений 2026-03-27 15:58:16 +02:00
RoyceDa
7e0e97f472 Новый протокол кодирования и декодирования вложений 2026-03-27 15:32:10 +02:00
RoyceDa
8d6090e632 Новая система вложений 2026-03-26 22:29:03 +02:00
RoyceDa
fd3fac54f6 Новая сериализация, оптимизации приема и парсинга пакетов 2026-03-26 22:28:43 +02:00
RoyceDa
bd3c0eec69 Исправлена возможность позвонить в системный аккаунт 2026-03-24 17:58:28 +02:00
RoyceDa
697b797f8c Исправлен баг интерфейса связанный с долгой загрузкой изображений 2026-03-24 17:57:43 +02:00
RoyceDa
429aa614d7 Исправление CI/CD 2026-03-24 17:29:47 +02:00
RoyceDa
e727529b89 Фикс CMD +, фикс зума, изменен верхний toolbar
Some checks failed
Linux Kernel Build / build (arm64, arm64) (push) Failing after 2m25s
Linux Kernel Build / build (x64, x86_64) (push) Successful in 2m56s
SP Builds / build (push) Successful in 3m35s
MacOS Kernel Build / build (arm64) (push) Successful in 11m54s
Windows Kernel Build / build (push) Successful in 16m16s
MacOS Kernel Build / build (x64) (push) Successful in 8m42s
2026-03-24 17:19:22 +02:00
RoyceDa
786d5428f8 Merge branch 'main' into dev 2026-03-24 16:50:46 +02:00
RoyceDa
e4da2510cc Улучшение CI/CD 2026-03-24 16:35:01 +02:00
RoyceDa
0e5384b908 ci/cd test 2026-03-24 16:19:07 +02:00
013a5d9f17 Обновить .gitea/workflows/linux.yaml 2026-03-24 14:13:09 +00:00
RoyceDa
f997581c23 Исправление вложения звонка 2026-03-24 16:11:42 +02:00
RoyceDa
1333eb40ce Улучшение CI/CD 2026-03-24 16:11:27 +02:00
RoyceDa
57be4631f2 CI/CD без кэша 2026-03-22 20:08:19 +02:00
RoyceDa
7c2718ff9a Merge branch 'dev' into main
All checks were successful
SP Builds / build (push) Successful in 14m6s
2026-03-22 19:59:52 +02:00
RoyceDa
40b2d9c3f0 Улучшение CI/CD 2026-03-22 19:59:15 +02:00
RoyceDa
13d52c694f Поднятие версии 2026-03-22 19:58:26 +02:00
RoyceDa
27f011ec61 Merge branch 'dev' into main
Some checks failed
SP Builds / build (push) Has been cancelled
2026-03-22 19:56:47 +02:00
RoyceDa
7e977b762f Правильная расстановка иконок 2026-03-22 19:52:24 +02:00
RoyceDa
6dc35d7cca Новое вложение ЗВОНОК теперь отправляется от лица звонящего 2026-03-22 19:49:59 +02:00
RoyceDa
a1c8b3d95a Трансляция Emoji из Unicode к общему виду 2026-03-22 19:21:11 +02:00
RoyceDa
d2e574d186 CI/CD без рестарта докера 2026-03-22 18:28:28 +02:00
RoyceDa
2e9ccf9c6e Merge branch 'dev' into main 2026-03-22 17:30:50 +02:00
RoyceDa
1eca665a62 CI/CD 2026-03-22 17:19:58 +02:00
RoyceDa
319ff7baf1 Merge branch 'main' of https://git.rosetta.im/Rosetta/desktop into main 2026-03-22 16:44:59 +02:00
RoyceDa
e61c9c5f58 Merge branch 'dev' into main 2026-03-22 16:43:57 +02:00
RoyceDa
426f0c40bc Проблема обмена ключами в звонках 2026-03-22 16:41:09 +02:00
RoyceDa
b300fa4d03 Улучшен CI/CD 2026-03-22 16:08:24 +02:00
04cd27a5f3 Обновить .gitea/workflows/service-packs.yaml 2026-03-21 19:41:14 +00:00
d29fe317a8 Merge pull request '1.1.2-1.5.3' (#20) from dev into main
Some checks failed
SP Builds / build (push) Has been cancelled
Reviewed-on: #20
2026-03-21 19:37:20 +00:00
RoyceDa
329e6d7825 Улучшенный CI/CD 2026-03-21 21:33:24 +02:00
RoyceDa
bd3411de52 Поднятие версии 2026-03-21 21:31:57 +02:00
RoyceDa
6f95f326bf Поднятие версии 2026-03-21 21:30:39 +02:00
RoyceDa
91b955d621 События звонков в сообщениях 2026-03-21 21:28:20 +02:00
RoyceDa
e019702dbe Новый тип вложений - Attachment.CALL с активными звонками 2026-03-21 21:18:09 +02:00
RoyceDa
48e0cddbaa Фикс звонков самому себе 2026-03-21 19:39:47 +02:00
RoyceDa
98fbabc130 Финальная часть после тестов скорости шифрования кадров (frames) 2026-03-21 19:37:52 +02:00
RoyceDa
0c823c398f Шифрование тест 4 2026-03-21 19:21:56 +02:00
RoyceDa
1d6c30fb08 Шифрование тест 3 2026-03-21 19:16:44 +02:00
RoyceDa
0d70824d77 Шифрование тест 2 2026-03-21 19:03:43 +02:00
RoyceDa
4df39cb83d WASM для ускорения шифрования звонков, тест 2026-03-21 18:51:39 +02:00
RoyceDa
f269046c46 Отладка производительности 2026-03-20 22:25:33 +02:00
RoyceDa
5032d92f8e WASM ускоренный алгоритм шифрования для избежания backpressure 2026-03-20 21:24:36 +02:00
d4680ab6fc Merge pull request '1.1.1-1.5.3' (#17) from dev into main
All checks were successful
Windows Kernel Build / build (push) Successful in 14m52s
MacOS Kernel Build / build (push) Successful in 17m27s
SP Builds / build (push) Successful in 6m22s
Linux Kernel Build / build (push) Successful in 41m22s
Reviewed-on: #17
2026-03-20 16:49:29 +00:00
RoyceDa
e5a4c92ba7 Поднятие версии 2026-03-20 18:48:11 +02:00
RoyceDa
f91392e6aa Шифрование, финальная версия E2EE и обмен ключами с DH 2026-03-20 18:30:07 +02:00
RoyceDa
46af6661a1 Шифрование тест 2026-03-20 18:22:49 +02:00
RoyceDa
59d40e3005 Шифрование тест 2026-03-20 18:18:04 +02:00
RoyceDa
d3cda685cd Шифрование тест 2026-03-20 17:26:52 +02:00
RoyceDa
427f2e9e33 Шифрование звонков E2EE 2026-03-20 17:18:46 +02:00
RoyceDa
9f8840e077 Фикс звуков из ресурсов 2026-03-20 16:46:23 +02:00
523d67b01f 1.1.0-1.5.2
All checks were successful
Windows Kernel Build / build (push) Successful in 13m21s
MacOS Kernel Build / build (push) Successful in 13m57s
SP Builds / build (push) Successful in 6m41s
Linux Kernel Build / build (push) Successful in 29m21s
Reviewed-on: #16
2026-03-18 17:59:13 +00:00
RoyceDa
8f0e8e8251 Установка PROD сервера 2026-03-18 19:57:16 +02:00
RoyceDa
61d55f266f Поднятие версии 2026-03-18 19:56:39 +02:00
RoyceDa
824b1fec65 Правки в ядре для показа окна поверх всех окон при звонке 2026-03-18 18:28:37 +02:00
RoyceDa
41d7a89830 Дополнительные поправки по событийным звукам 2026-03-18 18:28:10 +02:00
RoyceDa
88288317ab Событийные звуки звонка (сбросить, мутинг, и прочее...) 2026-03-18 18:27:39 +02:00
RoyceDa
7b9936dcc4 Исправление неправильной отправки сетевого пакета при renegotation завершенного звонка 2026-03-17 19:19:36 +02:00
RoyceDa
fcf4204063 Обработка END_CALL_BECAUSE_BUSY и END_CALL_BECAUSE_PEER_DISCONNECTED 2026-03-17 18:39:06 +02:00
RoyceDa
6dd348230f Реализация динамического запроса транспортных серверов в соответствии с поправками в g365sfu 2026-03-17 15:02:57 +02:00
RoyceDa
2c026d596d Правильная обработка SDPOffer при renegotiation от SFU 2026-03-16 19:27:16 +02:00
RoyceDa
ab57303eb6 Буферизация ICE кандидатов (для избежания гонки) 2026-03-15 17:22:48 +02:00
RoyceDa
f57ec484e3 Исправлена опечатка TURN 2026-03-14 23:13:00 +02:00
RoyceDa
f0d0909382 Динамический запрос ICE серверов 2026-03-14 23:05:54 +02:00
RoyceDa
76442c4161 Обработка треков и IceCandidates 2026-03-14 20:23:29 +02:00
RoyceDa
0513a90036 Добавление треков (аудио) в RTCPeerConnection 2026-03-14 18:38:04 +02:00
RoyceDa
0600da5b7c Signal Peer исправление Src/Dst 2026-03-14 15:43:49 +02:00
RoyceDa
8dc2537cdc WebRTC пакет в протоколе 2026-03-14 15:36:26 +02:00
RoyceDa
2707bd2a39 Обработка WebRTC SDP/ICE 2026-03-14 15:28:24 +02:00
RoyceDa
ca36a8d818 Обмен SDP, создание комнаты, улучшенная организация кода 2026-03-14 15:28:01 +02:00
RoyceDa
e79282755b Финальный обмен ключами шифрования, все готово для установки WebRTC соединения 2026-03-11 17:22:29 +02:00
RoyceDa
e06d58facf Реализация сигналинга и обмена ключами 2026-03-02 18:53:15 +02:00
RoyceDa
7a89a3a307 OFFERS & ANSWERS webRTC 2026-02-28 18:31:21 +02:00
RoyceDa
9ad0e5d00a Правильные SignalType 2026-02-28 17:42:10 +02:00
RoyceDa
9eac2fae6f Обмен ключами шифрования DH 2026-02-28 17:33:23 +02:00
RoyceDa
461ccbfa94 Дизайн звонков 2026-02-28 12:48:53 +02:00
RoyceDa
8b16c4ce0f Подложка к вложению аватарки 2026-02-26 20:55:55 +02:00
RoyceDa
c3a53b517e Фикс ошибки чтения 2026-02-26 20:54:52 +02:00
RoyceDa
b9603462a0 Подложка под прозрачные аватарки 2026-02-26 12:40:19 +02:00
RoyceDa
84d3cc7be4 Прозрачным аватаркам добавлена подложка 2026-02-26 12:20:35 +02:00
RoyceDa
a431b23476 Прозрачным аватаркам добавлена подложка 2026-02-26 12:19:30 +02:00
RoyceDa
88369171b6 Запоминание выбора сообщения при переключении между диалогами 2026-02-26 00:19:49 +02:00
e2b767779f 1.0.8-1.5.0
All checks were successful
SP Builds / build (push) Successful in 5m26s
Reviewed-on: #15
2026-02-24 17:20:21 +00:00
RoyceDa
4e42eb3c02 Поднятие версии 2026-02-24 19:19:00 +02:00
RoyceDa
49d7d9ff62 Фикс установки аватарки у группы 2026-02-24 19:15:58 +02:00
RoyceDa
fabd85106d Оптимизация размера лог-файла 2026-02-24 19:11:56 +02:00
RoyceDa
0b3bdface8 Исправление фонового скролла у изображения 2026-02-24 18:53:04 +02:00
6a0a97798d 1.0.7-1.5.0
All checks were successful
SP Builds / build (push) Successful in 5m34s
Reviewed-on: #14
2026-02-24 16:46:36 +00:00
RoyceDa
7c806149b3 Поднятие версии 2026-02-24 18:44:37 +02:00
RoyceDa
f1fb7ba252 Скрываем счетчик непрочитанных при синхронизации 2026-02-24 18:31:04 +02:00
RoyceDa
453cc55fc0 Исправление консольной ошибки при синхронизации 2026-02-24 18:17:48 +02:00
RoyceDa
785406671c Синхронизация прочтения в группах и фикс синхронизации сообщений в группах 2026-02-24 17:33:26 +02:00
RoyceDa
089fa055d3 Синхронизация сообщений в группах 2026-02-24 16:06:21 +02:00
RoyceDa
fbc4f73f3d Улучшенная организация кода 2026-02-24 15:42:15 +02:00
RoyceDa
bf057c14f4 Защищенная синхронизация ключей и мета-данных групп 2026-02-24 14:33:47 +02:00
RoyceDa
8952fe43e8 Фикс уведомлений при синхронизации в результате неправильного условия 2026-02-23 13:20:40 +02:00
1c8493b33f 1.0.6-1.5.0
All checks were successful
SP Builds / build (push) Successful in 3m43s
Reviewed-on: #11
2026-02-21 20:27:51 +00:00
RoyceDa
a9ce892ea2 Поднятие версии 2026-02-21 22:26:40 +02:00
RoyceDa
fe418dabc9 При клике на текст сообщения теперь он выделяется, а не уходит в ответ к сообщению 2026-02-21 22:25:06 +02:00
RoyceDa
1572f06ef4 Исправлена отчистка сообщений после нажатия ESC 2026-02-21 20:49:55 +02:00
6054be7f5c Merge pull request 'main' (#10) from main into dev
Reviewed-on: #10
2026-02-21 18:20:27 +00:00
RoyceDa
a19803fc63 /test 2026-02-21 19:52:10 +02:00
RoyceDa
0349f2815e /test 2026-02-21 19:34:15 +02:00
RoyceDa
c720ed631c /test 2026-02-21 19:19:57 +02:00
RoyceDa
443433e61f Test 2026-02-21 19:08:57 +02:00
d324ad5c57 Merge pull request 'dev' (#9) from dev into main
Reviewed-on: #9
2026-02-21 17:06:36 +00:00
RoyceDa
0189998d54 Улучшеный pipe для linux 2026-02-21 19:05:11 +02:00
b68cfe851f Merge pull request 'main' (#8) from main into dev
Reviewed-on: #8
2026-02-21 16:37:02 +00:00
a9ebc136ab Обновление сборки для Linux 2026-02-21 13:55:28 +00:00
e473524c4b Правильный ранер для Linux 2026-02-21 13:22:53 +00:00
rosetta
6d75225635 1.0.5-1.5.0 2026-02-20 19:07:13 +02:00
991310592f 1.0.5-1.5.0
Reviewed-on: #7
2026-02-20 16:44:12 +00:00
RoyceDa
d6e14e470a 1.0.5-1.5.0 2026-02-20 18:42:22 +02:00
8ffa66920e 1.0.5-1.5.0
Some checks failed
Windows Kernel Build / build (push) Failing after 4m26s
MacOS Kernel Build / build (push) Failing after 12m6s
Linux Kernel Build / build (push) Has been cancelled
SP Builds / build (push) Has been cancelled
Reviewed-on: #6
2026-02-20 16:26:06 +00:00
RoyceDa
48909a7eeb Поднятие версии 2026-02-20 18:23:48 +02:00
RoyceDa
9379bd656d Ускоренная загрузка диалогов 2026-02-20 18:17:47 +02:00
RoyceDa
142082ba83 Исправлен скролл вниз при подгрузке новых сообщений 2026-02-20 18:01:22 +02:00
RoyceDa
c64a7005d3 Оптимизирован код вложений, исправлена установка аватарки на диалог, а не у отправителя 2026-02-20 17:43:56 +02:00
RoyceDa
db72246e5a Flip-анимация перемещения диалогов 2026-02-20 17:21:09 +02:00
RoyceDa
d53c987e7e Фикс уведомлений при синхронизации 2026-02-20 16:38:22 +02:00
RoyceDa
2e18d489be Оптимизация ядра, исправление гонки потоков при получении версии 2026-02-20 16:34:54 +02:00
62f3bd5e35 1.0.4-1.4.9
Reviewed-on: #5
2026-02-19 21:13:20 +00:00
RoyceDa
f01ed34285 1.0.4-1.4.9 2026-02-19 23:11:41 +02:00
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
RoyceDa
f8ca15422f Автоматическая сборка сервисных пакетов обновлений 2026-02-18 20:39:58 +02:00
eee419c0d4 1.0.3-1.4.9
Reviewed-on: #3
2026-02-18 18:37:40 +00:00
RoyceDa
2692c941e1 Убрана темная подолжка у фотографий 2026-02-18 20:30:07 +02:00
RoyceDa
4248abc629 Фикс плавающей иконки 2026-02-18 20:23:49 +02:00
RoyceDa
782f156070 Фикс плавающей иконки 2026-02-18 20:23:17 +02:00
RoyceDa
6670956a51 Улучшение Workflow 2026-02-18 19:20:55 +02:00
RoyceDa
67ef2b7ea7 Исправление некликабельной кнопки kernel update 2026-02-18 19:20:27 +02:00
53a9c0037f Merge pull request 'Актуализация ветки Dev' (#2) from main into dev
Reviewed-on: #2
2026-02-18 17:08:01 +00:00
RoyceDa
c50f3f0097 Linux Runner 2026-02-18 18:50:33 +02:00
RoyceDa
2a5e297478 1.0.2-1.4.9 2026-02-18 18:21:14 +02:00
RoyceDa
d55073b7bc Workflow 2026-02-18 18:04:25 +02:00
RoyceDa
3f239ed82b Workflow 2026-02-18 17:53:37 +02:00
RoyceDa
9410b0f1e6 Workflow 2026-02-18 17:47:26 +02:00
RoyceDa
3735f680d2 darwin workflow 2026-02-18 16:55:21 +02:00
RoyceDa
7472747a9e / 2026-02-18 16:21:26 +02:00
RoyceDa
0ac5ecee8a / 2026-02-18 15:38:44 +02:00
RoyceDa
597e2e0532 / 2026-02-18 15:26:33 +02:00
RoyceDa
6a69c3b195 / 2026-02-18 15:05:26 +02:00
RoyceDa
c24c1c7fd9 / 2026-02-18 14:58:43 +02:00
RoyceDa
f6a57762c0 / 2026-02-18 14:47:07 +02:00
RoyceDa
3a854c8d27 / 2026-02-18 14:41:58 +02:00
RoyceDa
65089fd71b / 2026-02-18 14:29:09 +02:00
RoyceDa
5470df481d test 2026-02-18 14:12:49 +02:00
RoyceDa
05429a0726 test 2026-02-18 14:10:40 +02:00
RoyceDa
fc73cf2acf cache 2026-02-18 13:58:06 +02:00
RoyceDa
779fa202b8 npm cache 2026-02-18 13:54:01 +02:00
RoyceDa
6a8269afa1 github actions 2026-02-18 13:45:15 +02:00
RoyceDa
d6ce12a007 npm cache 2026-02-18 13:43:41 +02:00
RoyceDa
eb1cf90d03 workspace correct 2026-02-18 13:32:44 +02:00
RoyceDa
319086218d ampersand 2026-02-18 12:20:46 +02:00
RoyceDa
3eb2b7f493 workflow 2026-02-18 10:30:46 +02:00
RoyceDa
6bcdaaf067 ssh 2026-02-17 21:51:17 +02:00
RoyceDa
95b0f1e056 act runner test 2026-02-17 21:34:44 +02:00
RoyceDa
d928b48dbd build 2026-02-17 21:10:45 +02:00
RoyceDa
171ed60194 ci/cd 2026-02-17 21:08:07 +02:00
RoyceDa
8f12be2c5d npm cache 2026-02-17 21:02:39 +02:00
RoyceDa
bbab09a936 cache: 'npm' 2026-02-17 20:57:20 +02:00
RoyceDa
1c873bb118 -Scope CurrentUser 2026-02-17 20:56:21 +02:00
RoyceDa
8cabb212c1 pwsh fix 2026-02-17 20:48:54 +02:00
RoyceDa
e39d084dad Workflow 2026-02-17 20:37:45 +02:00
RoyceDa
b0998990fd Npm cache 2026-02-17 20:29:03 +02:00
RoyceDa
73f32cf429 Merge branch 'main' of https://git.rosetta.im/Rosetta/desktop into main 2026-02-17 20:23:33 +02:00
RoyceDa
9a15383d65 CI/CD 2026-02-17 20:21:14 +02:00
110 changed files with 5856 additions and 1280 deletions

View File

@@ -1,30 +0,0 @@
name: Build Windows
#Запускаем только кнопкой "Run workflow" в Actions -> Build Windows
on:
workflow_dispatch:
jobs:
build:
runs-on: Windows
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install npm dependencies
run: npm install
- name: Build the application
run: npm run kernel:win
- name: Upload build on SSH
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
port: ${{ secrets.SSH_PORT }}
password: ${{ secrets.SSH_PASSWORD }}
source: "/dist/builds/win/x64/Rosetta-*.exe"
target: ${{ secrets.SSH_TARGET_DIR }}

View File

@@ -0,0 +1,82 @@
name: MacOS Kernel Build
run-name: Build and Upload MacOS Kernel
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'lib/**'
jobs:
build:
runs-on: macos
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
# Кэш npm (тарифы грузятся из ~/.npm-cache на macOS)
- name: Cache npm cache
uses: actions/cache@v5
with:
path: ${{ env.HOME }}/.npm-cache
key: ${{ runner.os }}-npm-${{ hashFiles('**/package.json') }}
restore-keys: |
${{ runner.os }}-npm-
if-no-files-found: ignore
# Кэш для electron-builder
- name: Cache electron-builder
uses: actions/cache@v5
with:
path: |
${{ env.HOME }}/Library/Caches/electron-builder
${{ env.HOME }}/Library/Caches/electron
key: ${{ runner.os }}-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
restore-keys: |
${{ runner.os }}-electron-builder-
if-no-files-found: ignore
- name: NPM offline setup
shell: bash
run: |
mkdir -p dist/builds/darwin/arm64 dist/builds/darwin/x64
npm config set cache "$HOME/.npm-cache" --global
npm config set prefer-offline true --global
- name: Install npm dependencies
run: npm install --prefer-offline --no-audit --no-fund
- name: Build the application
run: |
npx electron-vite build
npx electron-builder --mac --${{ matrix.arch }}
- name: Check if files exist
run: |
echo "=== Checking dist structure ==="
find dist/builds/darwin/${{ matrix.arch }} -type f -name "*.pkg" 2>/dev/null || echo "No PKG files found"
ls -la dist/builds/darwin/${{ matrix.arch }}/ 2>/dev/null || echo "arch folder not found"
- name: Upload to SSH using SCP
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SDU_SSH_HOST }}
username: ${{ secrets.SDU_SSH_USERNAME }}
password: ${{ secrets.SDU_SSH_PASSWORD }}
port: 22
source: "dist/builds/darwin/${{ matrix.arch }}/Rosetta-*.pkg"
target: "${{ secrets.SDU_SSH_KERNEL }}/darwin/${{ matrix.arch }}"
strip_components: 4
rm: true

View File

@@ -0,0 +1,64 @@
name: Linux Kernel Build
run-name: Build and Upload Linux Kernel
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'lib/**'
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- arch: x64
out_dir: x86_64
- arch: arm64
out_dir: arm64
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install npm dependencies
run: npm install --no-audit --no-fund
- name: Debug ARCH
run: |
echo "arch=${{ matrix.arch }}"
echo "out_dir=${{ matrix.out_dir }}"
- name: Build the application
run: |
mkdir -p dist/builds/linux/x64
mkdir -p dist/builds/linux/${{ matrix.out_dir }}
npx electron-vite build
npx electron-builder --linux --${{ matrix.arch }}
- name: Check if files exist
run: |
echo "=== Checking dist structure ==="
find dist/builds/linux/${{ matrix.out_dir }} -type f -name "*.AppImage" 2>/dev/null || echo "No AppImage files found"
ls -la dist/builds/linux/${{ matrix.out_dir }}/ 2>/dev/null || echo "arch folder not found"
- name: Upload to SSH using SCP
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SDU_SSH_HOST }}
username: ${{ secrets.SDU_SSH_USERNAME }}
password: ${{ secrets.SDU_SSH_PASSWORD }}
port: 22
source: dist/builds/linux/${{ matrix.out_dir }}/Rosetta-*.AppImage
target: ${{ secrets.SDU_SSH_KERNEL }}/linux/${{ matrix.arch }}
strip_components: 4
rm: true

View File

@@ -0,0 +1,91 @@
name: SP Builds
run-name: Build and Upload SP Packages
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'app/**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Checkout code
uses: actions/checkout@v6
- name: NPM offline setup
shell: bash
run: |
npm config set cache "$HOME/.npm-cache" --global
npm config set prefer-offline true --global
- name: Install npm dependencies
run: npm install --prefer-offline --no-audit --no-fund
#Собираем Kernel чтобы свежие файлы попали в папку out
- name: Build the application
run: npm run kernel:linux
- name: Install ZIP in Docker container
run: |
apt-get update
apt-get install -y zip
#Собираем сервисные пакеты для всех платформ
- name: Build SP
shell: bash
run: |
chmod +x "$GITHUB_WORKSPACE/build-packs.sh"
sh "$GITHUB_WORKSPACE/build-packs.sh"
#Загружаем на удаленный сервер по SSH используя scp и пароль из секретов
#Загружаем из двух папок dist/builds/darwin/x64 и dist/builds/darwin/arm64, так как electron-builder может создавать разные файлы для разных архитектур
# - name: Upload to SSH
# shell: bash
# run: |
# chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
# sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
# -l "$GITHUB_WORKSPACE/packs/*" \
# -r "${{ secrets.SDU_SSH_PACKS }}" \
# -s "${{ secrets.SDU_SSH_HOST }}" \
# -u "${{ secrets.SDU_SSH_USERNAME }}" \
# -p '${{ secrets.SDU_SSH_PASSWORD }}'
- name: Check SP
shell: bash
run: |
echo "=== Workspace ==="
pwd
ls -la
echo "=== Packs ==="
find packs -maxdepth 3 -type f 2>/dev/null || true
test -n "$(find packs -type f -print -quit 2>/dev/null)" || { echo "packs is empty"; exit 1; }
- name: Clean files before upload
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SDU_SSH_HOST }}
username: ${{ secrets.SDU_SSH_USERNAME }}
password: ${{ secrets.SDU_SSH_PASSWORD }}
port: 22
script: |
mkdir -p "${{ secrets.SDU_SSH_PACKS }}"
find "${{ secrets.SDU_SSH_PACKS }}" -mindepth 1 -type f -delete
- name: Upload to SSH using SCP
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SDU_SSH_HOST }}
username: ${{ secrets.SDU_SSH_USERNAME }}
password: ${{ secrets.SDU_SSH_PASSWORD }}
port: 22
source: "packs/*"
target: "${{ secrets.SDU_SSH_PACKS }}"
strip_components: 1
rm: false

View File

@@ -0,0 +1,355 @@
# PowerShell script to upload files to SFTP server with remote folder cleanup
# Works on clean Windows without additional utilities (uses WinSCP)
# ==========================================
# PARAMETERS (can override config values via command line)
# ==========================================
param(
[Parameter(Mandatory=$false, HelpMessage="SFTP server IP address or hostname")]
[string]$ServerAddress,
[Parameter(Mandatory=$false, HelpMessage="Username for connection")]
[string]$Username,
[Parameter(Mandatory=$false, HelpMessage="Password for connection")]
[string]$PasswordParam,
[Parameter(Mandatory=$false, HelpMessage="Local file path or pattern (e.g., C:\files\* or dist/builds/x64/Rosetta-*.exe)")]
[string]$LocalFilePath,
[Parameter(Mandatory=$false, HelpMessage="Remote folder on server")]
[string]$RemoteFolderPath,
[Parameter(Mandatory=$false, HelpMessage="SSH port")]
[int]$Port,
[Parameter(Mandatory=$false, HelpMessage="Path to WinSCP executable (auto-detect if not provided)")]
[string]$WinSCPPath
)
# ==========================================
# CONFIGURATION - Default fallback values
# ==========================================
# These values are used only if not provided via command-line parameters or environment variables
$CONFIG_ServerAddress = ""
$CONFIG_Username = ""
$CONFIG_Password = ""
$CONFIG_LocalFilePath = ""
$CONFIG_RemoteFolderPath = ""
$CONFIG_Port = 22
$CONFIG_WinSCPPath = ""
# Priority: Command-line Parameters (highest) > Environment Variables > Config Values (lowest)
# If parameter not provided via command line, check environment variable, then use config value
if (-not $ServerAddress) {
$ServerAddress = if ($env:SFTP_SERVER) { $env:SFTP_SERVER } else { $CONFIG_ServerAddress }
}
if (-not $Username) {
$Username = if ($env:SFTP_USERNAME) { $env:SFTP_USERNAME } else { $CONFIG_Username }
}
# Если пароль передан через CLI (-PasswordParam), используем его даже если пустая строка
if (-not $PSBoundParameters.ContainsKey('PasswordParam')) {
$PasswordParam = if ($env:SFTP_PASSWORD) { $env:SFTP_PASSWORD } else { $CONFIG_Password }
}
if (-not $LocalFilePath) {
$LocalFilePath = if ($env:SFTP_LOCAL_PATH) { $env:SFTP_LOCAL_PATH } else { $CONFIG_LocalFilePath }
}
if (-not $RemoteFolderPath) {
$RemoteFolderPath = if ($env:SFTP_REMOTE_PATH) { $env:SFTP_REMOTE_PATH } else { $CONFIG_RemoteFolderPath }
}
if (-not $Port -or $Port -eq 0) {
$Port = if ($env:SFTP_PORT) { [int]$env:SFTP_PORT } else { $CONFIG_Port }
}
if (-not $WinSCPPath) {
$WinSCPPath = if ($env:WINSCP_PATH) { $env:WINSCP_PATH } else { $CONFIG_WinSCPPath }
}
# Validate required parameters
$requiredParams = @(
@{Name = "ServerAddress"; Value = $ServerAddress},
@{Name = "Username"; Value = $Username},
@{Name = "PasswordParam"; Value = $PasswordParam},
@{Name = "LocalFilePath"; Value = $LocalFilePath},
@{Name = "RemoteFolderPath"; Value = $RemoteFolderPath}
)
$missingParams = @()
foreach ($param in $requiredParams) {
if ([string]::IsNullOrWhiteSpace($param.Value)) {
$missingParams += $param.Name
}
}
if ($missingParams.Count -gt 0) {
Write-Host "ERROR: Missing required parameters: $($missingParams -join ', ')" -ForegroundColor Red
Write-Host "Please configure values in the script CONFIG section or pass them as parameters." -ForegroundColor Red
exit 1
}
# Logging function
function Write-Log {
param(
[Parameter(Mandatory=$false)]
[string]$Message = "(empty message)",
[Parameter(Mandatory=$false)]
[ValidateSet("Info", "Warning", "Error", "Success")]
[string]$Level = "Info"
)
# Handle null or empty messages
if ([string]::IsNullOrWhiteSpace($Message)) {
$Message = "(empty message)"
}
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$color = switch ($Level) {
"Error" { "Red" }
"Warning" { "Yellow" }
"Success" { "Green" }
default { "White" }
}
Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color
}
# Function to find WinSCP installation
function Find-WinSCP {
$possiblePaths = @(
"C:\Program Files\WinSCP\WinSCP.com",
"C:\Program Files (x86)\WinSCP\WinSCP.com",
"C:\Program Files\WinSCP\WinSCP.exe",
"C:\Program Files (x86)\WinSCP\WinSCP.exe",
"C:\Program Files\WinSCP\WinSCPPortable.exe",
"C:\Program Files (x86)\WinSCP\WinSCPPortable.exe"
)
foreach ($path in $possiblePaths) {
if (Test-Path $path) {
Write-Log "Found WinSCP at: $path" "Info"
return $path
}
}
Write-Log "WinSCP not found. Please install it from https://winscp.net/" "Error"
return $null
}
# Main upload function using WinSCP
function Upload-ToSFTP {
param(
[Parameter(Mandatory=$true)]
[string]$Server,
[Parameter(Mandatory=$true)]
[string]$User,
[Parameter(Mandatory=$true)]
[string]$Pass,
[Parameter(Mandatory=$true)]
[string[]]$FileList,
[Parameter(Mandatory=$true)]
[string]$RemotePath,
[Parameter(Mandatory=$true)]
[int]$PortNum,
[Parameter(Mandatory=$true)]
[string]$WinSCPExe
)
# Decode once if URL-encoded; we will pass plain password via -password switch (no extra encoding needed)
$decodedPassword = if ($Pass -match '%[0-9A-Fa-f]{2}') { [System.Net.WebUtility]::UrlDecode($Pass) } else { $Pass }
# Create temporary file paths BEFORE script content (needed for variable expansion)
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss_fff'
$debugDir = Join-Path $env:TEMP "winscp_debug"
if (-not (Test-Path $debugDir)) {
New-Item -ItemType Directory -Path $debugDir -Force | Out-Null
}
$scriptPath = Join-Path $debugDir "script_$timestamp.txt"
$logFile = Join-Path $debugDir "log_$timestamp.txt"
$outputPath = Join-Path $debugDir "output_$timestamp.txt"
$errorPath = Join-Path $debugDir "error_$timestamp.txt"
# Create WinSCP script file WITH password (use @"..."@ to expand variables)
$scriptContent = @"
option batch abort
option confirm off
option echo off
option reconnecttime 3
"@
# Add connection string with auto-accept of host key; pass password via -password to avoid URL encoding issues
$scriptContent += "`r`nopen sftp://$User@$Server`:$PortNum/ -password=`"$decodedPassword`" -hostkey=`"*`"`r`n"
# Try to clear remote folder by removing all .exe files (ignore if none exist)
$scriptContent += "call rm -f $RemotePath/*.exe`r`n"
# Add files to WinSCP script
if ($FileList.Count -eq 0) {
Write-Log "No files found matching pattern" "Warning"
$scriptContent += "exit`r`n"
}
else {
foreach ($filePath in $FileList) {
# For local Windows paths, keep backslashes as-is (don't convert to forward slashes)
# WinSCP needs native Windows paths for local files
$remoteFilename = Split-Path $filePath -Leaf
$scriptContent += "put `"$filePath`" `"$RemotePath/$remoteFilename`"`r`n"
}
$scriptContent += "close`r`nexit`r`n"
}
# Save script to temporary file
try {
Set-Content -Path $scriptPath -Value $scriptContent -Encoding UTF8
Write-Log "Created WinSCP script at: $scriptPath" "Info"
Write-Log "Script content:" "Info"
Get-Content $scriptPath | ForEach-Object { Write-Log "$_" "Info" }
Write-Log "Executing WinSCP: $WinSCPExe" "Info"
try {
# Determine if this is .com (command-line) or .exe (GUI)
$isCom = $WinSCPExe -like "*.com"
if ($isCom) {
# WinSCP.com uses /log= for logging
$process = Start-Process -FilePath $WinSCPExe `
-ArgumentList "/log=$logFile /script=$scriptPath" `
-NoNewWindow `
-PassThru `
-Wait `
-RedirectStandardOutput $outputPath `
-RedirectStandardError $errorPath
}
else {
# WinSCP.exe (GUI) - needs option logfile in script
$scriptContent += "`r`noption logfile=$logFile"
Set-Content -Path $scriptPath -Value $scriptContent -Encoding UTF8
$process = Start-Process -FilePath $WinSCPExe `
-ArgumentList "/console /script=$scriptPath" `
-NoNewWindow `
-PassThru `
-Wait `
-RedirectStandardOutput $outputPath `
-RedirectStandardError $errorPath
}
}
catch {
Write-Log "Error starting process: $_" "Error"
throw
}
Write-Log "WinSCP process finished with exit code: $($process.ExitCode)" "Info"
# Read WinSCP logs
$winscp_log = Get-Content $logFile -ErrorAction SilentlyContinue -Raw
$output = Get-Content $outputPath -ErrorAction SilentlyContinue -Raw
$error_output = Get-Content $errorPath -ErrorAction SilentlyContinue -Raw
if ($winscp_log) {
Write-Log "WinSCP Log:`r`n$winscp_log" "Info"
}
if ($output) {
Write-Log "Output:`r`n$output" "Info"
}
else {
Write-Log "No standard output from WinSCP" "Info"
}
if ($error_output) {
Write-Log "Standard Error:`r`n$error_output" "Error"
}
if ($process.ExitCode -eq 0) {
Write-Log "Upload completed successfully" "Success"
return $true
}
else {
Write-Log "Upload failed with exit code: $($process.ExitCode)" "Error"
return $false
}
}
catch {
Write-Log "Error during upload: $_" "Error"
return $false
}
finally {
# Cleanup temporary files
Start-Sleep -Milliseconds 500
if (Test-Path $scriptPath) {
Remove-Item $scriptPath -Force -ErrorAction SilentlyContinue
}
if (Test-Path $logFile) {
Remove-Item $logFile -Force -ErrorAction SilentlyContinue
}
if (Test-Path $outputPath) {
Remove-Item $outputPath -Force -ErrorAction SilentlyContinue
}
if (Test-Path $errorPath) {
Remove-Item $errorPath -Force -ErrorAction SilentlyContinue
}
}
}
# =================
# MAIN LOGIC
# =================
Write-Log "========== STARTING FILE UPLOAD PROCESS ==========" "Info"
Write-Log "Server: $ServerAddress`:$Port" "Info"
Write-Log "Username: $Username" "Info"
Write-Log "File pattern: $LocalFilePath" "Info"
Write-Log "Remote folder: $RemoteFolderPath" "Info"
Write-Log "=============================================" "Info"
# Find WinSCP if path not provided
if (-not $WinSCPPath) {
$WinSCPPath = Find-WinSCP
if (-not $WinSCPPath) {
exit 1
}
}
# Verify WinSCP exists
if (-not (Test-Path $WinSCPPath)) {
Write-Log "Error: WinSCP not found at: $WinSCPPath" "Error"
exit 1
}
# Get files matching pattern
$files = @(Get-Item -Path $LocalFilePath -ErrorAction SilentlyContinue | Where-Object {-not $_.PSIsContainer})
if ($files.Count -eq 0) {
Write-Log "Error: No files found matching pattern: $LocalFilePath" "Error"
Write-Log "Current directory: $(Get-Location)" "Error"
Write-Log "Checking if path exists: $(Test-Path $LocalFilePath)" "Error"
exit 1
}
Write-Log "Found $($files.Count) file(s) to upload" "Info"
$filePathList = @($files | ForEach-Object {$_.FullName})
# Perform upload
$success = Upload-ToSFTP -Server $ServerAddress `
-User $Username `
-Pass $PasswordParam `
-FileList $filePathList `
-RemotePath $RemoteFolderPath `
-PortNum $Port `
-WinSCPExe $WinSCPPath
Write-Log "========== PROCESS COMPLETED ==========" "Info"
if ($success) {
exit 0
}
else {
exit 1
}

View File

@@ -0,0 +1,58 @@
name: Windows Kernel Build
run-name: Build and Upload Windows Kernel
#Запускаем только кнопкой "Run workflow" в Actions -> Build Windows
#Или если есть коммпит в папку lib в ветке main
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'lib/**'
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
# Кэш для electron-builder
- name: Cache electron-builder
uses: actions/cache@v5
with:
path: |
${{ env.LOCALAPPDATA }}\\electron-builder\\Cache
${{ env.LOCALAPPDATA }}\\electron\\Cache
key: ${{ runner.os }}-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
restore-keys: |
${{ runner.os }}-electron-builder-
if-no-files-found: ignore
- name: NPM offline setup
shell: powershell
run: |
npm config set cache "$env:LOCALAPPDATA\npm-cache" --global
npm config set prefer-offline true --global
- name: Install npm dependencies
run: npm install --prefer-offline --no-audit --no-fund
- name: Build the application
run: npm run kernel:win
- name: Upload to SSH using WinSCP Powershell
shell: powershell
run: |
& "$env:GITHUB_WORKSPACE\.gitea\workflows\sshupload.ps1" `
-LocalFilePath "dist/builds/win/x64/Rosetta-*.exe" `
-RemoteFolderPath "${{ secrets.SDU_SSH_KERNEL }}/win32/x64" `
-ServerAddress "${{ secrets.SDU_SSH_HOST }}" `
-Username "${{ secrets.SDU_SSH_USERNAME }}" `
-PasswordParam '${{ secrets.SDU_SSH_PASSWORD }}'

View File

@@ -6,9 +6,8 @@ import { ConfirmSeed } from './views/ConfirmSeed/ConfirmSeed';
import { SetPassword } from './views/SetPassword/SetPassword'; import { SetPassword } from './views/SetPassword/SetPassword';
import { Main } from './views/Main/Main'; import { Main } from './views/Main/Main';
import { ExistsSeed } from './views/ExistsSeed/ExistsSeed'; import { ExistsSeed } from './views/ExistsSeed/ExistsSeed';
import { Box, Divider } from '@mantine/core'; import { Box } from '@mantine/core';
import './style.css' import './style.css'
import { useRosettaColors } from './hooks/useRosettaColors';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import { InformationProvider } from './providers/InformationProvider/InformationProvider'; import { InformationProvider } from './providers/InformationProvider/InformationProvider';
import { BlacklistProvider } from './providers/BlacklistProvider/BlacklistProvider'; import { BlacklistProvider } from './providers/BlacklistProvider/BlacklistProvider';
@@ -23,12 +22,11 @@ import { DialogStateProvider } from './providers/DialogStateProvider.tsx/DialogS
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm'; import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider'; import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider'; import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
import { PlayerProvider } from './providers/PlayerProvider/PlayerProvider';
window.Buffer = Buffer; window.Buffer = Buffer;
export default function App() { export default function App() {
const { allAccounts, accountProviderLoaded } = useAccountProvider(); const { allAccounts, accountProviderLoaded } = useAccountProvider();
const colors = useRosettaColors();
const getViewByLoginState = () => { const getViewByLoginState = () => {
if (!accountProviderLoaded) { if (!accountProviderLoaded) {
@@ -59,22 +57,23 @@ export default function App() {
<SystemAccountProvider> <SystemAccountProvider>
<Box h={'100%'}> <Box h={'100%'}>
<Topbar></Topbar> <Topbar></Topbar>
<Divider color={colors.borderColor}></Divider>
<ContextMenuProvider> <ContextMenuProvider>
<ImageViwerProvider> <ImageViwerProvider>
<AvatarProvider> <PlayerProvider>
<Routes> <AvatarProvider>
<Route path="/" element={ <Routes>
getViewByLoginState() <Route path="/" element={
} /> getViewByLoginState()
<Route path="/create-seed" element={<CreateSeed />} /> } />
<Route path="/confirm-seed" element={<ConfirmSeed />} /> <Route path="/create-seed" element={<CreateSeed />} />
<Route path="/set-password" element={<SetPassword />} /> <Route path="/confirm-seed" element={<ConfirmSeed />} />
<Route path="/main/*" element={<Main />} /> <Route path="/set-password" element={<SetPassword />} />
<Route path="/exists-seed" element={<ExistsSeed />} /> <Route path="/main/*" element={<Main />} />
<Route path="/deviceconfirm" element={<DeviceConfirm />} /> <Route path="/exists-seed" element={<ExistsSeed />} />
</Routes> <Route path="/deviceconfirm" element={<DeviceConfirm />} />
</AvatarProvider> </Routes>
</AvatarProvider>
</PlayerProvider>
</ImageViwerProvider> </ImageViwerProvider>
</ContextMenuProvider> </ContextMenuProvider>
</Box> </Box>

View File

@@ -60,6 +60,7 @@ export function ActionAvatar(props : ActionAvatarProps) {
size={120} size={120}
radius={120} radius={120}
mx="auto" mx="auto"
bg={avatars.length > 0 ? '#fff' : undefined}
name={props.title.trim() || props.publicKey} name={props.title.trim() || props.publicKey}
color={'initials'} color={'initials'}
src={avatars.length > 0 ? src={avatars.length > 0 ?

View File

@@ -0,0 +1,42 @@
.active {
background: linear-gradient(90deg,rgba(0, 186, 59, 1) 0%, rgba(0, 194, 81, 1) 50%);
background-size: 200% 200%;
animation: activeFlow 5s ease-in-out infinite;
}
@keyframes activeFlow {
0% {
background-position: 0% 50%;
filter: saturate(1);
}
50% {
background-position: 100% 50%;
filter: saturate(1.15);
}
100% {
background-position: 0% 50%;
filter: saturate(1);
}
}
.connecting {
background: linear-gradient(120deg, #ff2d2d, #ff7a00, #ff2d2d);
background-size: 220% 220%;
animation: connectingFlow 5s ease-in-out infinite;
}
@keyframes connectingFlow {
0% {
background-position: 0% 50%;
filter: saturate(1);
}
50% {
background-position: 100% 50%;
filter: saturate(1.15);
}
100% {
background-position: 0% 50%;
filter: saturate(1);
}
}
/* ...existing code... */

View File

@@ -0,0 +1,98 @@
import { useCalls } from "@/app/providers/CallProvider/useCalls";
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
import { Box, Flex, Loader, Text } from "@mantine/core";
import classes from "./ActiveCall.module.css";
import { CallState } from "@/app/providers/CallProvider/CallProvider";
import { IconMicrophone, IconMicrophoneOff, IconPhoneX, IconVolume, IconVolumeOff } from "@tabler/icons-react";
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
export function ActiveCall() {
const {activeCall, callState, duration, muted, sound, close, setMuted, setSound, setShowCallView} = useCalls();
const [userInfo] = useUserInformation(activeCall);
//const colors = useRosettaColors();
if(activeCall == ""){
return <></>
}
const getConnectingClass = () => {
if(callState === CallState.CONNECTING
|| callState === CallState.INCOMING
|| callState === CallState.KEY_EXCHANGE
|| callState === CallState.WEB_RTC_EXCHANGE){
return classes.connecting;
}
if(callState === CallState.ACTIVE){
return classes.active;
}
return "";
}
return (
<>
<Box py={4} style={{
cursor: 'pointer'
}} px={10} className={getConnectingClass()} onClick={() => setShowCallView(true)}>
<Flex align={'center'} justify={'row'} gap={10}>
<Flex w={'100%'} justify={'space-between'} align={'center'}>
<Flex>
{!muted && (
<IconMicrophoneOff style={{
cursor: 'pointer'
}} onClick={(e) => {
e.stopPropagation();
setMuted(true);
}} size={16} color={'#fff'}></IconMicrophoneOff>
)}
{muted && (
<IconMicrophone style={{
cursor: 'pointer'
}} onClick={(e) => {
e.stopPropagation();
setMuted(false);
}} size={16} color={'#fff'}></IconMicrophone>
)}
</Flex>
<Flex justify={'center'} align={'center'} gap={'xs'}>
<Text fw={500} c={'#fff'} style={{
userSelect: 'none'
}} fz={13}>{userInfo?.title || activeCall}</Text>
{callState === CallState.CONNECTING && (
<Loader type={'dots'} size={12} color="white"></Loader>
)}
{callState == CallState.ACTIVE && (
<Text fw={500} c={'#ffffff'} style={{
userSelect: 'none'
}} fz={12}>{translateDurationToTime(duration)}</Text>
)}
</Flex>
<Flex gap={'xs'} align={'center'} justify={'center'}>
{sound && (
<IconVolumeOff style={{
cursor: 'pointer'
}} size={16} onClick={(e) => {
e.stopPropagation();
setSound(false)
}} color={'#fff'}></IconVolumeOff>
)}
{!sound && (
<IconVolume style={{
cursor: 'pointer'
}} size={16} onClick={(e) => {
e.stopPropagation();
setSound(true)
}} color={'#fff'}></IconVolume>
)}
<IconPhoneX style={{
cursor: 'pointer'
}} onClick={(e) => {
e.stopPropagation();
close();
}} size={16} color={'#fff'}></IconPhoneX>
</Flex>
</Flex>
</Flex>
</Box>
</>
);
}

View File

@@ -0,0 +1,139 @@
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
import { CallContextValue, CallState } from "@/app/providers/CallProvider/CallProvider";
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
import { Avatar, Box, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { IconChevronLeft, IconMicrophone, IconMicrophoneOff, IconPhone, IconPhoneX, IconQrcode, IconVolume, IconVolumeOff, IconX } from "@tabler/icons-react";
import { KeyImage } from "../KeyImage/KeyImage";
export interface CallProps {
context: CallContextValue;
}
export function Call(props: CallProps) {
const {
activeCall,
duration,
callState,
close,
sound,
setSound,
setMuted,
setShowCallView,
muted,
getKeyCast,
accept
} = props.context;
const [userInfo] = useUserInformation(activeCall);
const avatars = useAvatars(activeCall);
const colors = useRosettaColors();
const theme = useMantineTheme();
return (
<Box pos={'absolute'} top={0} left={0} w={'100%'} h={'100vh'} style={{
zIndex: 11,
background: 'linear-gradient(120deg,#141414 0%, #000000 100%)',
}}>
<Flex h={'100%'} w={'100vw'} direction={'column'} gap={'lg'} pt={'xl'}>
<Flex direction={'row'} w={'100%'} gap={'sm'} align={'center'} justify={'space-between'} p={'sm'}>
<Flex style={{
cursor: 'pointer'
}} onClick={() => setShowCallView(false)} justify={'center'} align={'center'}>
<IconChevronLeft color="white" size={20}></IconChevronLeft>
<Text fw={500} c={'white'}>Back</Text>
</Flex>
<Flex>
<Popover width={300} disabled={getKeyCast() == ''} withArrow>
<Popover.Target>
<IconQrcode color={getKeyCast() == '' ? 'gray' : 'white'} size={24}></IconQrcode>
</Popover.Target>
<Popover.Dropdown p={'xs'}>
<Flex direction={'row'} align={'center'} gap={'xs'}>
<Text maw={300} c={'dimmed'} fz={'xs'}>
This call is secured by 256 bit end-to-end encryption. Only you and the recipient can read or listen to the content of this call.
</Text>
<KeyImage radius={0} colors={[
theme.colors.blue[1],
theme.colors.blue[2],
theme.colors.blue[3],
theme.colors.blue[4],
theme.colors.blue[5]
]} size={80} keyRender={getKeyCast()}></KeyImage>
</Flex>
</Popover.Dropdown>
</Popover>
</Flex>
</Flex>
<Flex direction={'column'} mt={'xl'} style={{
userSelect: 'none'
}} w={'100vw'} gap={'sm'} align={'center'} justify={'center'}>
<Avatar size={128} bg={avatars.length > 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} color={'initials'} name={userInfo.title}></Avatar>
<Text fz={20} fw={'bold'} c={'#FFF'}>{userInfo.title}</Text>
{callState == CallState.ACTIVE && (<Text fz={14} c={'#FFF'}>{translateDurationToTime(duration)}</Text>)}
{callState == CallState.CONNECTING && (<Text fz={14} c={'#FFF'}>Connecting...</Text>)}
{callState == CallState.INCOMING && (<Text fz={14} c={'#FFF'}>Incoming call...</Text>)}
{callState == CallState.KEY_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
{callState == CallState.WEB_RTC_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
<Flex gap={'xl'} align={'center'} justify={'center'} mt={'xl'}>
{(callState == CallState.ACTIVE
|| callState == CallState.WEB_RTC_EXCHANGE
|| callState == CallState.CONNECTING
|| callState == CallState.KEY_EXCHANGE) && (
<>
<Box w={50} onClick={() => setSound(!sound)} style={{
borderRadius: 25,
cursor: 'pointer'
}} h={50} bg={sound ? colors.chevrons.active : colors.chevrons.disabled}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
{!sound && <IconVolume size={24} color={'#fff'}></IconVolume>}
{sound && <IconVolumeOff size={24} color={'#fff'}></IconVolumeOff>}
</Flex>
</Box>
<Box w={50} onClick={() => setMuted(!muted)} style={{
borderRadius: 25,
cursor: 'pointer'
}} h={50} bg={!muted ? colors.chevrons.active : colors.chevrons.disabled}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
{muted && <IconMicrophone size={24} color={'#fff'}></IconMicrophone>}
{!muted && <IconMicrophoneOff size={24} color={'#fff'}></IconMicrophoneOff>}
</Flex>
</Box>
<Box w={50} onClick={close} style={{
borderRadius: 25,
cursor: 'pointer'
}} h={50} bg={colors.error}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
<IconPhoneX size={24} color={'#fff'}></IconPhoneX>
</Flex>
</Box>
</>
)}
{callState == CallState.INCOMING && (
<>
{userInfo.title != "Rosetta" && (
<Box w={50} onClick={close} style={{
borderRadius: 25,
cursor: 'pointer'
}} h={50} bg={colors.error}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
<IconX size={24} color={'#fff'}></IconX>
</Flex>
</Box>
)}
<Box w={userInfo.title != "Rosetta" ? 50 : 100} onClick={accept} style={{
borderRadius: '50%',
cursor: 'pointer'
}} h={userInfo.title != "Rosetta" ? 50 : 100} bg={colors.success}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
<IconPhone size={24} color={'#fff'}></IconPhone>
</Flex>
</Box>
</>
)}
</Flex>
</Flex>
</Flex>
</Box>
)
}

View File

@@ -1,14 +1,13 @@
import { useRosettaColors } from "@/app/hooks/useRosettaColors"; import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate"; import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey"; import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
import { useDialog } from "@/app/providers/DialogProvider/useDialog"; import { useDialog } from "@/app/providers/DialogProvider/useDialog";
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation"; import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider"; import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState"; import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
import { Avatar, Box, Divider, Flex, Loader, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core"; import { Avatar, Box, Divider, Flex, Loader, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { IconBookmark, IconLockAccess, IconLockCancel, IconTrashX } from "@tabler/icons-react"; import { IconBookmark, IconPhone, IconTrashX } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge"; import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
@@ -20,6 +19,7 @@ import { ReplyHeader } from "../ReplyHeader/ReplyHeader";
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints"; import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
import { BackToDialogs } from "../BackToDialogs/BackToDialogs"; import { BackToDialogs } from "../BackToDialogs/BackToDialogs";
import { useSystemAccounts } from "@/app/providers/SystemAccountsProvider/useSystemAccounts"; import { useSystemAccounts } from "@/app/providers/SystemAccountsProvider/useSystemAccounts";
import { useCalls } from "@/app/providers/CallProvider/useCalls";
export function ChatHeader() { export function ChatHeader() {
@@ -29,7 +29,6 @@ export function ChatHeader() {
const publicKey = usePublicKey(); const publicKey = usePublicKey();
const {deleteMessages, dialog} = useDialog(); const {deleteMessages, dialog} = useDialog();
const theme = useMantineTheme(); const theme = useMantineTheme();
const [blocked, blockUser, unblockUser] = useBlacklist(dialog);
const [opponent, ___, forceUpdateUserInformation] = useUserInformation(dialog); const [opponent, ___, forceUpdateUserInformation] = useUserInformation(dialog);
const [protocolState] = useProtocolState(); const [protocolState] = useProtocolState();
const [userTypeing, setUserTypeing] = useState(false); const [userTypeing, setUserTypeing] = useState(false);
@@ -39,6 +38,7 @@ export function ChatHeader() {
const {lg} = useRosettaBreakpoints(); const {lg} = useRosettaBreakpoints();
const systemAccounts = useSystemAccounts(); const systemAccounts = useSystemAccounts();
const isSystemAccount = systemAccounts.find((acc) => acc.publicKey == dialog) != undefined; const isSystemAccount = systemAccounts.find((acc) => acc.publicKey == dialog) != undefined;
const {call} = useCalls();
useEffect(() => { useEffect(() => {
@@ -78,20 +78,6 @@ export function ChatHeader() {
}); });
} }
const onClickBlockUser = () => {
if(opponent.publicKey != "DELETED"
&& opponent.publicKey != publicKey){
blockUser();
}
}
const onClickUnblockUser = () => {
if(opponent.publicKey != "DELETED"
&& opponent.publicKey != publicKey){
unblockUser();
}
}
const onClickProfile = () => { const onClickProfile = () => {
if(opponent.publicKey != "DELETED" && opponent.publicKey != publicKey){ if(opponent.publicKey != "DELETED" && opponent.publicKey != publicKey){
navigate("/main/profile/" + opponent.publicKey); navigate("/main/profile/" + opponent.publicKey);
@@ -116,7 +102,7 @@ export function ChatHeader() {
onClick={onClickProfile} onClick={onClickProfile}
> >
<IconBookmark stroke={2} size={20}></IconBookmark> <IconBookmark stroke={2} size={20}></IconBookmark>
</Avatar> : <Avatar onClick={onClickProfile} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar> </Avatar> : <Avatar onClick={onClickProfile} bg={avatars.length > 0 ? '#fff' : undefined} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
} }
<Flex direction={'column'} onClick={onClickProfile}> <Flex direction={'column'} onClick={onClickProfile}>
<Flex align={'center'} gap={3}> <Flex align={'center'} gap={3}>
@@ -149,32 +135,18 @@ export function ChatHeader() {
</Flex> </Flex>
</Flex> </Flex>
<Flex h={'100%'} align={'center'} gap={'sm'}> <Flex h={'100%'} align={'center'} gap={'sm'}>
<Tooltip onClick={onClickClearMessages} withArrow position={'bottom'} label={"Clear all messages"}> {publicKey != opponent.publicKey && !isSystemAccount && (
<IconTrashX <IconPhone
style={{ onClick={() => call(dialog)}
cursor: 'pointer' style={{
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconTrashX> cursor: 'pointer'
</Tooltip> }} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconPhone>
{publicKey != opponent.publicKey && !blocked && !isSystemAccount && (
<Tooltip onClick={onClickBlockUser} withArrow position={'bottom'} label={"Block user"}>
<IconLockCancel
style={{
cursor: 'pointer'
}} stroke={1.5} color={theme.colors.red[7]} size={24}
>
</IconLockCancel>
</Tooltip>
)}
{blocked && !isSystemAccount && (
<Tooltip onClick={onClickUnblockUser} withArrow position={'bottom'} label={"Unblock user"}>
<IconLockAccess
style={{
cursor: 'pointer'
}} stroke={1.5} color={theme.colors.green[7]} size={24}
>
</IconLockAccess>
</Tooltip>
)} )}
<IconTrashX
onClick={onClickClearMessages}
style={{
cursor: 'pointer'
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconTrashX>
</Flex> </Flex>
</Flex>} </Flex>}
{replyMessages.messages.length > 0 && !replyMessages.inDialogInput && <ReplyHeader></ReplyHeader>} {replyMessages.messages.length > 0 && !replyMessages.inDialogInput && <ReplyHeader></ReplyHeader>}

View File

@@ -18,6 +18,8 @@ import { useDialogInfo } from "@/app/providers/DialogListProvider/useDialogInfo"
import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu"; import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu";
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin"; import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute"; import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
export interface DialogProps extends DialogRow { export interface DialogProps extends DialogRow {
onClickDialog: (dialog: string) => void; onClickDialog: (dialog: string) => void;
@@ -51,6 +53,7 @@ export function Dialog(props : DialogProps) {
const isInCurrentDialog = props.dialog_id == сurrentDialogPublicKeyView; const isInCurrentDialog = props.dialog_id == сurrentDialogPublicKeyView;
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1'; const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
const [protocolState] = useProtocolState();
usePacket(0x0B, (packet : PacketTyping) => { usePacket(0x0B, (packet : PacketTyping) => {
if(packet.getFromPublicKey() == opponent && packet.getToPublicKey() == publicKey && !fromMe){ if(packet.getFromPublicKey() == opponent && packet.getToPublicKey() == publicKey && !fromMe){
@@ -85,7 +88,7 @@ export function Dialog(props : DialogProps) {
<IconBookmark stroke={2} size={20}></IconBookmark> <IconBookmark stroke={2} size={20}></IconBookmark>
</Avatar> : </Avatar> :
<Box style={{ position: 'relative', display: 'inline-block' }}> <Box style={{ position: 'relative', display: 'inline-block' }}>
<Avatar src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} /> <Avatar bg={avatars.length > 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} />
{userInfo.online == OnlineState.ONLINE && ( {userInfo.online == OnlineState.ONLINE && (
<Box <Box
style={{ style={{
@@ -153,7 +156,7 @@ export function Dialog(props : DialogProps) {
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && ( {!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle> <IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
)} )}
{unreaded > 0 && !lastMessageFromMe && <Badge {unreaded > 0 && !lastMessageFromMe && protocolState != ProtocolState.SYNCHRONIZATION && <Badge
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)} color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
c={isInCurrentDialog ? colors.brandColor : 'white'} c={isInCurrentDialog ? colors.brandColor : 'white'}
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>} size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}

View File

@@ -1,10 +1,10 @@
import { useDialog } from "@/app/providers/DialogProvider/useDialog"; import { useDialog } from "@/app/providers/DialogProvider/useDialog";
import { useRosettaColors } from "@/app/hooks/useRosettaColors"; import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core"; import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react"; import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMicrophone, IconMoodSmile, IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist"; 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 { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
import { DialogAttachment } from "../DialogAttachment/DialogAttachment"; import { DialogAttachment } from "../DialogAttachment/DialogAttachment";
import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing"; import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing";
@@ -25,7 +25,8 @@ import { AnimatedButton } from "../AnimatedButton/AnimatedButton";
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc"; import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
import { MentionList, Mention } from "../MentionList/MentionList"; import { MentionList, Mention } from "../MentionList/MentionList";
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts"; import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
import { useVoiceMessage } from "./useVoiceMessage";
import { VoiceRecorder } from "../VoiceRecorder/VoiceRecorder";
export function DialogInput() { export function DialogInput() {
const colors = useRosettaColors(); const colors = useRosettaColors();
@@ -47,6 +48,7 @@ export function DialogInput() {
const [mentionList, setMentionList] = useState<Mention[]>([]); const [mentionList, setMentionList] = useState<Mention[]>([]);
const mentionHandling = useRef<string>(""); const mentionHandling = useRef<string>("");
const {getDraft, saveDraft} = useDrafts(dialog); const {getDraft, saveDraft} = useDrafts(dialog);
const {start, stop, isRecording, duration, waves, getAudioBlob, interpolateCompressWaves} = useVoiceMessage();
const avatars = useAvatars( const avatars = useAvatars(
@@ -60,9 +62,19 @@ export function DialogInput() {
useHotkeys([ useHotkeys([
['Esc', () => { ['Esc', () => {
setAttachments([]); setAttachments([]);
deselectAllMessages();
}] }]
], [], true); ], [], true);
const hasText = message.trim().length > 0;
const showSendIcon = hasText || attachments.length > 0 || isRecording;
const onMicroClick = () => {
if(!isRecording) {
start();
}
};
const fileDialog = useFileDialog({ const fileDialog = useFileDialog({
multiple: false, multiple: false,
//naccept: '*', //naccept: '*',
@@ -88,7 +100,11 @@ export function DialogInput() {
blob: fileContent, blob: fileContent,
id: generateRandomKey(8), id: generateRandomKey(8),
type: AttachmentType.FILE, type: AttachmentType.FILE,
preview: files[0].size + "::" + files[0].name preview: files[0].size + "::" + files[0].name,
transport: {
transport_server: "",
transport_tag: ""
}
}]); }]);
} }
}); });
@@ -104,14 +120,26 @@ export function DialogInput() {
}, [dialog, editableDivRef]); }, [dialog, editableDivRef]);
useEffect(() => { useEffect(() => {
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
/**
* У системных аккаунтов нельзя отвечать на сообщения
*/
return;
}
if(replyMessages.inDialogInput && replyMessages.inDialogInput == dialog){ if(replyMessages.inDialogInput && replyMessages.inDialogInput == dialog){
setAttachments([{ setAttachments([{
type: AttachmentType.MESSAGES, type: AttachmentType.MESSAGES,
id: generateRandomKey(8), id: generateRandomKey(8),
blob: JSON.stringify([...replyMessages.messages]), blob: JSON.stringify([...replyMessages.messages]),
preview: "" preview: "",
transport: {
transport_server: "",
transport_tag: ""
}
}]); }]);
editableDivRef.current.focus(); if(editableDivRef.current){
editableDivRef.current.focus();
}
} }
}, [dialog, replyMessages]); }, [dialog, replyMessages]);
@@ -171,8 +199,28 @@ export function DialogInput() {
mentionHandling.current = username; mentionHandling.current = username;
} }
const send = () => { const send = async () => {
if(blocked || (message.trim() == "" && attachments.length <= 0)) { if(blocked || (message.trim() == "" && attachments.length <= 0 && !isRecording)){
return;
}
if(isRecording){
const audioBlob = getAudioBlob();
stop();
if(!audioBlob){
return;
}
sendMessage("", [
{
blob: Buffer.from(await audioBlob.arrayBuffer()).toString('hex'),
id: generateRandomKey(8),
type: AttachmentType.VOICE,
preview: duration + "::" + interpolateCompressWaves(35).join(","),
transport: {
transport_server: "",
transport_tag: ""
}
}
]);
return; return;
} }
sendMessage(message, attachments); sendMessage(message, attachments);
@@ -208,6 +256,12 @@ export function DialogInput() {
} }
const onClickCamera = async () => { const onClickCamera = async () => {
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
/**
* У системных аккаунтов нельзя вызывать вложения
*/
return;
}
if(avatars.length == 0){ if(avatars.length == 0){
return; return;
} }
@@ -215,9 +269,15 @@ export function DialogInput() {
blob: avatars[0].avatar, blob: avatars[0].avatar,
id: generateRandomKey(8), id: generateRandomKey(8),
type: AttachmentType.AVATAR, type: AttachmentType.AVATAR,
preview: await base64ImageToBlurhash(avatars[0].avatar) preview: "",
transport: {
transport_server: "",
transport_tag: ""
}
}]); }]);
editableDivRef.current.focus(); if(editableDivRef.current){
editableDivRef.current.focus();
}
} }
const sendTypeingPacket = () => { const sendTypeingPacket = () => {
@@ -229,6 +289,12 @@ export function DialogInput() {
} }
const onPaste = async (event: React.ClipboardEvent) => { const onPaste = async (event: React.ClipboardEvent) => {
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
/**
* У системных аккаунтов нельзя вызывать вложения
*/
return;
}
if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){ if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){
return; return;
} }
@@ -242,14 +308,21 @@ export function DialogInput() {
const file = item.getAsFile(); const file = item.getAsFile();
if (file) { if (file) {
const base64Image = await imagePrepareForNetworkTransfer(file); const base64Image = await imagePrepareForNetworkTransfer(file);
const attachmentId = generateRandomKey(8);
setAttachments([...attachments, { setAttachments([...attachments, {
blob: base64Image, blob: base64Image,
id: generateRandomKey(8), id: attachmentId,
type: AttachmentType.IMAGE, type: AttachmentType.IMAGE,
preview: await base64ImageToBlurhash(base64Image) preview: "",
transport: {
transport_server: "",
transport_tag: ""
}
}]); }]);
} }
editableDivRef.current.focus(); if(editableDivRef.current){
editableDivRef.current.focus();
}
break; break;
} }
} }
@@ -273,11 +346,16 @@ export function DialogInput() {
return; return;
} }
let fileContent = await filePrapareForNetworkTransfer(file); let fileContent = await filePrapareForNetworkTransfer(file);
const attachmentId = generateRandomKey(8);
setAttachments([...attachments, { setAttachments([...attachments, {
blob: fileContent, blob: fileContent,
id: generateRandomKey(8), id: attachmentId,
type: AttachmentType.FILE, type: AttachmentType.FILE,
preview: files[0].size + "::" + files[0].name preview: files[0].size + "::" + files[0].name,
transport: {
transport_server: "",
transport_tag: ""
}
}]); }]);
} }
@@ -318,79 +396,118 @@ export function DialogInput() {
{!blocked && {!blocked &&
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}> <Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
<Flex w={25} mt={10} justify={'center'}> <Flex w={25} mt={10} justify={'center'}>
<Menu width={150} withArrow> {isRecording && (
<Menu.Target> <IconTrash onClick={stop} style={{
<IconPaperclip stroke={1.5} style={{ cursor: 'pointer'
cursor: 'pointer' }} color={colors.error} stroke={1.5} size={25}></IconTrash>
}} size={25} color={colors.chevrons.active}></IconPaperclip> )}
</Menu.Target> {!isRecording && (
<Menu.Dropdown style={{ <Menu width={150} withArrow>
userSelect: 'none' <Menu.Target>
}}> <IconPaperclip stroke={1.5} style={{
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label> cursor: 'pointer'
<Menu.Item fz={'xs'} fw={500} leftSection={ }} size={25} color={colors.chevrons.active}></IconPaperclip>
<IconFile size={14}></IconFile> </Menu.Target>
} onClick={onClickPaperclip}>File</Menu.Item> <Menu.Dropdown style={{
{((avatars.length > 0 && !hasGroup(dialog)) userSelect: 'none'
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin)) }}>
&& <Menu.Item fz={'xs'} fw={500} leftSection={ <Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
<IconCamera size={14}></IconCamera> <Menu.Item fz={'xs'} fw={500} leftSection={
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>} <IconFile size={14}></IconFile>
</Menu.Dropdown> } onClick={onClickPaperclip}>File</Menu.Item>
</Menu> {((avatars.length > 0 && !hasGroup(dialog))
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
&& <Menu.Item fz={'xs'} fw={500} leftSection={
<IconCamera size={14}></IconCamera>
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
</Menu.Dropdown>
</Menu>
)}
</Flex> </Flex>
<Flex <Flex
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'} w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'} maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
align={'center'} align={'center'}
> >
<RichTextInput {!isRecording && <>
ref={editableDivRef} <RichTextInput
style={{ ref={editableDivRef}
border: 0, style={{
minHeight: 45, border: 0,
fontSize: 14, minHeight: 45,
background: 'transparent', fontSize: 14,
width: '100%', background: 'transparent',
paddingLeft: 10, width: '100%',
paddingRight: 10, paddingLeft: 10,
outline: 'none', paddingRight: 10,
paddingTop: 10, outline: 'none',
paddingBottom: 8 paddingTop: 10,
}} paddingBottom: 8
placeholder="Type message..." }}
autoFocus placeholder="Type message..."
//ref={textareaRef} autoFocus
//onPaste={onPaste} onKeyDown={handleKeyDown}
//maxLength={2500} onChange={setMessage}
//w={'100%'} onPaste={onPaste}
//h={'100%'} ></RichTextInput>
onKeyDown={handleKeyDown} </>}
onChange={setMessage} {isRecording && <>
onPaste={onPaste} <VoiceRecorder duration={duration} waves={waves}></VoiceRecorder>
</>}
//dangerouslySetInnerHTML={{__html: message}}
></RichTextInput>
</Flex> </Flex>
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}> <Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
<Popover withArrow> {!isRecording && <>
<Popover.Target> <Popover withArrow>
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{ <Popover.Target>
cursor: 'pointer' <IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
}}></IconMoodSmile> cursor: 'pointer'
</Popover.Target> }}></IconMoodSmile>
<Popover.Dropdown p={0}> </Popover.Target>
<EmojiPicker <Popover.Dropdown p={0}>
onEmojiClick={onEmojiClick} <EmojiPicker
searchDisabled onEmojiClick={onEmojiClick}
skinTonesDisabled searchDisabled
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT} skinTonesDisabled
/> theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
</Popover.Dropdown> />
</Popover> </Popover.Dropdown>
<IconSend stroke={1.5} color={message.trim() == "" && attachments.length <= 0 ? colors.chevrons.active : colors.brandColor} onClick={send} style={{ </Popover>
cursor: 'pointer' </>}
}} size={25}></IconSend> <Box pos="relative" ml={isRecording ? 35 : 0} w={25} h={25}>
<Transition mounted={showSendIcon} transition="pop" duration={180} timingFunction="ease">
{(styles) => (
<IconSend
stroke={1.5}
color={colors.brandColor}
onClick={send}
style={{
...styles,
position: 'absolute',
inset: 0,
cursor: 'pointer'
}}
size={25}
/>
)}
</Transition>
<Transition mounted={!showSendIcon} transition="pop" duration={180} timingFunction="ease">
{(styles) => (
<IconMicrophone
stroke={1.5}
color={colors.chevrons.active}
onClick={onMicroClick}
style={{
...styles,
position: 'absolute',
inset: 0,
cursor: 'pointer'
}}
size={25}
/>
)}
</Transition>
</Box>
</Flex> </Flex>
</Flex>} </Flex>}
{blocked && <Box mih={62} bg={colors.boxColor}> {blocked && <Box mih={62} bg={colors.boxColor}>

View File

@@ -0,0 +1,273 @@
import { useState, useRef, useCallback, useEffect } from "react";
export function useVoiceMessage(): {
isRecording: boolean;
isPaused: boolean;
duration: number;
waves: number[];
start: () => Promise<void>;
stop: () => void;
pause: () => void;
play: () => void;
error: string | null;
getAudioBlob: () => Blob | null;
interpolateCompressWaves: (targetLength: number) => number[];
} {
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [duration, setDuration] = useState(0);
const [waves, setWaves] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const waveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const waveDataRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const stopWaveLoop = useCallback(() => {
if (waveTimerRef.current) {
clearInterval(waveTimerRef.current);
waveTimerRef.current = null;
}
}, []);
const startTimer = useCallback(() => {
if (timerRef.current) return;
timerRef.current = setInterval(() => {
setDuration((prev) => prev + 1);
}, 1000);
}, []);
const startWaveLoop = useCallback(() => {
stopWaveLoop();
const analyser = analyserRef.current;
if (!analyser) return;
if (!waveDataRef.current || waveDataRef.current.length !== analyser.frequencyBinCount) {
waveDataRef.current = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount));
}
const MAX_WAVES = 120;
const tick = () => {
if (!analyserRef.current || !waveDataRef.current) return;
analyserRef.current.getByteFrequencyData(waveDataRef.current);
let peak = 0;
for (let i = 0; i < waveDataRef.current.length; i++) {
const v = waveDataRef.current[i];
if (v > peak) peak = v;
}
const bar = peak / 255;
setWaves((prev) => {
const next = [...prev, bar];
return next.length > MAX_WAVES ? next.slice(next.length - MAX_WAVES) : next;
});
};
tick();
waveTimerRef.current = setInterval(tick, 300);
}, [stopWaveLoop]);
const cleanupAudio = useCallback(() => {
stopWaveLoop();
sourceRef.current?.disconnect();
sourceRef.current = null;
analyserRef.current = null;
waveDataRef.current = null;
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}, [stopWaveLoop]);
const start = useCallback(async () => {
try {
setError(null);
setDuration(0);
setWaves([]);
chunksRef.current = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0;
analyser.minDecibels = -100;
analyser.maxDecibels = -10;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
audioContextRef.current = audioContext;
analyserRef.current = analyser;
sourceRef.current = source;
// Выбираем лучший поддерживаемый кодек
const preferredTypes = [
"audio/webm;codecs=opus",
"audio/ogg;codecs=opus",
"audio/webm",
];
const mimeType = preferredTypes.find((t) => MediaRecorder.isTypeSupported(t)) ?? "";
const mediaRecorder = new MediaRecorder(stream, {
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 32_000,
});
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
cleanupAudio();
};
mediaRecorder.start(100);
setIsRecording(true);
setIsPaused(false);
startTimer();
startWaveLoop();
} catch (err) {
setError("Could not start voice recording. Please check microphone permissions.");
console.error("Voice recording error:", err);
}
}, [startTimer, startWaveLoop, cleanupAudio]);
const stop = useCallback(() => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
mediaRecorderRef.current = null;
setIsRecording(false);
setIsPaused(false);
clearTimer();
stopWaveLoop();
}
}, [isRecording, clearTimer, stopWaveLoop]);
const pause = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
mediaRecorderRef.current.pause();
setIsPaused(true);
clearTimer();
stopWaveLoop();
}
}, [clearTimer, stopWaveLoop]);
const play = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "paused") {
mediaRecorderRef.current.resume();
setIsPaused(false);
startTimer();
startWaveLoop();
}
}, [startTimer, startWaveLoop]);
useEffect(() => {
return () => {
clearTimer();
stopWaveLoop();
if (mediaRecorderRef.current && mediaRecorderRef.current?.state !== "inactive") {
mediaRecorderRef.current.stop();
}
cleanupAudio();
};
}, [clearTimer, stopWaveLoop, cleanupAudio]);
const getAudioBlob = useCallback((): Blob | null => {
if (chunksRef.current.length === 0) return null;
const mimeType = mediaRecorderRef.current?.mimeType ?? "audio/webm;codecs=opus";
return new Blob(chunksRef.current, { type: mimeType });
}, []);
const interpolateCompressWaves = useCallback((targetLength: number) => {
if (targetLength <= 0) return [];
if (waves.length === 0) return Array(targetLength).fill(0);
if (waves.length === targetLength) return waves;
if (waves.length > targetLength) {
const compressed: number[] = [];
const bucketSize = waves.length / targetLength;
for (let i = 0; i < targetLength; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
let max = 0;
for (let j = start; j < end && j < waves.length; j++) {
if (waves[j] > max) max = waves[j];
}
compressed.push(max);
}
return compressed;
}
if (targetLength === 1) return [waves[0]];
const stretched: number[] = [];
const lastSourceIndex = waves.length - 1;
for (let i = 0; i < targetLength; i++) {
const position = (i * lastSourceIndex) / (targetLength - 1);
const left = Math.floor(position);
const right = Math.min(Math.ceil(position), lastSourceIndex);
if (left === right) {
stretched.push(waves[left]);
continue;
}
const t = position - left;
const value = waves[left] * (1 - t) + waves[right] * t;
stretched.push(value);
}
return stretched;
}, [waves]);
return {
isRecording,
isPaused,
duration,
waves,
start,
stop,
pause,
play,
error,
getAudioBlob,
interpolateCompressWaves,
};
}

View File

@@ -4,7 +4,7 @@ import animationData from './lottie.json';
import { Box, Flex, Skeleton, Text } from "@mantine/core"; import { Box, Flex, Skeleton, Text } from "@mantine/core";
import { useDialogsList } from "@/app/providers/DialogListProvider/useDialogsList"; import { useDialogsList } from "@/app/providers/DialogListProvider/useDialogsList";
import { GroupDialog } from "../GroupDialog/GroupDialog"; import { GroupDialog } from "../GroupDialog/GroupDialog";
import React from "react"; import { AnimatePresence, motion } from "framer-motion";
interface DialogsListProps { interface DialogsListProps {
mode: 'all' | 'requests'; mode: 'all' | 'requests';
@@ -13,6 +13,7 @@ interface DialogsListProps {
export function DialogsList(props : DialogsListProps) { export function DialogsList(props : DialogsListProps) {
const {dialogs, loadingDialogs} = useDialogsList(); const {dialogs, loadingDialogs} = useDialogsList();
const filteredDialogs = dialogs.filter(v => (v.is_request == (props.mode == 'requests')));
return ( return (
<> <>
@@ -36,21 +37,30 @@ export function DialogsList(props : DialogsListProps) {
))} ))}
</> </>
)} )}
{loadingDialogs === 0 && dialogs.filter(v => (v.is_request == (props.mode == 'requests'))).map((dialog) => ( <motion.div style={{display: 'flex', flexDirection: 'column'}}>
<React.Fragment key={dialog.dialog_id}> <AnimatePresence mode="popLayout">
{dialog.dialog_id.startsWith('#group:') ? ( {loadingDialogs === 0 && filteredDialogs.map((dialog) => (
<GroupDialog <motion.div
onClickDialog={props.onSelectDialog} key={dialog.dialog_id}
{...dialog} layout
/> initial={false}
) : ( transition={{ duration: 0.1, ease: 'easeInOut' }}
<Dialog >
onClickDialog={props.onSelectDialog} {dialog.dialog_id.startsWith('#group:') ? (
{...dialog} <GroupDialog
/> onClickDialog={props.onSelectDialog}
)} {...dialog}
</React.Fragment> />
))} ) : (
<Dialog
onClickDialog={props.onSelectDialog}
{...dialog}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</> </>
); );
} }

View File

@@ -10,6 +10,8 @@ import { DialogsPanelHeader } from '../DialogsPanelHeader/DialogsPanelHeader';
import { useDialogsList } from '@/app/providers/DialogListProvider/useDialogsList'; import { useDialogsList } from '@/app/providers/DialogListProvider/useDialogsList';
import { useVerifyRequest } from '@/app/providers/DeviceProvider/useVerifyRequest'; import { useVerifyRequest } from '@/app/providers/DeviceProvider/useVerifyRequest';
import { DeviceVerify } from '../DeviceVerify/DeviceVerify'; import { DeviceVerify } from '../DeviceVerify/DeviceVerify';
import { ActiveCall } from '../ActiveCall/ActiveCall';
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
export function DialogsPanel() { export function DialogsPanel() {
const [dialogsMode, setDialogsMode] = useState<'all' | 'requests'>('all'); const [dialogsMode, setDialogsMode] = useState<'all' | 'requests'>('all');
@@ -18,6 +20,7 @@ export function DialogsPanel() {
const colors = useRosettaColors(); const colors = useRosettaColors();
const navigate = useNavigate(); const navigate = useNavigate();
const device = useVerifyRequest(); const device = useVerifyRequest();
const [viewState] = useViewPanelsState();
useEffect(() => { useEffect(() => {
((async () => { ((async () => {
@@ -52,6 +55,9 @@ export function DialogsPanel() {
direction={'column'} direction={'column'}
justify={'space-between'} justify={'space-between'}
> >
{viewState == ViewPanelsState.DIALOGS_PANEL_ONLY && (
<ActiveCall></ActiveCall>
)}
<Box> <Box>
<DialogsPanelHeader></DialogsPanelHeader> <DialogsPanelHeader></DialogsPanelHeader>
{device && ( {device && (

View File

@@ -7,14 +7,16 @@ import { useHotkeys } from "@mantine/hooks";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey"; import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
import { DialogHeaderText } from "../DialogHeaderText/DialogHeaderText"; import { DialogHeaderText } from "../DialogHeaderText/DialogHeaderText";
import { useCoreDevice } from "@/app/providers/DeviceProvider/useCoreDevice";
export function DialogsPanelHeader() { export function DialogsPanelHeader() {
const colors = useRosettaColors(); const colors = useRosettaColors();
const logout = useLogout(); const logout = useLogout();
const navigate = useNavigate(); const navigate = useNavigate();
const publicKey = usePublicKey(); const publicKey = usePublicKey();
const viewKeys = window.platform == 'darwin' ? '⌘' : 'Ctrl+'; const {platform} = useCoreDevice();
const triggerKeys = window.platform == 'darwin' ? 'mod' : 'Ctrl'; const viewKeys = platform == 'darwin' ? '' : 'Ctrl+';
const triggerKeys = platform == 'darwin' ? 'mod' : 'Ctrl';
useHotkeys([ useHotkeys([
[`${triggerKeys}+L`, () => logout()], [`${triggerKeys}+L`, () => logout()],

View File

@@ -4,7 +4,7 @@
left: 12px; left: 12px;
display: flex; display: flex;
gap: 8px; gap: 8px;
z-index: 10; z-index: 15;
app-region: no-drag; app-region: no-drag;
} }
.close_btn, .minimize_btn, .maximize_btn { .close_btn, .minimize_btn, .maximize_btn {

View File

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

View File

@@ -8,6 +8,8 @@ import { ErrorBoundaryProvider } from "@/app/providers/ErrorBoundaryProvider/Err
import { AttachmentError } from "../AttachmentError/AttachmentError"; import { AttachmentError } from "../AttachmentError/AttachmentError";
import { MessageAvatar } from "./MessageAvatar"; import { MessageAvatar } from "./MessageAvatar";
import { MessageProps } from "../Messages/Message"; import { MessageProps } from "../Messages/Message";
import { MessageCall } from "./MessageCall";
import { MessageVoice } from "./MessageVoice";
export interface MessageAttachmentsProps { export interface MessageAttachmentsProps {
attachments: Attachment[]; attachments: Attachment[];
@@ -51,6 +53,10 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
return <MessageFile {...attachProps} key={index}></MessageFile> return <MessageFile {...attachProps} key={index}></MessageFile>
case AttachmentType.AVATAR: case AttachmentType.AVATAR:
return <MessageAvatar {...attachProps} key={index}></MessageAvatar> return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
case AttachmentType.CALL:
return <MessageCall {...attachProps} key={index}></MessageCall>
case AttachmentType.VOICE:
return <MessageVoice {...attachProps} key={index}></MessageVoice>
default: default:
return <AttachmentError key={index}></AttachmentError>; return <AttachmentError key={index}></AttachmentError>;
} }

View File

@@ -4,12 +4,12 @@ import { AspectRatio, Button, Flex, Paper, Text } from "@mantine/core";
import { IconArrowDown } from "@tabler/icons-react"; import { IconArrowDown } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AttachmentProps } from "./MessageAttachments"; import { AttachmentProps } from "./MessageAttachments";
import { blurhashToBase64Image } from "@/app/utils/utils";
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress"; import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider"; import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider";
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment"; import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
import { PopoverLockIconAvatar } from "../PopoverLockIconAvatar/PopoverLockIconAvatar"; import { PopoverLockIconAvatar } from "../PopoverLockIconAvatar/PopoverLockIconAvatar";
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints"; import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
import { blurhashToBase64Image } from "@/app/workers/image/image";
export function MessageAvatar(props: AttachmentProps) { export function MessageAvatar(props: AttachmentProps) {
const colors = useRosettaColors(); const colors = useRosettaColors();
@@ -19,16 +19,18 @@ export function MessageAvatar(props: AttachmentProps) {
download, download,
downloadStatus, downloadStatus,
getBlob, getBlob,
getPreview} = useAttachment(props.attachment, props.chacha_key_plain); getPreview} = useAttachment(props.attachment, props.parent);
const mainRef = useRef<HTMLDivElement>(null); const mainRef = useRef<HTMLDivElement>(null);
const { open } = useImageViewer(); const { open } = useImageViewer();
const preview = getPreview(); const preview = getPreview();
const [blob, setBlob] = useState(props.attachment.blob); const [blob, setBlob] = useState(props.attachment.blob);
const {lg} = useRosettaBreakpoints(); const {lg} = useRosettaBreakpoints();
const [blurhashPreview, setBlurhashPreview] = useState("");
useEffect(() => { useEffect(() => {
constructBlob(); constructBlob();
constructFromBlurhash();
}, [downloadStatus]); }, [downloadStatus]);
const constructBlob = async () => { 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 ( return (
<Paper withBorder p={'sm'}> <Paper withBorder p={'sm'}>
<Flex gap={'sm'} direction={'row'}> <Flex gap={'sm'} direction={'row'}>
@@ -70,7 +78,8 @@ export function MessageAvatar(props: AttachmentProps) {
height: 60, height: 60,
width: 60, width: 60,
borderRadius: '50%', borderRadius: '50%',
objectFit: 'cover' objectFit: 'cover',
background: '#fff'
}} src={blob}></img>)} }} src={blob}></img>)}
{downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING && preview.length >= 20 && ( {downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING && preview.length >= 20 && (
<> <>
@@ -79,7 +88,7 @@ export function MessageAvatar(props: AttachmentProps) {
height: 60, height: 60,
borderRadius: '50%', borderRadius: '50%',
objectFit: 'cover' objectFit: 'cover'
}} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}></img> }} src={blurhashPreview == "" ? undefined : blurhashPreview}></img>
</> </>
)} )}
</AspectRatio> </AspectRatio>

View File

@@ -0,0 +1,62 @@
import { useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
import { AttachmentProps } from "./MessageAttachments";
import { Avatar, Box, Flex, Text } from "@mantine/core";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { IconPhoneIncoming, IconPhoneOutgoing, IconX } from "@tabler/icons-react";
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
export function MessageCall(props: AttachmentProps) {
const {
getPreview,
} =
useAttachment(
props.attachment,
props.parent,
);
const publicKey = usePublicKey();
const preview = getPreview();
const caller = props.parent.from == publicKey;
const duration = parseInt(preview);
const colors = useRosettaColors();
const error = duration == 0;
return (
<Box p={'sm'} style={{
background: colors.mainColor,
border: '1px solid ' + colors.borderColor,
borderRadius: 8,
minWidth: 200,
minHeight: 60
}}>
<Flex gap={'sm'} direction={'row'}>
<Avatar bg={error ? colors.error : colors.brandColor} size={40}>
{!error && <>
{!caller && (
<IconPhoneIncoming color={'white'} size={22}></IconPhoneIncoming>
)}
{caller && (
<IconPhoneOutgoing color={'white'} size={22}></IconPhoneOutgoing>
)}
</>}
{error && <>
<IconX color={'white'} size={22}></IconX>
</>}
</Avatar>
<Flex direction={'column'} gap={5}>
<Text size={'sm'}>{
error ? (!caller ? "Missed call" : "Rejected call") : (!caller ? "Incoming call" : "Outgoing call")
}</Text>
{!error &&
<Text size={'xs'} c={colors.chevrons.active}>
{translateDurationToTime(duration)}
</Text>
}
{error && <Text size={'xs'} c={colors.error}>
Call was not answered or was rejected
</Text>}
</Flex>
</Flex>
</Box>
);
}

View File

@@ -6,6 +6,7 @@ import { IconArrowDown, IconFile, IconX } from "@tabler/icons-react";
import { dotCenterIfNeeded, humanFilesize } from "@/app/utils/utils"; import { dotCenterIfNeeded, humanFilesize } from "@/app/utils/utils";
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress"; import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider"; import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
import { useCore } from "@/app/hooks/useCore";
export function MessageFile(props : AttachmentProps) { export function MessageFile(props : AttachmentProps) {
const colors = useRosettaColors(); const colors = useRosettaColors();
@@ -18,7 +19,7 @@ export function MessageFile(props : AttachmentProps) {
} = } =
useAttachment( useAttachment(
props.attachment, props.attachment,
props.chacha_key_plain, props.parent,
); );
const preview = getPreview(); const preview = getPreview();
const error = downloadStatus == DownloadStatus.ERROR; const error = downloadStatus == DownloadStatus.ERROR;
@@ -27,15 +28,15 @@ export function MessageFile(props : AttachmentProps) {
const filetype = filename.split(".")[filename.split(".").length - 1]; const filetype = filename.split(".")[filename.split(".").length - 1];
const isEncrypting = props.delivered == DeliveredMessageState.WAITING && uploadedPercentage <= 0; const isEncrypting = props.delivered == DeliveredMessageState.WAITING && uploadedPercentage <= 0;
const isUploading = props.delivered == DeliveredMessageState.WAITING && uploadedPercentage > 0 && uploadedPercentage < 100; const isUploading = props.delivered == DeliveredMessageState.WAITING && uploadedPercentage > 0 && uploadedPercentage < 100;
const {getDownloadsPath} = useCore();
const onClick = async () => { const onClick = async () => {
if(downloadStatus == DownloadStatus.ERROR){ if(downloadStatus == DownloadStatus.ERROR){
return; return;
} }
if(downloadStatus == DownloadStatus.DOWNLOADED){ if(downloadStatus == DownloadStatus.DOWNLOADED){
//let content = await getBlob(); const downloadsPath = await getDownloadsPath();
//let buffer = Buffer.from(content.split(",")[1], 'base64'); let pathInDownloads = downloadsPath + "/Rosetta Downloads/" + filename;
let pathInDownloads = window.downloadsPath + "/Rosetta Downloads/" + filename;
//await writeFile(pathInDownloads, buffer, false); //await writeFile(pathInDownloads, buffer, false);
window.shell.showItemInFolder(pathInDownloads); window.shell.showItemInFolder(pathInDownloads);
return; return;

View File

@@ -1,14 +1,15 @@
import { useRosettaColors } from "@/app/hooks/useRosettaColors"; import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider"; import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
import { useImageViewer } from "@/app/providers/ImageViewerProvider/useImageViewer"; 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 { IconArrowDown, IconCircleX, IconFlameFilled } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AttachmentProps } from "./MessageAttachments"; import { AttachmentProps } from "./MessageAttachments";
import { blurhashToBase64Image, isMessageDeliveredByTime } from "@/app/utils/utils"; import { isMessageDeliveredByTime } from "@/app/utils/utils";
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress"; import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider"; import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider";
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment"; import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
import { blurhashToBase64Image } from "@/app/workers/image/image";
export function MessageImage(props: AttachmentProps) { export function MessageImage(props: AttachmentProps) {
const colors = useRosettaColors(); const colors = useRosettaColors();
@@ -18,16 +19,20 @@ export function MessageImage(props: AttachmentProps) {
download, download,
downloadStatus, downloadStatus,
getBlob, getBlob,
getPreview} = useAttachment(props.attachment, props.chacha_key_plain); getPreview } = useAttachment(props.attachment, props.parent);
const mainRef = useRef<HTMLDivElement>(null); const mainRef = useRef<HTMLDivElement>(null);
const error = downloadStatus == DownloadStatus.ERROR; const error = downloadStatus == DownloadStatus.ERROR;
const { open } = useImageViewer(); const { open } = useImageViewer();
const preview = getPreview(); const preview = getPreview();
const [blob, setBlob] = useState(props.attachment.blob); const [blob, setBlob] = useState(props.attachment.blob);
const [loadedImage, setLoadedImage] = useState(false); const [loadedImage, setLoadedImage] = useState(false);
const [blurhashPreview, setBlurhashPreview] = useState("");
useEffect(() => { useEffect(() => {
console.info(props.attachment);
console.info("Consturcting image, download status: " + downloadStatus);
constructBlob(); constructBlob();
constructFromBlurhash();
}, [downloadStatus]); }, [downloadStatus]);
const constructBlob = async () => { const constructBlob = async () => {
@@ -45,6 +50,12 @@ export function MessageImage(props: AttachmentProps) {
open(images, 0); open(images, 0);
} }
const constructFromBlurhash = async () => {
if (preview.length < 20) return;
const blob = await blurhashToBase64Image(preview, 200, 220);
setBlurhashPreview(blob);
}
const onClick = () => { const onClick = () => {
if (downloadStatus == DownloadStatus.DOWNLOADED) { if (downloadStatus == DownloadStatus.DOWNLOADED) {
openImageViewer(); openImageViewer();
@@ -55,7 +66,6 @@ export function MessageImage(props: AttachmentProps) {
return; return;
} }
} }
return ( return (
<AspectRatio onClick={onClick} ref={mainRef} style={{ <AspectRatio onClick={onClick} ref={mainRef} style={{
minWidth: 200, minWidth: 200,
@@ -76,7 +86,7 @@ export function MessageImage(props: AttachmentProps) {
border: '1px solid ' + colors.borderColor, border: '1px solid ' + colors.borderColor,
display: loadedImage ? 'block' : 'none' display: loadedImage ? 'block' : 'none'
}} src={blob} onLoad={() => setLoadedImage(true)}></img>)} }} src={blob} onLoad={() => setLoadedImage(true)}></img>)}
{((downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING) || !loadedImage) && preview.length >= 20 && ( {((downloadStatus == DownloadStatus.NOT_DOWNLOADED) || !loadedImage) && preview.length >= 20 && (
<> <>
<img style={{ <img style={{
minHeight: 220, minHeight: 220,
@@ -84,45 +94,7 @@ export function MessageImage(props: AttachmentProps) {
borderRadius: 8, borderRadius: 8,
objectFit: 'cover', objectFit: 'cover',
border: '1px solid ' + colors.borderColor border: '1px solid ' + colors.borderColor
}} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}></img> }} src={blurhashPreview == "" ? undefined : blurhashPreview}></img>
<Portal target={mainRef.current!}>
<Flex direction={'column'} pos={'absolute'} justify={'center'} top={0} h={'100%'} align={'center'} gap={'xs'}>
{!error && (
<Box style={{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 50,
height: 40,
width: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}>
{downloadPercentage > 0 ? (
<AnimatedRoundedProgress size={40} value={downloadPercentage} color="white"></AnimatedRoundedProgress>
) : (
<IconArrowDown size={25} color={'white'} />
)}
</Box>
)}
{error && (
<Box p={'xs'} style={{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 8,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 4
}}>
<Text size={'xs'} c={'white'}>
Image expired
</Text>
<IconFlameFilled size={15} style={{
fontSmooth: 'always'
}} color={'white'} />
</Box>
)}
</Flex>
</Portal>
</> </>
)} )}
@@ -141,7 +113,43 @@ export function MessageImage(props: AttachmentProps) {
<AnimatedRoundedProgress size={40} value={uploadedPercentage > 95 ? 95 : uploadedPercentage}></AnimatedRoundedProgress> <AnimatedRoundedProgress size={40} value={uploadedPercentage > 95 ? 95 : uploadedPercentage}></AnimatedRoundedProgress>
</Box> </Box>
</Flex> </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 && {(props.delivered == DeliveredMessageState.ERROR || (props.delivered != DeliveredMessageState.DELIVERED &&
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length) !isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
)) && ( )) && (
@@ -149,6 +157,52 @@ export function MessageImage(props: AttachmentProps) {
<IconCircleX size={40} color={colors.error} /> <IconCircleX size={40} color={colors.error} />
</Overlay> </Overlay>
)} )}
{(downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.DOWNLOADING) && (<Flex direction={'column'} pos={'absolute'} top={0} justify={'center'} h={'100%'} align={'center'} gap={'xs'}>
{!error && downloadStatus == DownloadStatus.DOWNLOADING && (
<Box style={{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 50,
height: 40,
width: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}>
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} color="white"></AnimatedRoundedProgress>
</Box>
)}
{!error && downloadStatus != DownloadStatus.DOWNLOADING && (
<Box style={{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 50,
height: 40,
width: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}>
<IconArrowDown size={25} color={'white'} />
</Box>
)}
{error && (
<Box p={'xs'} style={{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 8,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 4
}}>
<Text size={'xs'} c={'white'}>
Image expired
</Text>
<IconFlameFilled size={15} style={{
fontSmooth: 'always'
}} color={'white'} />
</Box>
)}
</Flex>)}
</AspectRatio> </AspectRatio>
); );
} }

View File

@@ -5,6 +5,7 @@ import { ReplyedMessage } from "../ReplyedMessage/ReplyedMessage";
import { IconX } from "@tabler/icons-react"; import { IconX } from "@tabler/icons-react";
import { useSetting } from "@/app/providers/SettingsProvider/useSetting"; import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { MessageReply } from "@/app/providers/DialogProvider/useReplyMessages";
export function MessageReplyMessages(props: AttachmentProps) { export function MessageReplyMessages(props: AttachmentProps) {
const colors = useRosettaColors(); const colors = useRosettaColors();
@@ -12,9 +13,7 @@ export function MessageReplyMessages(props: AttachmentProps) {
('showAlertInReplyMessages', true); ('showAlertInReplyMessages', true);
const [bgInReplyMessages] = useSetting<string> const [bgInReplyMessages] = useSetting<string>
('bgInReplyMessages', ''); ('bgInReplyMessages', '');
const reply = JSON.parse(props.attachment.blob); const reply = JSON.parse(props.attachment.blob) as MessageReply[];
//console.info("Mreply", reply);
const closeAlert = () => { const closeAlert = () => {
modals.openConfirmModal({ modals.openConfirmModal({
@@ -40,8 +39,8 @@ export function MessageReplyMessages(props: AttachmentProps) {
{reply.length <= 0 && {reply.length <= 0 &&
<Skeleton h={50} w={'100%'}></Skeleton> <Skeleton h={50} w={'100%'}></Skeleton>
} }
{reply.map((msg, index) => ( {reply.map((msg : MessageReply, index) => (
<ReplyedMessage parent={props.parent} chacha_key_plain={props.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage> <ReplyedMessage parent={props.parent} chacha_key_plain={msg.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
))} ))}
{showAlertInReplyMessages && <Alert style={{ {showAlertInReplyMessages && <Alert style={{
borderTopLeftRadius: 0, borderTopLeftRadius: 0,

View File

@@ -0,0 +1,267 @@
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
import { AttachmentProps } from "./MessageAttachments";
import { Avatar, Box, Flex, Text, useMantineTheme } from "@mantine/core";
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
import { IconArrowDown, IconPlayerPauseFilled, IconPlayerPlayFilled, IconX } from "@tabler/icons-react";
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { useMemo, useRef } from "react";
import { usePlayerContext } from "@/app/providers/PlayerProvider/usePlayerContext";
const WAVE_BARS = 40;
const BAR_WIDTH = 2;
const BAR_GAP = 2;
const MIN_BAR_HEIGHT = 4;
const MAX_BAR_HEIGHT = 24;
function normalizeWaves(source: number[], targetLength: number): number[] {
if (targetLength <= 0) return [];
if (source.length === 0) return Array(targetLength).fill(0);
if (source.length === targetLength) return source;
if (source.length > targetLength) {
const compressed: number[] = [];
const bucketSize = source.length / targetLength;
for (let i = 0; i < targetLength; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
let max = 0;
for (let j = start; j < end && j < source.length; j++) {
if (source[j] > max) max = source[j];
}
compressed.push(max);
}
return compressed;
}
if (targetLength === 1) return [source[0]];
const stretched: number[] = [];
const lastSourceIndex = source.length - 1;
for (let i = 0; i < targetLength; i++) {
const position = (i * lastSourceIndex) / (targetLength - 1);
const left = Math.floor(position);
const right = Math.min(Math.ceil(position), lastSourceIndex);
if (left === right) {
stretched.push(source[left]);
continue;
}
const t = position - left;
stretched.push(source[left] * (1 - t) + source[right] * t);
}
return stretched;
}
function formatTime(seconds: number) {
const s = Math.max(0, Math.floor(seconds));
const m = Math.floor(s / 60).toString().padStart(2, "0");
const r = (s % 60).toString().padStart(2, "0");
return `${m}:${r}`;
}
export function MessageVoice(props: AttachmentProps) {
const { downloadPercentage, downloadStatus, uploadedPercentage, download, getPreview } = useAttachment(
props.attachment,
props.parent,
);
const theme = useMantineTheme();
const colors = useRosettaColors();
const preview = getPreview() || "";
const [durationPart = "0", wavesPart = ""] = preview.split("::");
const previewDuration = Number.parseInt(durationPart, 10) || 0;
const rawWaves = useMemo(
() =>
wavesPart
.split(",")
.map((s) => Number.parseFloat(s))
.filter((n) => Number.isFinite(n) && n >= 0),
[wavesPart]
);
const waves = useMemo(() => normalizeWaves(rawWaves, WAVE_BARS), [rawWaves]);
const peak = useMemo(() => {
const max = Math.max(...waves, 0);
return max > 0 ? max : 1;
}, [waves]);
const isUploading =
props.delivered === DeliveredMessageState.WAITING &&
uploadedPercentage > 0 &&
uploadedPercentage < 100;
const error = downloadStatus === DownloadStatus.ERROR;
const waveformWidth = WAVE_BARS * BAR_WIDTH + (WAVE_BARS - 1) * BAR_GAP;
const waveformRef = useRef<HTMLDivElement | null>(null);
const {
playAudio,
pause,
duration: currentDuration,
playing,
setDuration,
totalDuration,
currentMessageId,
} = usePlayerContext();
const messageId = String((props.parent as any)?.id ?? (props.attachment as any)?.messageId ?? props.attachment.id);
const isCurrentTrack = currentMessageId === messageId;
const fullDuration = Math.max(isCurrentTrack && totalDuration > 0 ? totalDuration : previewDuration, 1);
const safeCurrent = isCurrentTrack ? currentDuration : 0;
const playbackProgress = Math.max(0, Math.min(1, safeCurrent / fullDuration));
const createAudioBlob = () => new Blob([Buffer.from(props.attachment.blob, "hex")], { type: "audio/webm;codecs=opus" });
const ensureStarted = (seekToSec?: number) => {
const blob = createAudioBlob();
playAudio("Voice Message", "", blob, messageId);
if (typeof seekToSec === "number") {
requestAnimationFrame(() => setDuration(seekToSec));
}
};
const handleMainAction = () => {
if (error) return;
if (downloadStatus !== DownloadStatus.DOWNLOADED) {
download();
return;
}
if (!isCurrentTrack) {
ensureStarted();
return;
}
if (playing) {
pause();
return;
}
ensureStarted(Math.max(0, safeCurrent));
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (error || downloadStatus !== DownloadStatus.DOWNLOADED) return;
const rect = waveformRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
const x = e.clientX - rect.left;
const progress = Math.max(0, Math.min(1, x / rect.width));
const seekTo = progress * fullDuration;
if (!isCurrentTrack) {
ensureStarted(seekTo);
return;
}
setDuration(seekTo);
};
const timeText =
isCurrentTrack && safeCurrent > 0
? `-${formatTime(Math.max(0, fullDuration - safeCurrent))}`
: formatTime(fullDuration);
return (
<Flex gap="sm" align="center">
<Avatar
bg={error ? colors.error : colors.brandColor}
size={40}
style={{ cursor: "pointer", position: "relative" }}
onClick={handleMainAction}
>
{!error && (
<>
{downloadStatus === DownloadStatus.DOWNLOADING && (
<div style={{ position: "absolute", top: 0, left: 0 }}>
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} />
</div>
)}
{isUploading && (
<div style={{ position: "absolute", top: 0, left: 0 }}>
<AnimatedRoundedProgress color="#fff" size={40} value={uploadedPercentage} />
</div>
)}
{downloadStatus !== DownloadStatus.DOWNLOADED && <IconArrowDown color="white" size={22} />}
{downloadStatus === DownloadStatus.DOWNLOADED && !isUploading &&
(isCurrentTrack && playing ? (
<IconPlayerPauseFilled color="white" size={22} />
) : (
<IconPlayerPlayFilled color="white" size={22} />
))}
</>
)}
{(error || isUploading) && <IconX color="white" size={22} />}
</Avatar>
<Flex direction="column">
<Box
ref={waveformRef}
w={waveformWidth}
h={32}
onClick={handleSeek}
style={{ overflow: "hidden", cursor: "pointer" }}
>
<Flex h="100%" align="center" gap={BAR_GAP} wrap="nowrap">
{waves.map((value, index) => {
const normalized = Math.max(0, Math.min(1, value / peak));
const height = Math.max(
MIN_BAR_HEIGHT,
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
);
const passed = playbackProgress * waves.length - index;
const fillPercent = Math.max(0, Math.min(1, passed));
const inactiveColor = theme.colors.gray[4];
const activeColor = colors.brandColor;
let background = inactiveColor;
if (fillPercent >= 1) {
background = activeColor;
} else if (fillPercent > 0) {
background = `linear-gradient(90deg, ${activeColor} 0%, ${activeColor} ${fillPercent * 100}%, ${inactiveColor} ${fillPercent * 100}%, ${inactiveColor} 100%)`;
}
return (
<Box
key={index}
w={BAR_WIDTH}
h={height}
style={{
flex: `0 0 ${BAR_WIDTH}px`,
borderRadius: 999,
background,
}}
/>
);
})}
</Flex>
</Box>
<Text size="xs" c="dimmed">
{timeText}
</Text>
</Flex>
</Flex>
);
}

View File

@@ -103,7 +103,11 @@ export function Message(props: MessageProps) {
publicKey: user.publicKey, publicKey: user.publicKey,
message: props.message, message: props.message,
attachments: props.attachments.filter(a => a.type != AttachmentType.MESSAGES), attachments: props.attachments.filter(a => a.type != AttachmentType.MESSAGES),
message_id: props.message_id message_id: props.message_id,
/**
* Кодируем в hex чтобы было удобнее передавать по сети
*/
chacha_key_plain: props.chacha_key_plain
}; };
const avatars = useAvatars(user.publicKey); const avatars = useAvatars(user.publicKey);
@@ -125,6 +129,9 @@ export function Message(props: MessageProps) {
if (props.replyed) { if (props.replyed) {
return false; return false;
} }
if(props.chacha_key_plain == ""){
return false;
}
if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) { if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) {
return false; return false;
} }
@@ -186,7 +193,7 @@ export function Message(props: MessageProps) {
{computedMessageStyle == MessageStyle.ROWS && ( {computedMessageStyle == MessageStyle.ROWS && (
<Flex direction={'row'} justify={'space-between'} gap={'sm'}> <Flex direction={'row'} justify={'space-between'} gap={'sm'}>
<Flex direction={'row'} gap={'sm'}> <Flex direction={'row'} gap={'sm'}>
{(!props.avatar_no_render && (md || !props.replyed)) && <Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>} {(!props.avatar_no_render && (md || !props.replyed)) && <Avatar bg={avatars.length > 0 ? '#fff' : undefined} onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>}
<Flex direction={'column'}> <Flex direction={'column'}>
<Flex direction={'row'} gap={3} align={'center'}> <Flex direction={'row'} gap={3} align={'center'}>
{!props.avatar_no_render && ( {!props.avatar_no_render && (
@@ -213,7 +220,7 @@ export function Message(props: MessageProps) {
userSelect: 'text', userSelect: 'text',
fontSize: '13px', fontSize: '13px',
color: messageStyle == MessageStyle.BUBBLES ? (computedTheme == 'light' ? (props.parent?.from_me ? 'white' : 'black') : 'white') : (computedTheme == 'light' ? 'black' : 'white') color: messageStyle == MessageStyle.BUBBLES ? (computedTheme == 'light' ? (props.parent?.from_me ? 'white' : 'black') : 'white') : (computedTheme == 'light' ? 'black' : 'white')
}} ml={props.avatar_no_render ? 50 : undefined}> }} ml={props.avatar_no_render ? 50 : undefined} onDoubleClick={(e) => e.stopPropagation()}>
<TextParser performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser> <TextParser performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
</Box> </Box>
</Flex> </Flex>
@@ -262,7 +269,7 @@ export function Message(props: MessageProps) {
return ( return (
<Flex direction={props.from_me ? 'row-reverse' : 'row'} gap={'sm'} align={'flex-end'}> <Flex direction={props.from_me ? 'row-reverse' : 'row'} gap={'sm'} align={'flex-end'}>
{(md && props.is_last_message_in_stack) && ( {(md && props.is_last_message_in_stack) && (
<Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar> <Avatar bg={avatars.length > 0 ? '#fff' : undefined} onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar>
)} )}
{(md && !props.is_last_message_in_stack) && ( {(md && !props.is_last_message_in_stack) && (
<Box style={{ width: 40, height: 40, flexShrink: 0 }}></Box> <Box style={{ width: 40, height: 40, flexShrink: 0 }}></Box>
@@ -302,7 +309,7 @@ export function Message(props: MessageProps) {
userSelect: 'text', userSelect: 'text',
fontSize: '14px', fontSize: '14px',
color: props.from_me ? 'white' : (computedTheme == 'light' ? 'black' : 'white') color: props.from_me ? 'white' : (computedTheme == 'light' ? 'black' : 'white')
}}> }} onDoubleClick={(e) => e.stopPropagation()}>
<TextParser __reserved_2 performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser> <TextParser __reserved_2 performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
</Box> </Box>
)} )}

View File

@@ -7,14 +7,12 @@ import { MessageSkeleton } from "../MessageSkeleton/MessageSkeleton";
import { ScrollArea } from "@mantine/core"; import { ScrollArea } from "@mantine/core";
import { MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S, SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX } from "@/app/constants"; import { MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S, SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX } from "@/app/constants";
import { DialogAffix } from "../DialogAffix/DialogAffix"; import { DialogAffix } from "../DialogAffix/DialogAffix";
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
import { useSetting } from "@/app/providers/SettingsProvider/useSetting"; import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
export function Messages() { export function Messages() {
const colors = useRosettaColors(); const colors = useRosettaColors();
const publicKey = usePublicKey(); const publicKey = usePublicKey();
const { messages, dialog, loadMessagesToTop, loading } = useDialog(); const { messages, dialog, loadMessagesToTop, loading } = useDialog();
const { replyMessages, isSelectionStarted } = useReplyMessages();
const viewportRef = useRef<HTMLDivElement | null>(null); const viewportRef = useRef<HTMLDivElement | null>(null);
const lastMessageRef = useRef<HTMLDivElement | null>(null); const lastMessageRef = useRef<HTMLDivElement | null>(null);
@@ -22,6 +20,8 @@ export function Messages() {
const shouldAutoScrollRef = useRef(true); const shouldAutoScrollRef = useRef(true);
const isFirstRenderRef = useRef(true); const isFirstRenderRef = useRef(true);
const previousScrollHeightRef = useRef(0); const previousScrollHeightRef = useRef(0);
const distanceFromButtomRef = useRef(0);
const distanceFromTopRef = useRef(0);
const [affix, setAffix] = useState(false); const [affix, setAffix] = useState(false);
const [wallpaper] = useSetting<string> const [wallpaper] = useSetting<string>
@@ -75,25 +75,25 @@ export function Messages() {
return () => observer.disconnect(); return () => observer.disconnect();
}, [messages.length, loading]); }, [messages.length, loading]);
// MutationObserver - отслеживаем изменения контента (загрузка картинок, видео)
useEffect(() => { useEffect(() => {
if (!contentRef.current) return; if (!contentRef.current || !viewportRef.current) return;
const observer = new MutationObserver(() => { const contentEl = contentRef.current;
// Скроллим только если нужен авто-скролл //const viewportEl = viewportRef.current;
if (shouldAutoScrollRef.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); scrollToBottom(true);
} }
}); });
observer.observe(contentRef.current, { ro.observe(contentEl);
childList: true, return () => ro.disconnect();
subtree: true,
attributes: true,
attributeFilter: ['src', 'style', 'class']
});
return () => observer.disconnect();
}, [scrollToBottom]); }, [scrollToBottom]);
// Первый рендер - скроллим вниз моментально // Первый рендер - скроллим вниз моментально
@@ -121,15 +121,11 @@ export function Messages() {
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1];
// Скроллим если пользователь внизу или это его собственное сообщение // Скроллим если пользователь внизу или это его собственное сообщение
if ((shouldAutoScrollRef.current || lastMessage.from_me) && !affix) { if ((shouldAutoScrollRef.current || lastMessage.from_me) && distanceFromTopRef.current > 10) {
/** console.info(distanceFromTopRef.current);
* Скролл только если пользователь не читает сейчас старую переписку
* (!affix))
*/
//console.info("Scroll because", shouldAutoScrollRef.current);
scrollToBottom(true); scrollToBottom(true);
} }
}, [messages.length, loading, affix, scrollToBottom]); }, [messages.length, loading, scrollToBottom]);
// Восстановление позиции после загрузки старых сообщений // Восстановление позиции после загрузки старых сообщений
useEffect(() => { useEffect(() => {
@@ -142,12 +138,6 @@ export function Messages() {
} }
}, [messages.length]); }, [messages.length]);
// Скролл при отправке reply сообщения
useEffect(() => {
if (replyMessages.messages.length === 0 || isSelectionStarted()) return;
scrollToBottom(true);
}, [replyMessages.messages.length]);
const loadMessagesToScrollAreaTop = async () => { const loadMessagesToScrollAreaTop = async () => {
if (!viewportRef.current) return; if (!viewportRef.current) return;
@@ -187,6 +177,8 @@ export function Messages() {
onScrollPositionChange={(scroll) => { onScrollPositionChange={(scroll) => {
if (!viewportRef.current) return; if (!viewportRef.current) return;
distanceFromTopRef.current = scroll.y;
// Загружаем старые сообщения при достижении верха // Загружаем старые сообщения при достижении верха
if (scroll.y === 0 && !loading && messages.length >= 20) { if (scroll.y === 0 && !loading && messages.length >= 20) {
loadMessagesToScrollAreaTop(); loadMessagesToScrollAreaTop();
@@ -195,6 +187,7 @@ export function Messages() {
// Показываем/скрываем кнопку "вниз" // Показываем/скрываем кнопку "вниз"
const distanceFromBottom = const distanceFromBottom =
(viewportRef.current.scrollHeight - viewportRef.current.clientHeight) - scroll.y; (viewportRef.current.scrollHeight - viewportRef.current.clientHeight) - scroll.y;
distanceFromButtomRef.current = distanceFromBottom;
setAffix(distanceFromBottom > SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX); setAffix(distanceFromBottom > SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX);
}} }}

View File

@@ -1,7 +1,7 @@
import { useRosettaColors } from "@/app/hooks/useRosettaColors"; import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages"; import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
import { Button, Flex, Modal, Text } from "@mantine/core"; import { Button, Flex, Modal, Text } from "@mantine/core";
import { useDisclosure, useHotkeys } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconCornerUpLeft, IconCornerUpRightDouble, IconTrash, IconX } from "@tabler/icons-react"; import { IconCornerUpLeft, IconCornerUpRightDouble, IconTrash, IconX } from "@tabler/icons-react";
import classes from "./ReplyHeader.module.css"; import classes from "./ReplyHeader.module.css";
import { DialogsList } from "../DialogsList/DialogsList"; import { DialogsList } from "../DialogsList/DialogsList";
@@ -20,10 +20,6 @@ export function ReplyHeader() {
const navigate = useNavigate(); const navigate = useNavigate();
const {deleteSelectedMessages} = useDialog(); const {deleteSelectedMessages} = useDialog();
useHotkeys([
['Esc', deselectAllMessages]
], [], true);
const onClickForward = () => { const onClickForward = () => {
open(); open();
} }

View File

@@ -42,6 +42,20 @@ export function TextParser(props: TextParserProps) {
const theme = useMantineTheme(); const theme = useMantineTheme();
let entityCount = 0; let entityCount = 0;
const UNICODE_EMOJI_SEQUENCE_REGEX =
/(?:\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
const UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL = new RegExp(
UNICODE_EMOJI_SEQUENCE_REGEX.source,
"gu"
);
const toUnified = (value: string): string =>
Array.from(value)
.map((ch) => ch.codePointAt(0)?.toString(16))
.filter(Boolean)
.join("-");
const formatRules : FormatRule[] = [ const formatRules : FormatRule[] = [
{ {
pattern: [ pattern: [
@@ -119,6 +133,23 @@ export function TextParser(props: TextParserProps) {
return <>{match}</>; return <>{match}</>;
} }
}, },
{
// unicode emojis (including composite sequences)
pattern: [UNICODE_EMOJI_SEQUENCE_REGEX],
render: (match: string) => {
const textWithoutEmojis = props.text.replace(UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL, "");
const unified = toUnified(match);
if (textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
return <Emoji size={40} unified={unified}></Emoji>;
}
return <Emoji unified={unified}></Emoji>;
},
flush: (match: string) => {
return <Emoji unified={toUnified(match)}></Emoji>;
}
},
{ {
// :emoji_code: // :emoji_code:
pattern: [/:emoji_([a-zA-Z0-9_-]+):/], pattern: [/:emoji_([a-zA-Z0-9_-]+):/],

View File

@@ -5,17 +5,19 @@ import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolSt
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider"; import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
import { WindowsFrameButtons } from "../WindowsFrameButtons/WindowsFrameButtons"; import { WindowsFrameButtons } from "../WindowsFrameButtons/WindowsFrameButtons";
import { MacFrameButtons } from "../MacFrameButtons/MacFrameButtons"; import { MacFrameButtons } from "../MacFrameButtons/MacFrameButtons";
import { useCoreDevice } from "@/app/providers/DeviceProvider/useCoreDevice";
export function Topbar() { export function Topbar() {
const colors = useRosettaColors(); const colors = useRosettaColors();
const [protocolState] = useProtocolState(); const [protocolState] = useProtocolState();
const {platform} = useCoreDevice();
return ( return (
<Box className={classes.drag} ta={'center'} p={3} bg={colors.mainColor}> <Box className={classes.drag} ta={'center'} p={3} bg={colors.mainColor}>
{window.platform == 'win32' && <WindowsFrameButtons></WindowsFrameButtons>} {platform == 'win32' && <WindowsFrameButtons></WindowsFrameButtons>}
{window.platform == 'darwin' && <MacFrameButtons></MacFrameButtons>} {platform == 'darwin' && <MacFrameButtons></MacFrameButtons>}
{window.platform == 'linux' && <WindowsFrameButtons></WindowsFrameButtons>} {platform == 'linux' && <WindowsFrameButtons></WindowsFrameButtons>}
{(protocolState == ProtocolState.CONNECTED || protocolState == ProtocolState.SYNCHRONIZATION || !window.location.hash.includes("main")) && {(protocolState == ProtocolState.CONNECTED || protocolState == ProtocolState.SYNCHRONIZATION || !window.location.hash.includes("main")) &&
<Flex align={'center'} justify={'center'}> <Flex align={'center'} justify={'center'}>
<Text fw={'bolder'} fz={13} c={'gray'}> <Text fw={'bolder'} fz={13} c={'gray'}>

View File

@@ -20,7 +20,8 @@ export function UpdateAlert(props : UpdateAlertProps) {
updateStatus, updateStatus,
downloadLastApplicationUpdate, downloadLastApplicationUpdate,
restartAppForUpdateApply, restartAppForUpdateApply,
checkForUpdates checkForUpdates,
updateServer
} = useUpdater(); } = useUpdater();
useEffect(() => { useEffect(() => {
@@ -34,7 +35,7 @@ export function UpdateAlert(props : UpdateAlertProps) {
<Button h={45} leftSection={ <Button h={45} leftSection={
<IconRefresh size={15}/> <IconRefresh size={15}/>
} onClick={() => { } onClick={() => {
window.shell.openExternal(kernelUpdateUrl); window.shell.openExternal(updateServer + kernelUpdateUrl);
}} fullWidth variant={'gradient'} gradient={{ from: 'red', to: 'orange', deg: 233 }} radius={radius}> }} fullWidth variant={'gradient'} gradient={{ from: 'red', to: 'orange', deg: 233 }} radius={radius}>
Kernel update required Kernel update required
</Button> </Button>

View File

@@ -40,6 +40,7 @@ export function UserRow(props: UserRowProps) {
radius="xl" radius="xl"
name={userInfo.title} name={userInfo.title}
color={'initials'} color={'initials'}
bg={avatars.length > 0 ? '#fff' : undefined}
src={avatars.length > 0 ? avatars[0].avatar : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined}
/> />
<Flex direction={'column'}> <Flex direction={'column'}>

View File

@@ -0,0 +1,278 @@
import { Box, Flex, Text, useMantineTheme } from "@mantine/core";
import { useEffect, useRef, useState } from "react";
interface VoiceRecorderProps {
duration: number;
waves: number[];
}
type AnimatedBar = {
id: number;
value: number;
entered: boolean;
};
const VISIBLE_BARS = 50;
const BAR_WIDTH = 3;
const BAR_GAP = 2;
const STEP_PX = BAR_WIDTH + BAR_GAP;
const COMPONENT_HEIGHT = 45;
const MAX_BAR_HEIGHT = 28;
const MIN_BAR_HEIGHT = 4;
export function VoiceRecorder(props: VoiceRecorderProps) {
const theme = useMantineTheme();
const [bars, setBars] = useState<AnimatedBar[]>([]);
const [subShift, setSubShift] = useState(0);
const prevLengthRef = useRef(0);
const prevWavesRef = useRef<number[]>([]);
const idRef = useRef(0);
const enterFrameRef = useRef<number | null>(null);
const scrollFrameRef = useRef<number | null>(null);
const lastAppendAtRef = useRef<number | null>(null);
const appendIntervalRef = useRef(120);
const barsLengthRef = useRef(0);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const secs = (seconds % 60).toString().padStart(2, "0");
return `${mins}:${secs}`;
};
useEffect(() => {
barsLengthRef.current = bars.length;
}, [bars.length]);
useEffect(() => {
if (props.waves.length === 0) {
setBars([]);
setSubShift(0);
prevLengthRef.current = 0;
prevWavesRef.current = [];
lastAppendAtRef.current = null;
return;
}
if (props.waves.length < prevLengthRef.current) {
const resetBars = props.waves.slice(-VISIBLE_BARS).map((value) => ({
id: idRef.current++,
value,
entered: true,
}));
setBars(resetBars);
setSubShift(0);
prevLengthRef.current = props.waves.length;
prevWavesRef.current = props.waves;
lastAppendAtRef.current = performance.now();
return;
}
const prevWaves = prevWavesRef.current;
let appended: number[] = [];
// Обычный режим: длина выросла
if (props.waves.length > prevLengthRef.current) {
appended = props.waves.slice(prevLengthRef.current);
} else if (props.waves.length === prevLengthRef.current && props.waves.length > 0) {
// Rolling buffer: длина та же, но данные сдвигаются
let changed = false;
if (prevWaves.length !== props.waves.length) {
changed = true;
} else {
for (let i = 0; i < props.waves.length; i++) {
if (props.waves[i] !== prevWaves[i]) {
changed = true;
break;
}
}
}
if (changed) {
appended = [props.waves[props.waves.length - 1]];
}
}
if (appended.length > 0) {
const now = performance.now();
if (lastAppendAtRef.current != null) {
const dt = now - lastAppendAtRef.current;
const perBar = dt / appended.length;
appendIntervalRef.current = appendIntervalRef.current * 0.7 + perBar * 0.3;
}
lastAppendAtRef.current = now;
setSubShift(0);
const newIds: number[] = [];
setBars((prev) => {
const next = [...prev];
appended.forEach((value) => {
const id = idRef.current++;
newIds.push(id);
next.push({
id,
value,
entered: false,
});
});
return next.slice(-VISIBLE_BARS);
});
if (enterFrameRef.current) {
cancelAnimationFrame(enterFrameRef.current);
}
enterFrameRef.current = requestAnimationFrame(() => {
setBars((prev) => {
const ids = new Set(newIds);
return prev.map((bar) => (ids.has(bar.id) ? { ...bar, entered: true } : bar));
});
});
}
prevLengthRef.current = props.waves.length;
prevWavesRef.current = props.waves;
}, [props.waves]);
useEffect(() => {
const tick = () => {
const startedAt = lastAppendAtRef.current;
if (startedAt != null) {
const elapsed = performance.now() - startedAt;
const interval = Math.max(16, appendIntervalRef.current);
const progress = Math.min(1, elapsed / interval);
const smoothShift = barsLengthRef.current >= VISIBLE_BARS ? -progress * STEP_PX : 0;
setSubShift(smoothShift);
} else {
setSubShift(0);
}
scrollFrameRef.current = requestAnimationFrame(tick);
};
scrollFrameRef.current = requestAnimationFrame(tick);
return () => {
if (scrollFrameRef.current) {
cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
useEffect(() => {
return () => {
if (enterFrameRef.current) {
cancelAnimationFrame(enterFrameRef.current);
}
if (scrollFrameRef.current) {
cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const waveformWidth = VISIBLE_BARS * BAR_WIDTH + (VISIBLE_BARS - 1) * BAR_GAP;
return (
<Flex
direction="row"
h={COMPONENT_HEIGHT}
mih={COMPONENT_HEIGHT}
mah={COMPONENT_HEIGHT}
align="center"
justify="center"
gap="xs"
px={6}
>
<Text size="xs" c="dimmed" w={36}>
{formatDuration(props.duration)}
</Text>
<Box
w={waveformWidth}
h={COMPONENT_HEIGHT}
style={{
overflow: "hidden",
}}
>
<Flex
h="100%"
align="center"
gap={BAR_GAP}
wrap="nowrap"
style={{ transform: `translateX(${subShift}px)` }}
>
{Array.from({ length: VISIBLE_BARS }).map((_, index) => {
const bar = bars[index];
if (!bar) {
return (
<Box
key={`empty-${index}`}
w={BAR_WIDTH}
h={MIN_BAR_HEIGHT}
style={{
flex: `0 0 ${BAR_WIDTH}px`,
borderRadius: 999,
background: theme.colors.gray[3],
opacity: 0.22,
}}
/>
);
}
const normalized = Math.max(0, Math.min(1, bar.value));
const height = Math.max(
MIN_BAR_HEIGHT,
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
);
const isLast = index === bars.length - 1;
const isNearTail = index >= bars.length - 3;
return (
<Box
key={bar.id}
w={BAR_WIDTH}
h={height}
style={{
flex: `0 0 ${BAR_WIDTH}px`,
alignSelf: "center",
borderRadius: 999,
background: isLast
? `linear-gradient(180deg, ${theme.colors.blue[3]} 0%, ${theme.colors.blue[5]} 100%)`
: `linear-gradient(180deg, ${theme.colors.blue[4]} 0%, ${theme.colors.blue[6]} 100%)`,
boxShadow: isLast
? `0 0 10px ${theme.colors.blue[4]}55`
: isNearTail
? `0 0 6px ${theme.colors.blue[4]}22`
: "none",
transform: bar.entered ? "scaleY(1)" : "scaleY(0.18)",
transformOrigin: "center center",
transition: [
"height 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
"transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
"opacity 220ms ease",
"box-shadow 220ms ease",
].join(", "),
willChange: "height, transform, opacity",
}}
/>
);
})}
</Flex>
</Box>
</Flex>
);
}

View File

@@ -1,13 +1,7 @@
import { AttachmentType } from "./providers/ProtocolProvider/protocol/packets/packet.message"; import { AttachmentType } from "./providers/ProtocolProvider/protocol/packets/packet.message";
export const CORE_VERSION = window.version || "1.0.0";
/** /**
* Application directives * Application directives
*/ */
export const APPLICATION_PLATFROM = window.platform || "unknown";
export const APPLICATION_ARCH = window.arch || "unknown";
export const APP_PATH = window.appPath || ".";
export const SIZE_LOGIN_WIDTH_PX = 300; export const SIZE_LOGIN_WIDTH_PX = 300;
export const DEVTOOLS_CHEATCODE = "rosettadev1"; export const DEVTOOLS_CHEATCODE = "rosettadev1";
export const AVATAR_PASSWORD_TO_ENCODE = "rosetta-a"; export const AVATAR_PASSWORD_TO_ENCODE = "rosetta-a";
@@ -62,5 +56,8 @@ export const ALLOWED_DOMAINS_ZONES = [
'gg', 'gg',
'fm', 'fm',
'tv', 'tv',
'im' 'im',
'sc',
'su',
'by'
]; ];

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

@@ -6,6 +6,9 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script>
window.global = window;
</script>
<script type="module" src="/renderer.tsx"></script> <script type="module" src="/renderer.tsx"></script>
</body> </body>
</html> </html>

62
app/hooks/useCore.ts Normal file
View File

@@ -0,0 +1,62 @@
export function useCore() {
const openExternal = (url: string) => {
window.shell.openExternal(url);
};
const showItemInFolder = (fullPath: string) => {
window.shell.showItemInFolder(fullPath);
};
const getCoreVersion = async () => {
const version = await window.electron.ipcRenderer.invoke('ipcCore:getCoreVersion');
return version;
}
const getArch = async () => {
const arch = await window.electron.ipcRenderer.invoke('ipcCore:getArch');
return arch;
}
const getUserDir = async () => {
const userDir = await window.electron.ipcRenderer.invoke('ipcCore:getUserDir');
return userDir;
}
const getAppPath = async () => {
const appPath = await window.electron.ipcRenderer.invoke('ipcCore:getAppPath');
return appPath;
}
const getDownloadsPath = async () => {
const downloadsPath = await window.electron.ipcRenderer.invoke('ipcCore:getDownloadsPath');
return downloadsPath;
}
const getPlatform = async () => {
const platform = await window.electron.ipcRenderer.invoke('ipcCore:getPlatform');
return platform;
}
const getDeviceName = async () => {
const deviceName = await window.electron.ipcRenderer.invoke('device:name');
return deviceName;
}
const getDeviceId = async () => {
const deviceId = await window.electron.ipcRenderer.invoke('device:id');
return deviceId;
}
return {
openExternal,
showItemInFolder,
getCoreVersion,
getArch,
getUserDir,
getAppPath,
getDownloadsPath,
getPlatform,
getDeviceName,
getDeviceId
}
}

View File

78
app/hooks/useSound.ts Normal file
View File

@@ -0,0 +1,78 @@
import { useRef } from "react";
export function useSound() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const loopingAudioRef = useRef<HTMLAudioElement | null>(null);
const stopSound = () => {
if (!audioRef.current) {
return;
}
audioRef.current.pause();
audioRef.current.currentTime = 0;
audioRef.current.removeAttribute("src");
audioRef.current.load();
};
const playSound = async (sound : string, loop: boolean = false) => {
try {
if(loop){
if (!loopingAudioRef.current) {
loopingAudioRef.current = new Audio();
loopingAudioRef.current.volume = 0.1;
loopingAudioRef.current.preload = "auto";
loopingAudioRef.current.loop = true;
}
const url = await window.mediaApi.getSoundUrl(sound);
const player = loopingAudioRef.current;
player.src = url;
const playPromise = player.play();
if (playPromise) {
void playPromise.catch((e) => {
console.error("Failed to play looping UI sound:", e);
});
}
return;
}
if (!audioRef.current) {
audioRef.current = new Audio();
audioRef.current.volume = 0.1;
audioRef.current.preload = "auto";
audioRef.current.loop = loop;
}
const url = await window.mediaApi.getSoundUrl(sound);
const player = audioRef.current;
stopSound();
player.src = url;
const playPromise = player.play();
if (playPromise) {
void playPromise.catch((e) => {
console.error("Failed to play UI sound:", e);
});
}
} catch (e) {
console.error("Failed to prepare UI sound:", e);
}
}
const stopLoopSound = () => {
if (!loopingAudioRef.current) {
return;
}
loopingAudioRef.current.pause();
loopingAudioRef.current.currentTime = 0;
loopingAudioRef.current.removeAttribute("src");
loopingAudioRef.current.load();
}
return {
playSound,
stopSound,
stopLoopSound
}
}

View File

@@ -20,10 +20,19 @@ const useWindow = () => {
window.api.send('window-theme', theme); window.api.send('window-theme', theme);
} }
const setWindowPriority = (isTop: boolean) => {
if(isTop){
window.api.invoke('window-top');
} else {
window.api.invoke('window-priority-normal');
}
}
return { return {
setSize, setSize,
setResizeble, setResizeble,
setTheme setTheme,
setWindowPriority
} }
} }

View File

@@ -1,19 +1,21 @@
import { useContext, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDownloadStatus } from "../TransportProvider/useDownloadStatus"; import { useDownloadStatus } from "../TransportProvider/useDownloadStatus";
import { useUploadStatus } from "../TransportProvider/useUploadStatus"; import { useUploadStatus } from "../TransportProvider/useUploadStatus";
import { useFileStorage } from "../../hooks/useFileStorage"; import { useFileStorage } from "../../hooks/useFileStorage";
import { usePublicKey } from "../AccountProvider/usePublicKey"; import { usePublicKey } from "../AccountProvider/usePublicKey";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain"; 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 { useTransport } from "../TransportProvider/useTransport";
import { useDialogsCache } from "../DialogProvider/useDialogsCache"; import { useDialogsCache } from "../DialogProvider/useDialogsCache";
import { useConsoleLogger } from "../../hooks/useConsoleLogger"; import { useConsoleLogger } from "../../hooks/useConsoleLogger";
import { Attachment, AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message"; import { Attachment, AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
import { useMemory } from "../MemoryProvider/useMemory"; import { useMemory } from "../MemoryProvider/useMemory";
import { DialogContext } from "../DialogProvider/DialogProvider";
import { useSaveAvatar } from "../AvatarProvider/useSaveAvatar"; import { useSaveAvatar } from "../AvatarProvider/useSaveAvatar";
import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants"; import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants";
import { useDialog } from "../DialogProvider/useDialog"; import { useDialog } from "../DialogProvider/useDialog";
import { useCore } from "@/app/hooks/useCore";
import { MessageProps } from "@/app/components/Messages/Message";
import { useGroups } from "../DialogProvider/useGroups";
export enum DownloadStatus { export enum DownloadStatus {
DOWNLOADED, DOWNLOADED,
@@ -24,12 +26,11 @@ export enum DownloadStatus {
ERROR ERROR
} }
export function useAttachment(attachment: Attachment, keyPlain: string) { export function useAttachment(attachment: Attachment, parentMessage: MessageProps) {
const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
const uploadedPercentage = useUploadStatus(attachment.id); const uploadedPercentage = useUploadStatus(attachment.id);
const downloadPercentage = useDownloadStatus(attachment.id); const downloadPercentage = useDownloadStatus(attachment.id);
const [downloadStatus, setDownloadStatus] = useMemory("attachment-downloaded-status-" + attachment.id, DownloadStatus.PENDING, true); const [downloadStatus, setDownloadStatus] = useMemory("attachment-downloaded-status-" + attachment.id, DownloadStatus.PENDING, true);
const [downloadTag, setDownloadTag] = useState(""); const [downloadTag, setDownloadTag] = useState(attachment.transport.transport_tag || "");
const {readFile, writeFile, fileExists, size} = useFileStorage(); const {readFile, writeFile, fileExists, size} = useFileStorage();
const { downloadFile } = useTransport(); const { downloadFile } = useTransport();
const publicKey = usePublicKey(); const publicKey = usePublicKey();
@@ -37,13 +38,10 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
const {updateAttachmentInDialogCache} = useDialogsCache(); const {updateAttachmentInDialogCache} = useDialogsCache();
const {info} = useConsoleLogger('useAttachment'); const {info} = useConsoleLogger('useAttachment');
const {updateAttachmentsInMessagesByAttachmentId} = useDialog(); const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
const {getDownloadsPath} = useCore();
const {hasGroup} = useGroups();
const {dialog} = useDialog();
const context = useContext(DialogContext);
if(!context) {
throw new Error("useAttachment must be used within a DialogProvider");
}
const {dialog} = context;
const saveAvatar = useSaveAvatar(); const saveAvatar = useSaveAvatar();
useEffect(() => { useEffect(() => {
@@ -51,44 +49,38 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
}, []); }, []);
const getPreview = () => { const getPreview = () => {
if(attachment.preview.split("::")[0].match(uuidRegex)){
/**
* Это тег загрузки
*/
return attachment.preview.split("::").splice(1).join("::");
}
return attachment.preview; return attachment.preview;
} }
const calcDownloadStatus = async () => { const calcDownloadStatus = async () => {
if(attachment.preview.split("::")[0].match(uuidRegex)){ console.info("ds", attachment);
/** if (downloadStatus == DownloadStatus.DOWNLOADED) {
* Это тег загрузки
*/
setDownloadTag(attachment.preview.split("::")[0]);
}
if(!attachment.preview.split("::")[0].match(uuidRegex)){
/**
* Там не тег загрузки, значит это наш файл
*/
setDownloadStatus(DownloadStatus.DOWNLOADED);
return; return;
} }
if (downloadStatus == DownloadStatus.DOWNLOADED) { if(attachment.transport.transport_tag == ""){
/**
* Транспортного тега нет только у сообщений отправленных нами, значит он точно наш
*/
setDownloadStatus(DownloadStatus.DOWNLOADED);
return; return;
} }
if(attachment.type == AttachmentType.FILE){ if(attachment.type == AttachmentType.FILE){
/** /**
* Если это файл, то он хранится не в папке медиа, * Если это файл, то он хранится не в папке медиа,
* а в загрузках * а в загрузках, статус скачивания определяем не только по названию файла,
* но и по его размеру (если размеры и название совпало, то считаем файл скаченным)
*/ */
const preview = getPreview(); const preview = getPreview();
const filesize = parseInt(preview.split("::")[0]); const filesize = parseInt(preview.split("::")[0]);
const filename = preview.split("::")[1]; const filename = preview.split("::")[1];
let pathInDownloads = window.downloadsPath + "/Rosetta Downloads/" + filename; const downloadsPath = await getDownloadsPath();
let pathInDownloads = downloadsPath + "/Rosetta Downloads/" + filename;
const exists = await fileExists(pathInDownloads, false); const exists = await fileExists(pathInDownloads, false);
const existsLength = await size(pathInDownloads, false); const existsLength = await size(pathInDownloads, false);
if(exists && existsLength == filesize){ if(exists && existsLength == filesize){
/**
* Если название файла и его размер совпадают (и он существует), то считаем его скаченным
*/
setDownloadStatus(DownloadStatus.DOWNLOADED); setDownloadStatus(DownloadStatus.DOWNLOADED);
return; return;
} }
@@ -143,7 +135,7 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
let downloadedBlob = ''; let downloadedBlob = '';
try { try {
downloadedBlob = await downloadFile(attachment.id, downloadedBlob = await downloadFile(attachment.id,
downloadTag); downloadTag, attachment.transport.transport_server);
} catch (e) { } catch (e) {
console.info(e); console.info(e);
info("Error downloading attachment: " + attachment.id); info("Error downloading attachment: " + attachment.id);
@@ -151,8 +143,9 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
return; return;
} }
setDownloadStatus(DownloadStatus.DECRYPTING); setDownloadStatus(DownloadStatus.DECRYPTING);
console.info("decoding with key " + parentMessage.chacha_key_plain);
//console.info("Decrypted attachment ", Buffer.from(keyPlain, 'binary').toString('hex')); //console.info("Decrypted attachment ", Buffer.from(keyPlain, 'binary').toString('hex'));
const decrypted = await decodeWithPassword(keyPlain, downloadedBlob); const decrypted = await decodeWithPassword(parentMessage.chacha_key_plain, downloadedBlob);
setDownloadTag(""); setDownloadTag("");
if(attachment.type == AttachmentType.FILE) { if(attachment.type == AttachmentType.FILE) {
/** /**
@@ -161,8 +154,9 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
*/ */
const preview = getPreview(); const preview = getPreview();
const filename = preview.split("::")[1]; const filename = preview.split("::")[1];
const downloadsPath = await getDownloadsPath();
let buffer = Buffer.from(decrypted.split(",")[1], 'base64'); let buffer = Buffer.from(decrypted.split(",")[1], 'base64');
let pathInDownloads = window.downloadsPath + "/Rosetta Downloads/" + filename; let pathInDownloads = downloadsPath + "/Rosetta Downloads/" + filename;
/** /**
* Пишем файл в загрузки, но перед этим выбираем ему название, если файл в загрузках * Пишем файл в загрузки, но перед этим выбираем ему название, если файл в загрузках
* уже есть с таким названием то добавляем к названию (1), (2) и так далее, чтобы не перезаписать существующий файл * уже есть с таким названием то добавляем к названию (1), (2) и так далее, чтобы не перезаписать существующий файл
@@ -170,7 +164,7 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
let finalPath = pathInDownloads; let finalPath = pathInDownloads;
let fileIndex = 1; let fileIndex = 1;
while (await fileExists(finalPath, false)) { while (await fileExists(finalPath, false)) {
finalPath = window.downloadsPath + "/Rosetta Downloads/" + filename.split(".").slice(0, -1).join(".") + ` (${fileIndex})` + "." + filename.split(".").slice(-1); finalPath = downloadsPath + "/Rosetta Downloads/" + filename.split(".").slice(0, -1).join(".") + ` (${fileIndex})` + "." + filename.split(".").slice(-1);
fileIndex++; fileIndex++;
} }
await writeFile(finalPath, buffer, false); await writeFile(finalPath, buffer, false);
@@ -185,7 +179,14 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
await writeFile(avatarPath, await writeFile(avatarPath,
Buffer.from(await encodeWithPassword(AVATAR_PASSWORD_TO_ENCODE, decrypted))); Buffer.from(await encodeWithPassword(AVATAR_PASSWORD_TO_ENCODE, decrypted)));
setDownloadStatus(DownloadStatus.DOWNLOADED); setDownloadStatus(DownloadStatus.DOWNLOADED);
saveAvatar(dialog, avatarPath, decrypted); /**
* Устанавливаем аватарку тому, кто ее прислал.
*/
let avatarSetTo = parentMessage.from;
if(hasGroup(dialog)){
avatarSetTo = dialog;
}
saveAvatar(avatarSetTo, avatarPath, decrypted);
return; return;
} }
/** /**

View File

@@ -0,0 +1,226 @@
import { encodeWithPassword } from "@/app/workers/crypto/crypto";
import { MessageReply } from "../DialogProvider/useReplyMessages";
import { Attachment, AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
import { base64ImageToBlurhash } from "@/app/workers/image/image";
import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "@/app/constants";
import { useContext, useRef } from "react";
import { useTransport } from "../TransportProvider/useTransport";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useDatabase } from "../DatabaseProvider/useDatabase";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
import { AttachmentMeta, DialogContext } from "../DialogProvider/DialogProvider";
import { useTransportServer } from "../TransportProvider/useTransportServer";
export function usePrepareAttachment() {
const intervalsRef = useRef<NodeJS.Timeout>(null);
const {uploadFile} = useTransport();
const {updateDialog} = useDialogsList();
const {runQuery, getQuery} = useDatabase();
const {info} = useConsoleLogger('usePrepareAttachment');
const {getDialogCache} = useDialogsCache();
const context = useContext(DialogContext);
const transportServer = useTransportServer();
const updateTimestampInDialogCache = (dialog : string, message_id: string) => {
const dialogCache = getDialogCache(dialog);
if(dialogCache == null){
return;
}
for(let i = 0; i < dialogCache.length; i++){
if(dialogCache[i].message_id == message_id){
dialogCache[i].timestamp = Date.now();
break;
}
}
}
/**
* Обновляет транспортный сервер в кэше, чтобы поддерживать его в актуальном состоянии после загрузки
*/
const updateAttachmentTransportInCache = (dialog: string, message_id : string, attachment: Attachment) => {
const dialogCache = getDialogCache(dialog);
if(dialogCache == null){
return;
}
for(let i = 0; i < dialogCache.length; i++){
if(dialogCache[i].message_id == message_id){
for(let j = 0; j < dialogCache[i].attachments.length; j++){
if(dialogCache[i].attachments[j].id == attachment.id){
dialogCache[i].attachments[j].transport = attachment.transport;
}
}
}
}
}
/**
* Обновляет транспорт в базе после загрузки вложения (нам нужно сохранить транспорт)
*/
const updateAttachmentTransportInDatabase = async (message_id : string, attachment: Attachment) => {
let message = await getQuery(`SELECT attachments FROM messages WHERE message_id = ?`, [message_id]);
console.info(message)
if(!message){
return;
}
if(message.attachments == '[]'){
return;
}
let meta : AttachmentMeta[] = JSON.parse(message.attachments);
for(let i = 0; i < meta.length; i++){
if(meta[i].id == attachment.id){
meta[i].transport = attachment.transport;
}
}
await runQuery(`UPDATE messages SET attachments = ? WHERE message_id = ?`, [JSON.stringify(meta), message_id]);
}
/**
* Обновляет вложение в стейте сообщений
*/
const updateAttachmentTransportInContext = (message_id: string, attachment : Attachment) => {
if(context == null || !context){
/**
* Если этот диалог сейчас не открыт
*/
return;
}
context.setMessages((prev) => {
return prev.map((value) => {
if(value.message_id != message_id){
return value;
}
for(let i = 0; i < value.attachments.length; i++){
if(value.attachments[i].id != attachment.id){
return value;
}
value.attachments[i].transport = attachment.transport;
return value;
}
return value;
})
});
}
const updateTransportAfterUploading = async (dialog: string, message_id : string, attachment: Attachment) => {
updateAttachmentTransportInCache(dialog, message_id, attachment);
updateAttachmentTransportInDatabase(message_id, attachment);
updateAttachmentTransportInContext(message_id, attachment);
}
/**
* Обновляет временную метку в сообщении, пока вложения отправляются,
* потому что если этого не делать, то сообщение может быть помечено как
* не доставленное из-за таймаута доставки
* @param attachments Вложения
*/
const doTimestampUpdateImMessageWhileAttachmentsSend = (message_id: string, dialog: string) => {
if(intervalsRef.current){
clearInterval(intervalsRef.current);
}
intervalsRef.current = setInterval(async () => {
/**
* Обновляем время в левом меню
*/
await runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), message_id]);
updateDialog(dialog);
/**
* Обновляем состояние в кэше диалогов
*/
updateTimestampInDialogCache(dialog, message_id);
if(context == null || !context){
/**
* Если этот диалог сейчас не открыт
*/
return;
}
context.setMessages((prev) => {
return prev.map((value) => {
if(value.message_id != message_id){
return value;
}
return {
...value,
timestamp: Date.now()
};
})
});
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
}
/**
* Подготавливает вложения для отправки. Подготовка
* состоит в загрузке файлов на транспортный сервер, мы не делаем
* это через WebSocket из-за ограничений по размеру сообщений,
* а так же из-за надежности доставки файлов через HTTP
* @param attachments Attachments to prepare for sending
*/
const prepareAttachmentsToSend = async (message_id: string, dialog: string, password: string, attachments : Attachment[]) : Promise<Attachment[]> => {
if(attachments.length <= 0){
return [];
}
let prepared : Attachment[] = [];
try{
for(let i = 0; i < attachments.length; i++){
const attachment : Attachment = attachments[i];
if(attachment.type == AttachmentType.CALL){
/**
* Звонк загружать не надо, по этому просто отправляем его как есть, там нет blob
*/
prepared.push(attachment);
continue;
}
if(attachment.type == AttachmentType.MESSAGES){
let reply : MessageReply[] = JSON.parse(attachment.blob);
for(let j = 0; j < reply.length; j++){
for(let k = 0; k < reply[j].attachments.length; k++){
reply[j].attachments[k].blob = "";
}
}
prepared.push({
...attachment,
blob: await encodeWithPassword(password, JSON.stringify(reply))
});
continue;
}
if((attachment.type == AttachmentType.IMAGE
|| attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
/**
* Загружаем превью blurhash для изображения
*/
const blurhash = await base64ImageToBlurhash(attachment.blob);
attachment.preview = blurhash;
}
doTimestampUpdateImMessageWhileAttachmentsSend(message_id, dialog);
const content = await encodeWithPassword(password, attachment.blob);
const upid = attachment.id;
info(`Uploading attachment with upid: ${upid}`);
info(`Attachment content length: ${content.length}`);
let tag = await uploadFile(upid, content);
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
}
const preparedAttachment : Attachment = {
...attachment,
transport: {
transport_server: transportServer || "",
transport_tag: tag
},
preview: attachment.preview,
blob: ""
};
await updateTransportAfterUploading(dialog, message_id, preparedAttachment);
prepared.push(preparedAttachment);
}
return prepared;
}catch(e){
return prepared;
}
}
return {
prepareAttachmentsToSend
}
}

View File

@@ -2,12 +2,20 @@ import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { createContext, useEffect, useRef, useState } from "react"; import { createContext, useEffect, useRef, useState } from "react";
import { usePublicKey } from "../AccountProvider/usePublicKey"; import { usePublicKey } from "../AccountProvider/usePublicKey";
import { useFileStorage } from "@/app/hooks/useFileStorage"; 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 { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { useSystemAccounts } from "../SystemAccountsProvider/useSystemAccounts"; import { useSystemAccounts } from "../SystemAccountsProvider/useSystemAccounts";
import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants"; import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants";
export const AvatarContext = createContext({}); export interface AvatarProviderContextValue {
deliveredAvatars: string[];
saveAvatar: (fromPublicKey: string, path : string, decryptedContent : string) => Promise<void>;
loadAvatarsFromCacheByPublicKey: (publicKey : string, allDecode? : boolean) => Promise<void>;
changeAvatar: (base64Image : string, entity : string) => Promise<void>;
decodedAvatarsCache: AvatarCacheEntry[];
}
export const AvatarContext = createContext<AvatarProviderContextValue | null>(null);
interface AvatarProviderProps { interface AvatarProviderProps {
children: React.ReactNode; children: React.ReactNode;

View File

@@ -1,7 +1,7 @@
import { useContext } from "react"; import { useContext } from "react";
import { AvatarContext } from "./AvatarProvider"; import { AvatarContext } from "./AvatarProvider";
export function useSaveAvatar() { export function useSaveAvatar() : (fromPublicKey: string, path : string, decryptedContent : string) => Promise<void> {
const context : any = useContext(AvatarContext); const context : any = useContext(AvatarContext);
if(!context){ if(!context){
throw new Error("useSaveAvatar must be used within an AvatarProvider"); throw new Error("useSaveAvatar must be used within an AvatarProvider");

View File

@@ -0,0 +1,623 @@
import { Call } from "@/app/components/Call/Call";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { createContext, useEffect, useRef, useState } from "react";
import nacl from 'tweetnacl';
import { useSender } from "../ProtocolProvider/useSender";
import { PacketSignalPeer, SignalType } from "../ProtocolProvider/protocol/packets/packet.signal.peer";
import { usePacket } from "../ProtocolProvider/usePacket";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { PacketWebRTC, WebRTCSignalType } from "../ProtocolProvider/protocol/packets/packet.webrtc";
import { PacketIceServers } from "../ProtocolProvider/protocol/packets/packet.ice.servers";
import { modals } from "@mantine/modals";
import { Button, Flex, Text } from "@mantine/core";
import { useSound } from "@/app/hooks/useSound";
import useWindow from "@/app/hooks/useWindow";
import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE";
import { useDeattachedSender } from "../DialogProvider/useDeattachedSender";
import { AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
import { generateRandomKey } from "@/app/utils/utils";
export interface CallContextValue {
call: (callable: string) => void;
close: () => void;
activeCall: string;
callState: CallState;
muted: boolean;
sound: boolean;
setMuted: (muted: boolean) => void;
setSound: (sound: boolean) => void;
duration: number;
setShowCallView: (show: boolean) => void;
getKeyCast: () => string;
accept: () => void;
}
export enum CallState {
CONNECTING,
KEY_EXCHANGE,
/**
* Финальная стадия сигналинга, на которой обе стороны обменялись ключами и теперь устанавливают защищенный канал связи для звонка,
* через WebRTC, и готовятся к активному звонку.
*/
WEB_RTC_EXCHANGE,
ACTIVE,
ENDED,
INCOMING
}
export enum CallRole {
/**
* Вызывающая сторона, которая инициирует звонок
*/
CALLER,
/**
* Принимающая сторона, которая отвечает на звонок и принимает его
*/
CALLEE
}
export const CallContext = createContext<CallContextValue | null>(null);
export interface CallProviderProps {
children: React.ReactNode;
}
export function CallProvider(props : CallProviderProps) {
const [activeCall, setActiveCall] = useState<string>("");
const [callState, setCallState] = useState<CallState>(CallState.ENDED);
const [muted, setMutedState] = useState<boolean>(false);
const [sound, setSoundState] = useState<boolean>(true);
const durationIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [duration, setDuration] = useState<number>(0);
const [showCallView, setShowCallView] = useState<boolean>(callState == CallState.INCOMING);
const {info} = useConsoleLogger("CallProvider");
const [sessionKeys, setSessionKeys] = useState<nacl.BoxKeyPair | null>(null);
const send = useSender();
const publicKey = usePublicKey();
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const roomIdRef = useRef<string>("");
const roleRef = useRef<CallRole | null>(null);
const sharedSecretRef = useRef<string>("");
const iceServersRef = useRef<RTCIceServer[]>([]);
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]);
const mutedRef = useRef<boolean>(false);
const soundRef = useRef<boolean>(true);
const {sendMessage} = useDeattachedSender();
const hasRemoteTrackRef = useRef<boolean>(false);
/**
* Используются для входа в звонок
*/
const callSessionIdRef = useRef<string>("");
const callTokenRef = useRef<string>("");
const {playSound, stopSound, stopLoopSound} = useSound();
const {setWindowPriority} = useWindow();
useEffect(() => {
if(callState == CallState.ACTIVE){
stopLoopSound();
stopSound();
playSound("connected.mp3");
setWindowPriority(false);
durationIntervalRef.current = setInterval(() => {
setDuration(prev => prev + 1);
}, 1000);
}
}, [callState]);
useEffect(() => {
/**
* Нам нужно получить ICE серверы для установки соединения из разных сетей
* Получаем их от сервера
*/
let packet = new PacketIceServers();
send(packet);
return () => {
stopSound();
if (remoteAudioRef.current) {
remoteAudioRef.current.pause();
remoteAudioRef.current.srcObject = null;
}
peerConnectionRef.current?.close();
peerConnectionRef.current = null;
};
}, []);
usePacket(28, async (packet: PacketIceServers) => {
let iceServers = packet.getIceServers();
/**
* ICE серверы получены, теперь нужно привести их к форматку клиента и добавить udp и tcp варианты
*/
let formattedIceServers: RTCIceServer[] = [];
for(let i = 0; i < iceServers.length; i++){
let server = iceServers[i];
formattedIceServers.push({
urls: "turn:" + server.url + "?transport=" + server.transport,
username: server.username,
credential: server.credential
});
}
iceServersRef.current = formattedIceServers;
info("Received ICE servers from server, count: " + formattedIceServers.length);
}, []);
usePacket(27, async (packet: PacketWebRTC) => {
if(callState != CallState.WEB_RTC_EXCHANGE && callState != CallState.ACTIVE){
/**
* Нет активного звонка или мы не на стадии обмена WebRTC сигналами, игнорируем
*/
return;
}
const signalType = packet.getSignalType();
if(signalType == WebRTCSignalType.ANSWER){
/**
* Другая сторона (сервер SFU) отправил нам SDP ответ на наш оффер
*/
const sdp = JSON.parse(packet.getSdpOrCandidate());
await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp));
if(iceCandidatesBufferRef.current.length > 0){
/**
* У нас есть буферизированные ICE кандидаты, которые мы получили до установки удаленного описания, теперь мы можем их добавить в PeerConnection
*/
for(let i = 0; i < iceCandidatesBufferRef.current.length; i++){
await peerConnectionRef.current?.addIceCandidate(iceCandidatesBufferRef.current[i]);
}
iceCandidatesBufferRef.current = [];
}
info("Received WebRTC answer and set remote description");
return;
}
if(signalType == WebRTCSignalType.ICE_CANDIDATE){
/**
* Другая сторона отправила нам ICE кандидата для установления WebRTC соединения
*/
const candidate = JSON.parse(packet.getSdpOrCandidate());
if(peerConnectionRef.current?.remoteDescription == null){
/**
* Удаленное описание еще не установлено, буферизуем кандидата, чтобы добавить его после установки удаленного описания
*/
iceCandidatesBufferRef.current.push(new RTCIceCandidate(candidate));
info("Received WebRTC ICE candidate but remote description is not set yet, buffering candidate");
return;
}
await peerConnectionRef.current?.addIceCandidate(new RTCIceCandidate(candidate));
info("Received WebRTC ICE candidate and added to peer connection");
return;
}
if(signalType == WebRTCSignalType.OFFER && peerConnectionRef.current){
/**
* SFU сервер отправил нам оффер, например при renegotiation, нам нужно его принять и
* отправить ответ (ANSWER)
*/
const sdp = JSON.parse(packet.getSdpOrCandidate());
await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp));
let answer = await peerConnectionRef.current?.createAnswer();
await peerConnectionRef.current?.setLocalDescription(answer);
let answerSignal = new PacketWebRTC();
answerSignal.setSignalType(WebRTCSignalType.ANSWER);
answerSignal.setSdpOrCandidate(JSON.stringify(answer));
send(answerSignal);
info("Received WebRTC offer, set remote description and sent answer");
return;
}
}, [activeCall, sessionKeys, callState, roomIdRef]);
usePacket(26, async (packet: PacketSignalPeer) => {
const signalType = packet.getSignalType();
if(signalType == SignalType.END_CALL_BECAUSE_BUSY) {
openCallsModal("Line is busy, the user is currently on another call. Please try again later.");
end();
}
if(signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED) {
openCallsModal("The connection with the user was lost. The call has ended.")
end();
}
if(signalType == SignalType.RINGING_TIMEOUT) {
/**
* Другой стороне был отправлен сигнал звонка, но она не ответила на него в течении определенного времени
*/
openCallsModal("The user did not answer the call in time. Please try again later.");
end();
return;
}
if(signalType == SignalType.END_CALL){
/**
* Сбросили звонок
*/
end();
return;
}
if(signalType == SignalType.CALL){
/**
* Нам поступает звонок
*/
if(callState != CallState.ENDED){
/**
* У нас уже есть активный звонок, отправляем сигнал другой стороне, что линия занята
*/
return;
}
callSessionIdRef.current = packet.getCallId();
callTokenRef.current = packet.getJoinToken();
setWindowPriority(true);
playSound("ringtone.mp3", true);
setActiveCall(packet.getSrc());
setCallState(CallState.INCOMING);
setShowCallView(true);
}
if(signalType == SignalType.KEY_EXCHANGE){
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
/**
* Другая сторона отправила нам ключи, теперь отправляем ей свои для генерации общего секрета
*/
const sharedPublic = packet.getSharedPublic();
if(!sharedPublic){
info("Received key exchange signal without shared public key");
return;
}
if(!sessionKeys){
info("Received key exchange signal but session keys are not generated");
return;
}
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
info("Generated shared secret for call session: " + sharedSecretRef.current);
setCallState(CallState.WEB_RTC_EXCHANGE);
if(roleRef.current == CallRole.CALLER){
/**
* Вызывающий уже отправил ключ, сессия сгенерирована, сообщаем серверу что звонок активен
*/
const activeSignal = new PacketSignalPeer();
activeSignal.setSrc(publicKey);
activeSignal.setDst(activeCall);
activeSignal.setSignalType(SignalType.ACTIVE);
send(activeSignal);
return;
}
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(activeCall);
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
send(signalPacket);
}
if(signalType == SignalType.ACCEPT){
/**
* Другая сторона приняла наш звонок, комната на SFU создалась, нужно сгенерировать ключи
*/
const keys = generateSessionKeys();
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(activeCall);
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex'));
send(signalPacket);
}
if(signalType == SignalType.ACTIVE) {
if(!sessionKeys){
/**
* Сервер может отправить CREATE_ROOM сигнал, даже если мы приняли звонок на другом устройстве, по этому проверяем,
* на этом ли устройстве звонок принят посредством проверки наличия сгенерированных ключей шифрования
*/
stopLoopSound();
stopSound();
end();
return;
}
/**
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
*/
peerConnectionRef.current = new RTCPeerConnection({
iceServers: iceServersRef.current,
// @ts-ignore
encodedInsertableStreams: true
});
/**
* Подписываемся на ICE кандидат
*/
peerConnectionRef.current.onicecandidate = (event) => {
if(event.candidate){
let candidateSignal = new PacketWebRTC();
candidateSignal.setSignalType(WebRTCSignalType.ICE_CANDIDATE);
candidateSignal.setSdpOrCandidate(JSON.stringify(event.candidate));
send(candidateSignal);
}
}
/**
* Соединение установлено, можно начинать звонок, переходим в активное состояние звонка
*/
peerConnectionRef.current.onconnectionstatechange = () => {
console.info("Peer connection state changed: " + peerConnectionRef.current?.connectionState);
if(peerConnectionRef.current?.connectionState == "connected"){
/**
* WebRTC соединение установлено, звонок активен, останавливаем все остальные звуки
* системы
*/
tryActivateCall();
info("WebRTC connection established, call is active");
}
}
peerConnectionRef.current.ontrack = async (event) => {
try {
await attachReceiverE2EE(event.receiver, Buffer.from(sharedSecretRef.current, "hex"));
} catch (e) {
console.error("attachReceiverE2EE failed:", e);
}
/**
* При получении медиа-трека с другой стороны
*/
if(remoteAudioRef.current && event.streams[0]){
hasRemoteTrackRef.current = true;
tryActivateCall();
remoteAudioRef.current.srcObject = event.streams[0];
remoteAudioRef.current.muted = !soundRef.current;
void remoteAudioRef.current.play().catch((e) => {
console.error("Failed to play remote audio:", e);
});
}
}
/**
* Запрашиваем Аудио поток с микрофона и добавляем его в PeerConnection, чтобы другая сторона могла его получить и воспроизвести,
* когда мы установим WebRTC соединение
*/
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioTrack = localStream.getAudioTracks()[0];
const tx = peerConnectionRef.current.addTransceiver(audioTrack, {
direction: "sendrecv",
streams: [localStream]
});
await attachSenderE2EE(tx.sender, Buffer.from(sharedSecretRef.current, "hex"));
/**
* Отправляем свой оффер другой стороне
*/
let offer = await peerConnectionRef.current.createOffer();
await peerConnectionRef.current.setLocalDescription(offer);
let offerSignal = new PacketWebRTC();
offerSignal.setSignalType(WebRTCSignalType.OFFER);
offerSignal.setSdpOrCandidate(JSON.stringify(offer));
send(offerSignal);
return;
}
}, [activeCall, sessionKeys, duration]);
const tryActivateCall = () => {
if(hasRemoteTrackRef.current && peerConnectionRef.current?.connectionState == "connected"){
stopLoopSound();
stopSound();
setCallState(CallState.ACTIVE);
info("Call is now active");
}
}
const openCallsModal = (text : string) => {
modals.open({
centered: true,
children: (
<>
<Text size="sm">
{text}
</Text>
<Flex align={'center'} justify={'flex-end'}>
<Button style={{
outline: 'none'
}} color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
Close
</Button>
</Flex>
</>
),
withCloseButton: false
});
}
const generateSessionKeys = () => {
const sessionKeys = nacl.box.keyPair();
info("Generated keys for call session, len: " + sessionKeys.publicKey.length);
setSessionKeys(sessionKeys);
return sessionKeys;
}
const call = (dialog: string) => {
if(callState == CallState.ACTIVE
|| callState == CallState.CONNECTING
|| callState == CallState.KEY_EXCHANGE
|| callState == CallState.WEB_RTC_EXCHANGE){
openCallsModal("You are already on a call, please end the current call before starting a new one.");
return;
}
setWindowPriority(false);
setActiveCall(dialog);
setCallState(CallState.CONNECTING);
setShowCallView(true);
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(dialog);
signalPacket.setSignalType(SignalType.CALL);
send(signalPacket);
roleRef.current = CallRole.CALLER;
playSound("calling.mp3", true);
}
const close = () => {
const packetSignal = new PacketSignalPeer();
packetSignal.setSrc(publicKey);
packetSignal.setDst(activeCall);
packetSignal.setCallId(callSessionIdRef.current);
packetSignal.setJoinToken(callTokenRef.current);
packetSignal.setSignalType(SignalType.END_CALL);
send(packetSignal);
end();
}
const end = () => {
if(callState == CallState.ACTIVE){
/**
* Только если звонок был активен воспроизводим звуки
*/
playSound("end_call.mp3");
}
if (remoteAudioRef.current) {
remoteAudioRef.current.pause();
remoteAudioRef.current.srcObject = null;
}
generateCallAttachment();
setDuration(0);
durationIntervalRef.current && clearInterval(durationIntervalRef.current);
setWindowPriority(false);
peerConnectionRef.current?.close();
peerConnectionRef.current = null;
roomIdRef.current = "";
mutedRef.current = false;
soundRef.current = true;
setActiveCall("");
setCallState(CallState.ENDED);
setShowCallView(false);
setSessionKeys(null);
setDuration(0);
setMutedState(false);
setSoundState(true);
stopLoopSound();
stopSound();
roleRef.current = null;
}
/**
* Отправляет сообщение в диалог с звонящим с информацией о звонке
*/
const generateCallAttachment = () => {
if(roleRef.current != CallRole.CALLER){
/**
* Только звонящий отправляет информацию о звонке в виде вложения, чтобы ее можно было отобразить в UI диалога, например длительность звонка
*/
return;
}
sendMessage(activeCall, "", [{
id: generateRandomKey(16),
preview: duration.toString(),
type: AttachmentType.CALL,
transport: {
transport_server: "",
transport_tag: ""
},
blob: ""
}], true);
}
const accept = () => {
if(callState != CallState.INCOMING){
/**
* Нечего принимать
*/
return;
}
setWindowPriority(false);
stopLoopSound();
stopSound();
/**
* Звонок принят, генерируем свой ключ для будующего обмена
*/
generateSessionKeys();
/**
* Отправляем сигнал что звонок принят другой стороне, чтобы она могла начать обмен ключами и установку соединения
*/
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(activeCall);
signalPacket.setCallId(callSessionIdRef.current);
signalPacket.setJoinToken(callTokenRef.current);
signalPacket.setSignalType(SignalType.ACCEPT);
send(signalPacket);
/**
* Устанавливаем состояние звонка и стадию обмена ключами
*/
setCallState(CallState.KEY_EXCHANGE);
roleRef.current = CallRole.CALLEE;
}
/**
* Получает слепок ключа для отображения в UI
* чтобы не показывать настоящий ключ
* @returns
*/
const getKeyCast = () => {
if(!sharedSecretRef.current){
return "";
}
return sharedSecretRef.current;
}
const setMuted = (nextMuted: boolean) => {
if (mutedRef.current === nextMuted) {
return;
}
mutedRef.current = nextMuted;
playSound(nextMuted ? "micro_enable.mp3" : "micro_disable.mp3");
if(peerConnectionRef.current){
peerConnectionRef.current.getSenders().forEach(sender => {
if(sender.track?.kind == "audio"){
sender.track.enabled = !nextMuted;
}
});
}
setMutedState(nextMuted);
}
const setSound = (nextSound: boolean) => {
if (soundRef.current === nextSound) {
return;
}
soundRef.current = nextSound;
playSound(nextSound ? "sound_enable.mp3" : "sound_disable.mp3");
if(remoteAudioRef.current){
remoteAudioRef.current.muted = !nextSound;
if (nextSound) {
void remoteAudioRef.current.play().catch((e) => {
console.error("Failed to resume remote audio:", e);
});
}
}
setSoundState(nextSound);
}
const context = {
call,
close,
activeCall,
callState,
muted,
sound,
setMuted,
setSound,
duration,
setShowCallView,
getKeyCast,
accept
};
return (
<CallContext.Provider value={context}>
{props.children}
<audio ref={remoteAudioRef} autoPlay playsInline style={{ display: 'none' }} />
{showCallView && <Call context={context}></Call>}
</CallContext.Provider>
)
}

View File

@@ -0,0 +1,114 @@
import _sodium from "libsodium-wrappers-sumo";
type KeyInput = Buffer | Uint8Array;
const senderAttached = new WeakSet<RTCRtpSender>();
const receiverAttached = new WeakSet<RTCRtpReceiver>();
let sodiumReady = false;
let sodium: typeof _sodium;
export async function initE2EE(): Promise<void> {
if (sodiumReady) return;
await _sodium.ready;
sodium = _sodium;
sodiumReady = true;
}
function toUint8Array(input: KeyInput): Uint8Array {
const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
return new Uint8Array(u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength));
}
function fillNonceFromTimestamp(nonce: Uint8Array, tsRaw: unknown): void {
nonce.fill(0);
let ts = 0n;
if (typeof tsRaw === "bigint") ts = tsRaw;
else if (typeof tsRaw === "number" && Number.isFinite(tsRaw)) ts = BigInt(Math.floor(tsRaw));
nonce[0] = Number((ts >> 56n) & 0xffn);
nonce[1] = Number((ts >> 48n) & 0xffn);
nonce[2] = Number((ts >> 40n) & 0xffn);
nonce[3] = Number((ts >> 32n) & 0xffn);
nonce[4] = Number((ts >> 24n) & 0xffn);
nonce[5] = Number((ts >> 16n) & 0xffn);
nonce[6] = Number((ts >> 8n) & 0xffn);
nonce[7] = Number(ts & 0xffn);
}
function createFrameProcessor(key: Uint8Array) {
const nonceLen = sodium.crypto_stream_xchacha20_NONCEBYTES; // 24
const nonce = new Uint8Array(nonceLen);
return function processFrame(data: ArrayBuffer, timestamp: unknown): ArrayBuffer {
const input = new Uint8Array(data);
fillNonceFromTimestamp(nonce, timestamp);
const output = sodium.crypto_stream_xchacha20_xor(input, nonce, key);
return output.buffer.slice(output.byteOffset, output.byteOffset + output.byteLength) as ArrayBuffer;
};
}
function createTransform(processFrame: (data: ArrayBuffer, timestamp: unknown) => ArrayBuffer) {
return new TransformStream<any, any>({
transform(frame, controller) {
try {
frame.data = processFrame(frame.data, frame.timestamp);
} catch (e) {
console.error("[E2EE] frame error:", e);
}
controller.enqueue(frame);
}
});
}
export async function attachSenderE2EE(sender: RTCRtpSender, keyInput: KeyInput): Promise<void> {
if (senderAttached.has(sender)) return;
senderAttached.add(sender);
await initE2EE();
const key = toUint8Array(keyInput);
const keyLen = sodium.crypto_stream_xchacha20_KEYBYTES; // 32
if (key.byteLength < keyLen) {
throw new Error(`Key must be at least ${keyLen} bytes`);
}
const anySender = sender as any;
if (!anySender.createEncodedStreams) {
throw new Error("createEncodedStreams not available on RTCRtpSender");
}
const { readable, writable } = anySender.createEncodedStreams();
const processFrame = createFrameProcessor(key.slice(0, keyLen));
readable
.pipeThrough(createTransform(processFrame))
.pipeTo(writable)
.catch((e) => console.error("[E2EE] Sender pipeline failed:", e));
}
export async function attachReceiverE2EE(receiver: RTCRtpReceiver, keyInput: KeyInput): Promise<void> {
if (receiverAttached.has(receiver)) return;
receiverAttached.add(receiver);
await initE2EE();
const key = toUint8Array(keyInput);
const keyLen = sodium.crypto_stream_xchacha20_KEYBYTES; // 32
if (key.byteLength < keyLen) {
throw new Error(`Key must be at least ${keyLen} bytes`);
}
const anyReceiver = receiver as any;
if (!anyReceiver.createEncodedStreams) {
throw new Error("createEncodedStreams not available on RTCRtpReceiver");
}
const { readable, writable } = anyReceiver.createEncodedStreams();
const processFrame = createFrameProcessor(key.slice(0, keyLen));
readable
.pipeThrough(createTransform(processFrame))
.pipeTo(writable)
.catch((e) => console.error("[E2EE] Receiver pipeline failed:", e));
}

View File

@@ -0,0 +1,5 @@
export const translateDurationToTime = (duration: number) => {
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
}

View File

@@ -0,0 +1,15 @@
import { useContext } from "react";
import { CallContext, CallContextValue } from "./CallProvider";
/**
* Хук предоставляет функции для работы с звонками, такие как инициирование звонка, принятие звонка, завершение звонка и т.д.
* Он может использоваться в компонентах, связанных с звонками, для управления состоянием звонков и взаимодействия с сервером.
*/
export function useCalls() : CallContextValue {
const context = useContext(CallContext);
if (!context) {
throw new Error("useCalls must be used within a CallProvider");
}
return context;
}

View File

@@ -0,0 +1,33 @@
import { useCore } from "@/app/hooks/useCore";
import { useEffect, useState } from "react";
export function useCoreDevice() : {
deviceId: string;
deviceName: string;
platform: string;
} {
const { getDeviceId, getDeviceName, getPlatform } = useCore();
const [deviceId, setDeviceId] = useState<string>("");
const [deviceName, setDeviceName] = useState<string>("");
const [platform, setPlatform] = useState<string>("");
useEffect(() => {
fetchDeviceInfo();
}, []);
const fetchDeviceInfo = async () => {
const deviceId = await getDeviceId();
const deviceName = await getDeviceName();
const platform = await getPlatform();
setDeviceId(deviceId);
setDeviceName(deviceName);
setPlatform(platform);
console.info("Device info - ID:", deviceId, "Name:", deviceName);
}
return {
deviceId,
deviceName,
platform
}
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { DialogRow } from "./DialogListProvider"; import { DialogRow } from "./DialogListProvider";
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase"; import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { usePublicKey } from "../AccountProvider/usePublicKey"; 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 { constructLastMessageTextByAttachments } from "@/app/utils/constructLastMessageTextByAttachments";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain"; import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { DeliveredMessageState, Message } from "../DialogProvider/DialogProvider"; import { DeliveredMessageState, Message } from "../DialogProvider/DialogProvider";

View File

@@ -1,7 +1,7 @@
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from '@/app/crypto/crypto'; import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto';
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase'; import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
import { createContext, useEffect, useRef, useState } from 'react'; import { createContext, useEffect, useRef, useState } from 'react';
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message'; import { Attachment, AttachmentTransport, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
import { usePrivatePlain } from '../AccountProvider/usePrivatePlain'; import { usePrivatePlain } from '../AccountProvider/usePrivatePlain';
import { usePublicKey } from '../AccountProvider/usePublicKey'; import { usePublicKey } from '../AccountProvider/usePublicKey';
import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read'; import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read';
@@ -11,15 +11,13 @@ import { useBlacklist } from '../BlacklistProvider/useBlacklist';
import { useLogger } from '@/app/hooks/useLogger'; import { useLogger } from '@/app/hooks/useLogger';
import { useSender } from '../ProtocolProvider/useSender'; import { useSender } from '../ProtocolProvider/useSender';
import { usePacket } from '../ProtocolProvider/usePacket'; import { usePacket } from '../ProtocolProvider/usePacket';
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, MESSAGE_MAX_TIME_TO_DELEVERED_S, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants'; import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
import { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery'; import { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery';
import { useIdle } from '@mantine/hooks'; import { useIdle } from '@mantine/hooks';
import { useWindowFocus } from '@/app/hooks/useWindowFocus'; import { useWindowFocus } from '@/app/hooks/useWindowFocus';
import { useDialogsCache } from './useDialogsCache'; import { useDialogsCache } from './useDialogsCache';
import { useConsoleLogger } from '@/app/hooks/useConsoleLogger'; import { useConsoleLogger } from '@/app/hooks/useConsoleLogger';
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState'; import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
import { MessageReply } from './useReplyMessages';
import { useTransport } from '../TransportProvider/useTransport';
import { useFileStorage } from '@/app/hooks/useFileStorage'; import { useFileStorage } from '@/app/hooks/useFileStorage';
import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts'; import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
import { useDialogsList } from '../DialogListProvider/useDialogsList'; import { useDialogsList } from '../DialogListProvider/useDialogsList';
@@ -32,7 +30,6 @@ export interface DialogContextValue {
setMessages: (messages: React.SetStateAction<Message[]>) => void; setMessages: (messages: React.SetStateAction<Message[]>) => void;
dialog: string; dialog: string;
clearDialogCache: () => void; clearDialogCache: () => void;
prepareAttachmentsToSend: (password: string, attachments: Attachment[]) => Promise<Attachment[]>;
loadMessagesToTop: () => Promise<void>; loadMessagesToTop: () => Promise<void>;
loadMessagesToMessageId: (messageId: string) => Promise<void>; loadMessagesToMessageId: (messageId: string) => Promise<void>;
} }
@@ -49,6 +46,7 @@ export interface AttachmentMeta {
id: string; id: string;
type: AttachmentType; type: AttachmentType;
preview: string; preview: string;
transport: AttachmentTransport;
} }
export interface Message { export interface Message {
@@ -70,6 +68,23 @@ interface DialogProviderProps {
dialog: string; dialog: string;
} }
type DialogMessageEvent = {
dialogId: string;
message: Message;
};
const bus = new EventTarget();
export const emitDialogMessage = (payload: DialogMessageEvent) => {
bus.dispatchEvent(new CustomEvent<DialogMessageEvent>("dialog:message", { detail: payload }));
};
export const onDialogMessage = (handler: (payload: DialogMessageEvent) => void) => {
const listener = (e: Event) => handler((e as CustomEvent<DialogMessageEvent>).detail);
bus.addEventListener("dialog:message", listener);
return () => bus.removeEventListener("dialog:message", listener);
};
export function DialogProvider(props: DialogProviderProps) { export function DialogProvider(props: DialogProviderProps) {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const {allQuery, runQuery} = useDatabase(); const {allQuery, runQuery} = useDatabase();
@@ -87,15 +102,21 @@ export function DialogProvider(props: DialogProviderProps) {
const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache(); const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache();
const {info, warn, error} = useConsoleLogger('DialogProvider'); const {info, warn, error} = useConsoleLogger('DialogProvider');
const [viewState] = useViewPanelsState(); const [viewState] = useViewPanelsState();
const {uploadFile} = useTransport();
const {readFile} = useFileStorage(); const {readFile} = useFileStorage();
const intervalsRef = useRef<NodeJS.Timeout>(null);
const systemAccounts = useSystemAccounts(); const systemAccounts = useSystemAccounts();
const {updateDialog} = useDialogsList(); const {updateDialog} = useDialogsList();
const {hasGroup, getGroupKey} = useGroups(); const {hasGroup, getGroupKey} = useGroups();
const {popMention, isMentioned} = useMentions(); const {popMention, isMentioned} = useMentions();
useEffect(() => {
const unsub = onDialogMessage(({ dialogId, message }) => {
if (dialogId !== props.dialog) return;
setMessages((prev) => [...prev, message]);
});
return unsub;
}, [props.dialog]);
useEffect(() => { useEffect(() => {
setCurrentDialogPublicKeyView(props.dialog); setCurrentDialogPublicKeyView(props.dialog);
return () => { return () => {
@@ -194,26 +215,40 @@ export function DialogProvider(props: DialogProviderProps) {
readUpdated = true; readUpdated = true;
} }
let decryptKey = ''; let decryptKey = '';
if(message.from_me && message.chacha_key != "" && !message.chacha_key.startsWith("sync:")){
/**
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key
*/
try{
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key), 'binary').toString('hex');
}catch(e) {
decryptKey = "";
}
}
if(message.from_me && message.chacha_key != "" && message.chacha_key.startsWith("sync:")){ if(message.from_me && message.chacha_key != "" && message.chacha_key.startsWith("sync:")){
/** /**
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это * Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это
* сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет, * сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет,
* значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (plain_message) * значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (plain_message)
*/ */
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('utf-8'); decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('hex');
} }
if(hasGroup(props.dialog)){ if(hasGroup(props.dialog)){
/** /**
* Если это групповое сообщение, то получаем ключ группы * Если это групповое сообщение, то получаем ключ группы
*/ */
decryptKey = await getGroupKey(props.dialog); decryptKey = await getGroupKey(props.dialog);
/**
* Приводим к HEX так как этого требует формат расшифровки вложений в приложении
*/
decryptKey = Buffer.from(decryptKey).toString('hex');
} }
if(!message.from_me && !hasGroup(props.dialog)){ if(!message.from_me && !hasGroup(props.dialog)){
/** /**
* Если сообщение не от меня и не групповое, * Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом * расшифровываем ключ чачи своим приватным ключом
*/ */
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8'); decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
} }
finalMessages.push({ finalMessages.push({
from_public_key: message.from_public_key, from_public_key: message.from_public_key,
@@ -294,7 +329,6 @@ export function DialogProvider(props: DialogProviderProps) {
* Обработчик чтения для личных сообщений * Обработчик чтения для личных сообщений
*/ */
usePacket(0x07, async (packet : PacketRead) => { usePacket(0x07, async (packet : PacketRead) => {
info("Read packet received in dialog provider");
const fromPublicKey = packet.getFromPublicKey(); const fromPublicKey = packet.getFromPublicKey();
if(fromPublicKey == publicKey){ if(fromPublicKey == publicKey){
/** /**
@@ -308,7 +342,10 @@ export function DialogProvider(props: DialogProviderProps) {
*/ */
return; return;
} }
if(fromPublicKey != props.dialog && !idle){ if(idle){
return;
}
if(fromPublicKey != props.dialog){
return; return;
} }
setMessages((prev) => prev.map((msg) => { setMessages((prev) => prev.map((msg) => {
@@ -327,7 +364,6 @@ export function DialogProvider(props: DialogProviderProps) {
* Обработчик чтения групповых сообщений * Обработчик чтения групповых сообщений
*/ */
usePacket(0x07, async (packet : PacketRead) => { usePacket(0x07, async (packet : PacketRead) => {
info("Read packet received in dialog provider");
const fromPublicKey = packet.getFromPublicKey(); const fromPublicKey = packet.getFromPublicKey();
if(fromPublicKey == publicKey){ if(fromPublicKey == publicKey){
/** /**
@@ -342,7 +378,10 @@ export function DialogProvider(props: DialogProviderProps) {
*/ */
return; return;
} }
if(toPublicKey != props.dialog && !idle){ if(idle){
return;
}
if(toPublicKey != props.dialog){
return; return;
} }
setMessages((prev) => prev.map((msg) => { setMessages((prev) => prev.map((msg) => {
@@ -387,7 +426,6 @@ export function DialogProvider(props: DialogProviderProps) {
}, [publicKey]); }, [publicKey]);
usePacket(0x08, async (packet : PacketDelivery) => { usePacket(0x08, async (packet : PacketDelivery) => {
info("Delivery packet received in dialog provider");
const fromPublicKey = packet.getToPublicKey(); const fromPublicKey = packet.getToPublicKey();
const messageId = packet.getMessageId(); const messageId = packet.getMessageId();
if(fromPublicKey != props.dialog){ if(fromPublicKey != props.dialog){
@@ -419,13 +457,18 @@ export function DialogProvider(props: DialogProviderProps) {
const timestamp = packet.getTimestamp(); const timestamp = packet.getTimestamp();
const messageId = packet.getMessageId(); const messageId = packet.getMessageId();
if(fromPublicKey != publicKey){ if(fromPublicKey != publicKey){
/** /**
* Игнорируем если это не сообщение от нас * Игнорируем если это не сообщение от нас
*/ */
return; return;
} }
if(hasGroup(toPublicKey)){
/**
* Есть другой обработчик для синхронизации групп
*/
return;
}
if(toPublicKey != props.dialog) { if(toPublicKey != props.dialog) {
/** /**
* Игнорируем если это не сообщение для этого диалога * Игнорируем если это не сообщение для этого диалога
@@ -441,10 +484,8 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) { for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i]; const attachment = packet.getAttachments()[i];
attachments.push({ attachments.push({
id: attachment.id, ...attachment,
preview: attachment.preview, blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
}); });
} }
@@ -454,7 +495,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content, content: content,
timestamp: timestamp, timestamp: timestamp,
readed: 0, //сообщение прочитано readed: 0, //сообщение прочитано
chacha_key: chachaDecryptedKey.toString('utf-8'), chacha_key: chachaDecryptedKey.toString('hex'),
from_me: 1, //сообщение от нас from_me: 1, //сообщение от нас
plain_message: (decryptedContent as string), plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED, delivered: DeliveredMessageState.DELIVERED,
@@ -465,6 +506,84 @@ export function DialogProvider(props: DialogProviderProps) {
setMessages((prev) => ([...prev, newMessage])); setMessages((prev) => ([...prev, newMessage]));
}, [privatePlain]); }, [privatePlain]);
/**
* Обработчик сообщений для синхронизации своих же сообщений в группе
*/
usePacket(0x06, async (packet: PacketMessage) => {
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
if(fromPublicKey != publicKey){
/**
* Это не синхронизация, игнорируем ее в этом обработчике
*/
return;
}
if(toPublicKey != props.dialog){
/**
* Исправление кросс диалогового сообщения
*/
return;
}
if(!hasGroup(props.dialog)){
/**
* Если это не групповое сообщение, то для него есть
* другой обработчик выше
*/
return;
}
const content = packet.getContent();
const timestamp = packet.getTimestamp();
/**
* Генерация рандомного ID сообщения по SEED нужна для того,
* чтобы сообщение записанное здесь в стек сообщений совпадало
* с тем что записывается в БД в файле useDialogFiber.ts
*/
const messageId = packet.getMessageId();
const groupKey = await getGroupKey(toPublicKey);
if(!groupKey){
log("Group key not found for group " + toPublicKey);
error("Message dropped because group key not found for group " + toPublicKey);
return;
}
let decryptedContent = '';
try{
decryptedContent = await decodeWithPassword(groupKey, content);
}catch(e) {
decryptedContent = '';
}
let attachments: Attachment[] = [];
for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
attachments.push({
...attachment,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
});
}
const newMessage : Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: 0,
chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: 1,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: attachments
};
setMessages((prev) => ([...prev, newMessage]));
}, [messages, idle, props.dialog]);
/** /**
* Обработчик для личных сообщений * Обработчик для личных сообщений
*/ */
@@ -519,20 +638,18 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) { for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i]; const attachment = packet.getAttachments()[i];
attachments.push({ attachments.push({
id: attachment.id, ...attachment,
preview: attachment.preview, blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
}); });
} }
console.info(attachments);
const newMessage : Message = { const newMessage : Message = {
from_public_key: fromPublicKey, from_public_key: fromPublicKey,
to_public_key: toPublicKey, to_public_key: toPublicKey,
content: content, content: content,
timestamp: timestamp, timestamp: timestamp,
readed: idle ? 0 : 1, readed: idle ? 0 : 1,
chacha_key: chachaDecryptedKey.toString('utf-8'), chacha_key: chachaDecryptedKey.toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0, from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: (decryptedContent as string), plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED, delivered: DeliveredMessageState.DELIVERED,
@@ -586,7 +703,6 @@ export function DialogProvider(props: DialogProviderProps) {
error("Message dropped because group key not found for group " + toPublicKey); error("Message dropped because group key not found for group " + toPublicKey);
return; return;
} }
info("New group message packet received from " + fromPublicKey);
let decryptedContent = ''; let decryptedContent = '';
@@ -600,9 +716,7 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) { for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i]; const attachment = packet.getAttachments()[i];
attachments.push({ attachments.push({
id: attachment.id, ...attachment,
preview: attachment.preview,
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : "" blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
}); });
} }
@@ -613,7 +727,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content, content: content,
timestamp: timestamp, timestamp: timestamp,
readed: idle ? 0 : 1, readed: idle ? 0 : 1,
chacha_key: groupKey, chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0, from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: decryptedContent, plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED, delivered: DeliveredMessageState.DELIVERED,
@@ -687,7 +801,7 @@ export function DialogProvider(props: DialogProviderProps) {
* Если сообщение не от меня и не групповое, * Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом * расшифровываем ключ чачи своим приватным ключом
*/ */
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8'); decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
} }
finalMessages.push({ finalMessages.push({
from_public_key: message.from_public_key, from_public_key: message.from_public_key,
@@ -772,7 +886,7 @@ export function DialogProvider(props: DialogProviderProps) {
* Если сообщение не от меня и не групповое, * Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом * расшифровываем ключ чачи своим приватным ключом
*/ */
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8'); decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
} }
finalMessages.push({ finalMessages.push({
from_public_key: message.from_public_key, from_public_key: message.from_public_key,
@@ -831,6 +945,16 @@ export function DialogProvider(props: DialogProviderProps) {
}); });
continue; continue;
} }
if(meta.type == AttachmentType.CALL){
/**
* Если это звонок
*/
attachments.push({
...meta,
blob: ""
});
continue;
}
const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`); const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`);
if(!fileData) { if(!fileData) {
attachments.push({ attachments.push({
@@ -839,112 +963,26 @@ export function DialogProvider(props: DialogProviderProps) {
}); });
continue; continue;
} }
const decrypted = await decodeWithPassword(privatePlain, Buffer.from(fileData, 'binary').toString()); let blob = "";
if(meta.type != AttachmentType.IMAGE){
blob = await decodeWithPassword(privatePlain, Buffer.from(fileData, 'binary').toString());
}
attachments.push({ attachments.push({
id: meta.id, id: meta.id,
blob: decrypted, blob: blob,
type: meta.type, type: meta.type,
preview: meta.preview preview: meta.preview,
transport: meta.transport
}); });
} }
return attachments; return attachments;
}catch(e) { }catch(e) {
console.info(e);
error("Failed to parse attachments"); error("Failed to parse attachments");
} }
return []; return [];
} }
/**
* Обновляет временную метку в сообщении, пока вложения отправляются,
* потому что если этого не делать, то сообщение может быть помечено как
* не доставленное из-за таймаута доставки
* @param attachments Вложения
*/
const doTimestampUpdateImMessageWhileAttachmentsSend = (attachments : Attachment[]) => {
if(intervalsRef.current){
clearInterval(intervalsRef.current);
}
intervalsRef.current = setInterval(() => {
//update timestamp in message to keep message marked as error
updateDialog(props.dialog);
setMessages((prev) => {
return prev.map((value) => {
if(value.attachments.length <= 0){
return value;
}
if(value.attachments[0].id != attachments[0].id){
return value;
}
runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), value.message_id]);
return {
...value,
timestamp: Date.now()
};
})
});
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
}
/**
* Удаляет старый тег если вложения были подготовлены заново
* например при пересылке сообщений
*/
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
if(preview.indexOf("::") == -1){
return preview;
}
let parts = preview.split("::");
return parts.slice(1).join("::");
}
/**
* Подготавливает вложения для отправки. Подготовка
* состоит в загрузке файлов на транспортный сервер, мы не делаем
* это через WebSocket из-за ограничений по размеру сообщений,
* а так же из-за надежности доставки файлов через HTTP
* @param attachments Attachments to prepare for sending
*/
const prepareAttachmentsToSend = async (password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
if(attachments.length <= 0){
return [];
}
let prepared : Attachment[] = [];
try{
for(let i = 0; i < attachments.length; i++){
const attachment : Attachment = attachments[i];
if(attachment.type == AttachmentType.MESSAGES){
let reply : MessageReply[] = JSON.parse(attachment.blob)
for(let j = 0; j < reply.length; j++){
reply[j].attachments = await prepareAttachmentsToSend(password, reply[j].attachments, true);
}
prepared.push({
...attachment,
blob: await encodeWithPassword(password, JSON.stringify(reply))
});
continue;
}
doTimestampUpdateImMessageWhileAttachmentsSend(attachments);
const content = await encodeWithPassword(password, attachment.blob);
const upid = attachment.id;
info(`Uploading attachment with upid: ${upid}`);
info(`Attachment content length: ${content.length}`);
let tag = await uploadFile(upid, content);
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
}
prepared.push({
...attachment,
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
blob: ""
});
}
return prepared;
}catch(e){
return prepared;
}
}
/** /**
* Дедубликация сообщений по message_id, так как может возникать ситуация, что одно и то же сообщение * Дедубликация сообщений по message_id, так как может возникать ситуация, что одно и то же сообщение
* может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации * может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации
@@ -972,7 +1010,6 @@ export function DialogProvider(props: DialogProviderProps) {
setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog)); setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog));
}, },
dialog: props.dialog, dialog: props.dialog,
prepareAttachmentsToSend,
loadMessagesToTop, loadMessagesToTop,
loadMessagesToMessageId loadMessagesToMessageId
}}> }}>

View File

@@ -0,0 +1,161 @@
import { generateRandomKey } from "@/app/utils/utils";
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
import { useGroups } from "./useGroups";
import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5 } from "@/app/workers/crypto/crypto";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { AttachmentMeta, DeliveredMessageState, emitDialogMessage, Message } from "./DialogProvider";
import { useDatabase } from "../DatabaseProvider/useDatabase";
import { useFileStorage } from "@/app/hooks/useFileStorage";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
import { useSender } from "../ProtocolProvider/useSender";
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
/**
* Используется для отправки сообщений не внутри DialogProvider, а например в CallProvider,
* когда нам нужно отправить сообщение от своего имени что мы совершли звонок (Attachment.CALL)
*/
export function useDeattachedSender() {
const {hasGroup, getGroupKey} = useGroups();
const privatePlain = usePrivatePlain();
const {warn} = useConsoleLogger('useDeattachedSender');
const {runQuery} = useDatabase();
const {writeFile} = useFileStorage();
const publicKey = usePublicKey();
const {updateDialog} = useDialogsList();
const [protocolState] = useProtocolState();
const privateKey = usePrivateKeyHash();
const send = useSender();
const {prepareAttachmentsToSend} = usePrepareAttachment();
/**
* Отправка сообщения в диалог
* @param dialog ID диалога, может быть как публичным ключом собеседника, так и ID группового диалога
* @param message Сообщение
* @param attachemnts Вложения
*/
const sendMessage = async (dialog: string, message: string, attachemnts : Attachment[], serverSent: boolean = false) => {
const messageId = generateRandomKey(16);
let cahchaEncrypted = {ciphertext: "", key: "", nonce: ""} as any;
let key = Buffer.from("");
let encryptedKey = "";
let plainMessage = "";
let content = "";
if(!hasGroup(dialog)){
cahchaEncrypted = (await chacha20Encrypt(message.trim()) as any);
key = Buffer.concat([
Buffer.from(cahchaEncrypted.key, "hex"),
Buffer.from(cahchaEncrypted.nonce, "hex")]);
encryptedKey = await encrypt(key.toString('binary'), dialog);
plainMessage = await encodeWithPassword(privatePlain, message.trim());
content = cahchaEncrypted.ciphertext;
}else{
/**
* Это группа, там шифрование устроено иначе
* для групп используется один общий ключ, который
* есть только у участников группы, сам ключ при этом никак
* не отправляется по сети (ведь ID у группы общий и у каждого
* и так есть этот ключ)
*/
const groupKey = await getGroupKey(dialog);
if(!groupKey){
warn("Group key not found for dialog " + dialog);
return;
}
content = await encodeWithPassword(groupKey, message.trim());
plainMessage = await encodeWithPassword(privatePlain, message.trim());
encryptedKey = ""; // В группах не нужен зашифрованный ключ
key = Buffer.from(groupKey);
}
/**
* Нужно зашифровать ключ еще и нашим ключом,
* чтобы в последствии мы могли расшифровать этот ключ у своих
* же сообщений (смотреть problem_sync.md)
*/
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
emitDialogMessage({
dialogId: dialog,
message: {
from_public_key: publicKey,
to_public_key: dialog,
content: content,
timestamp: Date.now(),
readed: publicKey == dialog ? 1 : 0,
chacha_key: "",
from_me: 1,
plain_message: message,
delivered: serverSent ? (publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: attachemnts
} as Message
})
let attachmentsMeta : AttachmentMeta[] = [];
for(let i = 0; i < attachemnts.length; i++) {
const attachment = attachemnts[i];
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview,
transport: attachment.transport
});
if(attachment.type == AttachmentType.FILE){
/**
* Обычно вложения дублируются на диск. Так происходит со всем.
* Кроме файлов. Если дублировать файл весом в 2гб на диск отправка будет
* занимать очень много времени.
* К тому же, это приведет к созданию ненужной копии у отправителя
*/
continue;
}
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`, Buffer.from(await encodeWithPassword(privatePlain, attachment.blob)).toString('binary'));
}
await runQuery(`
INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
(serverSent ? (protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED)
), JSON.stringify(attachmentsMeta)]);
updateDialog(dialog);
if(publicKey == ""
|| dialog == ""
|| publicKey == dialog) {
return;
}
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog);
return;
}
if(!serverSent){
return;
}
const packet = new PacketMessage();
packet.setFromPublicKey(publicKey);
packet.setToPublicKey(dialog);
packet.setContent(content);
packet.setChachaKey(encryptedKey);
packet.setPrivateKey(privateKey);
packet.setMessageId(messageId);
packet.setTimestamp(Date.now());
packet.setAttachments(preparedToNetworkSendAttachements);
packet.setAesChachaKey(aesChachaKey);
send(packet);
}
return {sendMessage};
}

View File

@@ -1,6 +1,6 @@
import { useContext } from "react"; import { useContext } from "react";
import { useDatabase } from "../DatabaseProvider/useDatabase"; 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 { AttachmentMeta, DeliveredMessageState, DialogContext, Message } from "./DialogProvider";
import { Attachment, AttachmentType, PacketMessage } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message"; import { Attachment, AttachmentType, PacketMessage } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
import { usePublicKey } from "../AccountProvider/usePublicKey"; import { usePublicKey } from "../AccountProvider/usePublicKey";
@@ -14,6 +14,7 @@ import { useProtocolState } from "../ProtocolProvider/useProtocolState";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider"; import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useGroups } from "./useGroups"; import { useGroups } from "./useGroups";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger"; import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
export function useDialog() : { export function useDialog() : {
messages: Message[]; messages: Message[];
@@ -35,7 +36,6 @@ export function useDialog() : {
} }
const {loading, const {loading,
messages, messages,
prepareAttachmentsToSend,
clearDialogCache, clearDialogCache,
setMessages, setMessages,
dialog, loadMessagesToTop, loadMessagesToMessageId} = context; dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
@@ -47,6 +47,7 @@ export function useDialog() : {
const [protocolState] = useProtocolState(); const [protocolState] = useProtocolState();
const {hasGroup, getGroupKey} = useGroups(); const {hasGroup, getGroupKey} = useGroups();
const {warn} = useConsoleLogger('useDialog'); const {warn} = useConsoleLogger('useDialog');
const {prepareAttachmentsToSend} = usePrepareAttachment();
/** /**
* Отправка сообщения в диалог * Отправка сообщения в диалог
@@ -95,14 +96,13 @@ export function useDialog() : {
* же сообщений (смотреть problem_sync.md) * же сообщений (смотреть problem_sync.md)
*/ */
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary')); const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
setMessages((prev : Message[]) => ([...prev, { setMessages((prev : Message[]) => ([...prev, {
from_public_key: publicKey, from_public_key: publicKey,
to_public_key: dialog, to_public_key: dialog,
content: content, content: content,
timestamp: Date.now(), timestamp: Date.now(),
readed: publicKey == dialog ? 1 : 0, readed: publicKey == dialog ? 1 : 0,
chacha_key: "", chacha_key: key.toString('hex'),
from_me: 1, from_me: 1,
plain_message: message, plain_message: message,
delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING, delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
@@ -117,7 +117,8 @@ export function useDialog() : {
attachmentsMeta.push({ attachmentsMeta.push({
id: attachment.id, id: attachment.id,
type: attachment.type, type: attachment.type,
preview: attachment.preview preview: attachment.preview,
transport: attachment.transport
}); });
if(attachment.type == AttachmentType.FILE){ if(attachment.type == AttachmentType.FILE){
/** /**
@@ -134,7 +135,7 @@ export function useDialog() : {
await runQuery(` await runQuery(`
INSERT INTO messages INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) (from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : ( `, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, aesChachaKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING
), JSON.stringify(attachmentsMeta)]); ), JSON.stringify(attachmentsMeta)]);
updateDialog(dialog); updateDialog(dialog);
@@ -144,9 +145,9 @@ export function useDialog() : {
return; return;
} }
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
console.info("Sending key for message ", key.toString('hex')); console.info("Sending key for message ", key.toString('hex'));
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(key.toString('utf-8'), attachemnts); console.info(attachemnts);
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){ if(attachemnts.length <= 0 && message.trim() == ""){
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]); runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog); updateDialog(dialog);

View File

@@ -12,8 +12,8 @@ import { useDialogsCache } from "./useDialogsCache";
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase"; import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain"; import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { usePublicKey } from "../AccountProvider/usePublicKey"; 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 { AttachmentMeta, DeliveredMessageState, Message } from "./DialogProvider";
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read"; import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery"; import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger"; import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
@@ -27,6 +27,7 @@ import { useMentions } from "../DialogStateProvider.tsx/useMentions";
import { runTaskInQueue } from "./dialogQueue"; import { runTaskInQueue } from "./dialogQueue";
import { useProtocolState } from "../ProtocolProvider/useProtocolState"; import { useProtocolState } from "../ProtocolProvider/useProtocolState";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider"; import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useUpdateSyncTime } from "./useUpdateSyncTime";
/** /**
* При вызове будет запущен "фоновый" обработчик * При вызове будет запущен "фоновый" обработчик
@@ -53,27 +54,7 @@ export function useDialogFiber() {
const [userInfo] = useUserInformation(publicKey); const [userInfo] = useUserInformation(publicKey);
const { pushMention } = useMentions(); const { pushMention } = useMentions();
const [protocolState] = useProtocolState(); const [protocolState] = useProtocolState();
const updateSyncTime = useUpdateSyncTime();
/**
* Обновляет время последней синхронизации для аккаунта
* @param timestamp время
*/
const updateSyncTime = async (timestamp: number) => {
if(protocolState == ProtocolState.SYNCHRONIZATION){
/**
* Если сейчас идет синхронизация то чтобы при синхронизации
* не создавать нагрузку на базу данных
* по постоянному обновлению, обновляем базу один раз - когда
* приходит пакет о том что синхронизация закончилась
*/
return;
}
await runQuery(
"INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " +
"ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?",
[publicKey, timestamp, timestamp, publicKey]
);
};
/** /**
* Лог * Лог
@@ -82,101 +63,6 @@ export function useDialogFiber() {
info("Starting passive fiber for dialog packets"); info("Starting passive fiber for dialog packets");
}, []); }, []);
/**
* Нам приходят сообщения от себя самих же при синхронизации
* нужно обрабатывать их особым образом соотвественно
*
* Метод нужен для синхронизации своих сообщений
*/
usePacket(0x06, async (packet: PacketMessage) => {
runTaskInQueue(async () => {
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
const aesChachaKey = packet.getAesChachaKey();
const content = packet.getContent();
const timestamp = packet.getTimestamp();
const messageId = packet.getMessageId();
if (fromPublicKey != publicKey) {
/**
* Игнорируем если это не сообщение от нас
*/
return;
}
const chachaKey = await decodeWithPassword(privatePlain, aesChachaKey);
const chachaDecryptedKey = Buffer.from(chachaKey, "binary");
const key = chachaDecryptedKey.slice(0, 32);
const nonce = chachaDecryptedKey.slice(32);
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
await updateSyncTime(timestamp);
let attachmentsMeta: any[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
log("Attachment received id " + attachment.id + " type " + attachment.type);
let nextLength = messageAttachments.push({
...attachment,
blob: ""
});
if (attachment.type == AttachmentType.MESSAGES) {
/**
* Этот тип вложения приходит сразу в blob и не нуждается
* в последующем скачивании
*/
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
messageAttachments[nextLength - 1].blob = decryptedBlob;
}
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
});
}
const newMessage: Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: 1, //сообщение прочитано
chacha_key: chachaDecryptedKey.toString('utf-8'),
from_me: 1, //сообщение от нас
plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: messageAttachments
};
await runQuery(`
INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [fromPublicKey,
toPublicKey,
content,
timestamp,
0, //по умолчанию не прочитаны
"sync:" + aesChachaKey,
1, //Свои же сообщения всегда от нас
await encodeWithPassword(privatePlain, decryptedContent),
publicKey,
messageId,
DeliveredMessageState.DELIVERED,
JSON.stringify(attachmentsMeta)]);
updateDialog(toPublicKey);
let dialogCache = getDialogCache(toPublicKey);
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
}
});
}, [privatePlain, currentDialogPublicKeyView]);
/** /**
* Обработчик сообщений для группы * Обработчик сообщений для группы
*/ */
@@ -218,7 +104,7 @@ export function useDialogFiber() {
decryptedContent = ''; decryptedContent = '';
} }
let attachmentsMeta: any[] = []; let attachmentsMeta: AttachmentMeta[] = [];
let messageAttachments: Attachment[] = []; let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) { for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i]; const attachment = packet.getAttachments()[i];
@@ -243,7 +129,8 @@ export function useDialogFiber() {
attachmentsMeta.push({ attachmentsMeta.push({
id: attachment.id, id: attachment.id,
type: attachment.type, type: attachment.type,
preview: attachment.preview preview: attachment.preview,
transport: attachment.transport
}); });
} }
@@ -253,7 +140,7 @@ export function useDialogFiber() {
content: content, content: content,
timestamp: timestamp, timestamp: timestamp,
readed: idle ? 0 : 1, readed: idle ? 0 : 1,
chacha_key: groupKey, chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0, from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: decryptedContent, plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED, delivered: DeliveredMessageState.DELIVERED,
@@ -328,7 +215,7 @@ export function useDialogFiber() {
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED)); addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
} }
}); });
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle]); }, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle, protocolState]);
/** /**
* Обработчик личных сообщений * Обработчик личных сообщений
*/ */
@@ -375,7 +262,7 @@ export function useDialogFiber() {
const nonce = chachaDecryptedKey.slice(32); const nonce = chachaDecryptedKey.slice(32);
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex')); const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
let attachmentsMeta: any[] = []; let attachmentsMeta: AttachmentMeta[] = [];
let messageAttachments: Attachment[] = []; let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) { for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i]; const attachment = packet.getAttachments()[i];
@@ -391,7 +278,7 @@ export function useDialogFiber() {
* Этот тип вложения приходит сразу в blob и не нуждается * Этот тип вложения приходит сразу в blob и не нуждается
* в последующем скачивании * в последующем скачивании
*/ */
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob); const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob);
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`, writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary')); Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
messageAttachments[nextLength - 1].blob = decryptedBlob; messageAttachments[nextLength - 1].blob = decryptedBlob;
@@ -400,7 +287,8 @@ export function useDialogFiber() {
attachmentsMeta.push({ attachmentsMeta.push({
id: attachment.id, id: attachment.id,
type: attachment.type, type: attachment.type,
preview: attachment.preview preview: attachment.preview,
transport: attachment.transport
}); });
} }
@@ -410,7 +298,7 @@ export function useDialogFiber() {
content: content, content: content,
timestamp: timestamp, timestamp: timestamp,
readed: idle ? 0 : 1, readed: idle ? 0 : 1,
chacha_key: chachaDecryptedKey.toString('utf-8'), chacha_key: chachaDecryptedKey.toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0, from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: (decryptedContent as string), plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED, delivered: DeliveredMessageState.DELIVERED,
@@ -445,9 +333,9 @@ export function useDialogFiber() {
* чтобы когда приходит пачка сообщений с сервера в момент того как * чтобы когда приходит пачка сообщений с сервера в момент того как
* пользователь был неактивен, не слать уведомления по всем этим сообщениям * пользователь был неактивен, не слать уведомления по всем этим сообщениям
*/ */
if (!muted.includes(fromPublicKey) || protocolState == ProtocolState.SYNCHRONIZATION) { if (!muted.includes(fromPublicKey) && protocolState != ProtocolState.SYNCHRONIZATION) {
/** /**
* Если пользователь в муте или сейчас идет синхронизация - не отправляем уведомление * Если пользователь в муте И сейчас не идет синхронизация, то не отправляем уведомление
*/ */
notify("New message", "You have a new message"); notify("New message", "You have a new message");
} }
@@ -457,48 +345,7 @@ export function useDialogFiber() {
addOrUpdateDialogCache(fromPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED)); addOrUpdateDialogCache(fromPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
} }
}); });
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle]); }, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle, protocolState]);
/**
* Обработчик синхронизации прочтения личных сообщений
*/
usePacket(0x07, async (packet: PacketRead) => {
runTaskInQueue(async () => {
if (hasGroup(packet.getToPublicKey())) {
/**
* Если это относится к группам, то игнорируем здесь,
* для этого есть отдельный слушатель usePacket ниже
*/
return;
}
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
if (fromPublicKey != publicKey) {
/**
* Игнорируем если это не синхронизация нашего прочтения
*/
return;
}
console.info("PACKED_READ_SYNC");
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`,
[toPublicKey, fromPublicKey, publicKey]);
console.info("updating with params ", [fromPublicKey, toPublicKey, publicKey]);
updateDialog(toPublicKey);
log("Read sync packet from other device");
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
if (message.from_public_key == toPublicKey && !message.readed) {
console.info("Marking message as read in cache for dialog with " + fromPublicKey);
console.info({ fromPublicKey, toPublicKey });
return {
...message,
readed: 1
}
}
return message;
}));
});
}, [updateDialog, publicKey]);
/** /**
* Обработчик прочтения личных сообщений * Обработчик прочтения личных сообщений
@@ -522,13 +369,11 @@ export function useDialogFiber() {
return; return;
} }
await updateSyncTime(Date.now()); await updateSyncTime(Date.now());
console.info("PACKED_READ_IM");
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`, [toPublicKey, fromPublicKey, publicKey]); await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`, [toPublicKey, fromPublicKey, publicKey]);
console.info("read im with params ", [fromPublicKey, toPublicKey, publicKey]);
updateDialog(fromPublicKey); updateDialog(fromPublicKey);
log("Read packet received from " + fromPublicKey + " for " + toPublicKey);
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => { addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
if (message.from_public_key == toPublicKey && !message.readed) { if (message.from_public_key == publicKey && !message.readed) {
console.info("Marking message as read in cache for dialog with " + fromPublicKey); console.info("Marking message as read in cache for dialog with " + fromPublicKey);
console.info({ fromPublicKey, toPublicKey }); console.info({ fromPublicKey, toPublicKey });
return { return {
@@ -540,20 +385,28 @@ export function useDialogFiber() {
})); }));
}); });
}, [updateDialog, publicKey]); }, [updateDialog, publicKey]);
/** /**
* Обработчик прочтения групповых сообщений * Обработчик прочтения групповых сообщений
*/ */
usePacket(0x07, async (packet: PacketRead) => { usePacket(0x07, async (packet: PacketRead) => {
runTaskInQueue(async () => { runTaskInQueue(async () => {
if (!hasGroup(packet.getToPublicKey())) { const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
if (!hasGroup(toPublicKey)) {
/** /**
* Если это не относится к группам, то игнорируем здесь, * Если это не относится к группам, то игнорируем здесь,
* для этого есть отдельный слушатель usePacket выше * для этого есть отдельный слушатель usePacket выше
*/ */
return; return;
} }
const fromPublicKey = packet.getFromPublicKey(); if(fromPublicKey == publicKey){
const toPublicKey = packet.getToPublicKey(); /**
* Игнорируем если это наше прочтение
* которое получается при синхронизации
*/
return;
}
await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND from_public_key = ? AND account = ?`, [toPublicKey, publicKey, publicKey]); await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND from_public_key = ? AND account = ?`, [toPublicKey, publicKey, publicKey]);
await updateSyncTime(Date.now()); await updateSyncTime(Date.now());
updateDialog(toPublicKey); updateDialog(toPublicKey);

View File

@@ -30,7 +30,7 @@ export function useDialogsCache() {
const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => { const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => {
/** /**
* TODO: Optimize this function to avoid full map if possible * TODO: Оптимизировать чтобы проходил снизу вверх
*/ */
let newCache = dialogsCache.map((cache) => { let newCache = dialogsCache.map((cache) => {
let newMessages = cache.messages.map((message) => { let newMessages = cache.messages.map((message) => {

View File

@@ -1,6 +1,6 @@
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase"; import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain"; 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 { generateRandomKey } from "@/app/utils/utils";
import { useDialogsList } from "../DialogListProvider/useDialogsList"; import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { usePublicKey } from "../AccountProvider/usePublicKey"; import { usePublicKey } from "../AccountProvider/usePublicKey";
@@ -162,26 +162,10 @@ export function useGroups() : {
const groupId = packet.getGroupId(); const groupId = packet.getGroupId();
info(`Creating group with id ${groupId}`); info(`Creating group with id ${groupId}`);
const encryptKey = generateRandomKey(64); const encryptKey = generateRandomKey(64);
const secureKey = await encodeWithPassword(privatePlain, encryptKey); /**
let content = await encodeWithPassword(encryptKey, `$a=Group created`); * После создания группы в нее необходимо зайти, в соотвествии с новым протоколом
let plainMessage = await encodeWithPassword(privatePlain, `$a=Group created`); */
await runQuery(` joinGroup(await constructGroupString(groupId, title, encryptKey, description));
INSERT INTO groups (account, group_id, title, description, key) VALUES (?, ?, ?, ?, ?)
`, [publicKey, groupId, title, description, secureKey]);
await runQuery(`
INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [publicKey, "#group:" + groupId, content, Date.now(), 1, "", 1, plainMessage, publicKey, generateRandomKey(16),
DeliveredMessageState.DELIVERED
, '[]']);
updateDialog("#group:" + groupId);
updateGroupInformation({
groupId: groupId,
title: title,
description: description
});
setLoading(false);
navigate(`/main/chat/${prepareForRoute(groupId)}`);
}); });
} }
@@ -201,9 +185,11 @@ export function useGroups() : {
const groupId = parsed.groupId; const groupId = parsed.groupId;
const title = parsed.title; const title = parsed.title;
const description = parsed.description; const description = parsed.description;
const encodedGroupString = await encodeWithPassword(privatePlain, groupString);
const packet = new PacketGroupJoin(); const packet = new PacketGroupJoin();
packet.setGroupId(parsed.groupId); packet.setGroupId(parsed.groupId);
packet.setGroupString(encodedGroupString);
send(packet); send(packet);
setLoading(true); setLoading(true);

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect } from "react"; import { useContext } from "react";
import { useMemory } from "../MemoryProvider/useMemory"; import { useMemory } from "../MemoryProvider/useMemory";
import { Attachment } from "../ProtocolProvider/protocol/packets/packet.message"; import { Attachment } from "../ProtocolProvider/protocol/packets/packet.message";
import { DialogContext } from "./DialogProvider"; import { DialogContext } from "./DialogProvider";
@@ -19,6 +19,7 @@ export interface MessageReply {
message: string; message: string;
attachments: Attachment[]; attachments: Attachment[];
message_id: string; message_id: string;
chacha_key_plain: string;
} }
export function useReplyMessages() { export function useReplyMessages() {
@@ -35,7 +36,6 @@ export function useReplyMessages() {
const {dialog} = context; const {dialog} = context;
const selectMessage = (message : MessageReply) => { const selectMessage = (message : MessageReply) => {
console.info(message);
if(replyMessages.publicKey != dialog){ if(replyMessages.publicKey != dialog){
/** /**
* Сброс выбора сообщений из другого диалога * Сброс выбора сообщений из другого диалога
@@ -54,7 +54,6 @@ export function useReplyMessages() {
} }
replyMessages.messages.push(message); replyMessages.messages.push(message);
const sortedByTime = replyMessages.messages.sort((a, b) => a.timestamp - b.timestamp); const sortedByTime = replyMessages.messages.sort((a, b) => a.timestamp - b.timestamp);
setReplyMessages({ setReplyMessages({
publicKey: dialog, publicKey: dialog,
messages: sortedByTime messages: sortedByTime
@@ -106,16 +105,6 @@ export function useReplyMessages() {
})); }));
} }
useEffect(() => {
if(replyMessages.publicKey != dialog
&& replyMessages.inDialogInput != dialog){
/**
* Сброс выбора сообщений при смене диалога
*/
deselectAllMessages();
}
}, [dialog]);
return {replyMessages, return {replyMessages,
translateMessagesToDialogInput, translateMessagesToDialogInput,
isSelectionInCurrentDialog, isSelectionInCurrentDialog,

View File

@@ -6,19 +6,55 @@ import { usePublicKey } from "../AccountProvider/usePublicKey";
import { PacketSync, SyncStatus } from "../ProtocolProvider/protocol/packets/packet.sync"; import { PacketSync, SyncStatus } from "../ProtocolProvider/protocol/packets/packet.sync";
import { useSender } from "../ProtocolProvider/useSender"; import { useSender } from "../ProtocolProvider/useSender";
import { usePacket } from "../ProtocolProvider/usePacket"; import { usePacket } from "../ProtocolProvider/usePacket";
import { whenFinish } from "./dialogQueue"; import { runTaskInQueue, whenFinish } from "./dialogQueue";
import { useProtocol } from "../ProtocolProvider/useProtocol"; import { useProtocol } from "../ProtocolProvider/useProtocol";
import { PacketGroupJoin } from "../ProtocolProvider/protocol/packets/packet.group.join";
import { useGroups } from "./useGroups";
import { chacha20Decrypt, decodeWithPassword, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { GroupStatus } from "../ProtocolProvider/protocol/packets/packet.group.invite.info";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useUpdateGroupInformation } from "../InformationProvider/useUpdateGroupInformation";
import { useGroupInviteStatus } from "./useGroupInviteStatus";
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
import { useUpdateSyncTime } from "./useUpdateSyncTime";
import { useFileStorage } from "@/app/hooks/useFileStorage";
import { AttachmentMeta, DeliveredMessageState, Message } from "./DialogProvider";
import { MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from "@/app/constants";
import { useMemory } from "../MemoryProvider/useMemory";
import { useDialogsCache } from "./useDialogsCache";
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
import { useLogger } from "@/app/hooks/useLogger";
import { useIdle } from "@mantine/hooks";
import { useViewPanelsState } from "@/app/hooks/useViewPanelsState";
import { useWindowFocus } from "@/app/hooks/useWindowFocus";
/** /**
* Хук отвечает за синхронизацию сообщений, запрос синхронизации * Хук отвечает за синхронизацию сообщений, запрос синхронизации
* при подключении * при подключении
*/ */
export function useSynchronize() { export function useSynchronize() {
const [_, setProtocolState] = useProtocolState(); const [protocolState, setProtocolState] = useProtocolState();
const {getQuery, runQuery} = useDatabase(); const {getQuery, runQuery} = useDatabase();
const publicKey = usePublicKey(); const publicKey = usePublicKey();
const send = useSender(); const send = useSender();
const {protocol} = useProtocol(); const {protocol} = useProtocol();
const {parseGroupString, hasGroup, getGroupKey} = useGroups();
const privatePlain = usePrivatePlain();
const {error, info} = useConsoleLogger('useSynchronize');
const log = useLogger('useSynchronize');
const {setInviteStatusByGroupId} = useGroupInviteStatus('');
const updateGroupInformation = useUpdateGroupInformation();
const {updateDialog} = useDialogsList();
const idle = useIdle(TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD * 1000);
const updateSyncTime = useUpdateSyncTime();
const {writeFile} = useFileStorage();
const { getDialogCache, addOrUpdateDialogCache } = useDialogsCache();
const [currentDialogPublicKeyView, __] = useMemory("current-dialog-public-key-view", "", true);
const [viewState] = useViewPanelsState();
const focused = useWindowFocus();
useEffect(() => { useEffect(() => {
if(protocol.handshakeExchangeComplete){ if(protocol.handshakeExchangeComplete){
@@ -38,15 +74,47 @@ export function useSynchronize() {
send(packet); send(packet);
} }
/**
* Пакет приходит либо при входе в группу (но там используется слушатель once), либо при
* синхронизации. В данном случае этот пакет прийдет только при синхронизации
*/
usePacket(20, async (packet: PacketGroupJoin) => {
const decryptedGroupString = await decodeWithPassword(privatePlain, packet.getGroupString());
const parsed = await parseGroupString(decryptedGroupString);
if(!parsed){
error("Received invalid group string, skipping");
return;
}
const groupStatus = packet.getGroupStatus();
if(groupStatus != GroupStatus.JOINED){
error("Cannot sync group that is not joined, skipping");
return;
}
const secureKey = await encodeWithPassword(privatePlain, parsed.encryptKey);
await runQuery(`
INSERT INTO groups (account, group_id, title, description, key) VALUES (?, ?, ?, ?, ?)
`, [publicKey, parsed.groupId, parsed.title, parsed.description, secureKey]);
updateDialog("#group:" + parsed.groupId);
setInviteStatusByGroupId(parsed.groupId, GroupStatus.JOINED);
updateGroupInformation({
groupId: parsed.groupId,
title: parsed.title,
description: parsed.description
});
info("Group synchronized " + parsed.groupId);
}, [publicKey]);
usePacket(25, async (packet: PacketSync) => { usePacket(25, async (packet: PacketSync) => {
const status = packet.getStatus(); const status = packet.getStatus();
if(status == SyncStatus.BATCH_START){ if(status == SyncStatus.BATCH_START){
setProtocolState(ProtocolState.SYNCHRONIZATION); setProtocolState(ProtocolState.SYNCHRONIZATION);
} }
if(status == SyncStatus.BATCH_END){ if(status == SyncStatus.BATCH_END){
console.info("Batch start"); /**
* Этот Promise ждет пока все сообщения синхронизируются и обработаются, только
* после этого
*/
await whenFinish(); await whenFinish();
console.info("Batch finished");
await runQuery( await runQuery(
"INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " + "INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " +
"ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?", "ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?",
@@ -62,4 +130,295 @@ export function useSynchronize() {
setProtocolState(ProtocolState.CONNECTED); setProtocolState(ProtocolState.CONNECTED);
} }
}, [publicKey]); }, [publicKey]);
/**
* Нам приходят сообщения от себя самих же при синхронизации
* нужно обрабатывать их особым образом соотвественно
*
* Метод нужен для синхронизации своих сообщений
*/
usePacket(0x06, async (packet: PacketMessage) => {
runTaskInQueue(async () => {
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
const aesChachaKey = packet.getAesChachaKey();
const content = packet.getContent();
const timestamp = packet.getTimestamp();
const messageId = packet.getMessageId();
if(hasGroup(toPublicKey)){
/**
* Игнорируем если это сообщение для группы, для них есть отдельный слушатель usePacket ниже
*/
return;
}
if (fromPublicKey != publicKey) {
/**
* Игнорируем если это не сообщение от нас
*/
return;
}
const chachaKey = await decodeWithPassword(privatePlain, aesChachaKey);
const chachaDecryptedKey = Buffer.from(chachaKey, "binary");
const key = chachaDecryptedKey.slice(0, 32);
const nonce = chachaDecryptedKey.slice(32);
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
await updateSyncTime(timestamp);
let attachmentsMeta: AttachmentMeta[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
let nextLength = messageAttachments.push({
...attachment,
blob: ""
});
if (attachment.type == AttachmentType.MESSAGES) {
/**
* Этот тип вложения приходит сразу в blob и не нуждается
* в последующем скачивании
*/
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob);
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
messageAttachments[nextLength - 1].blob = decryptedBlob;
}
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview,
transport: attachment.transport
});
}
const newMessage: Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: 1, //сообщение прочитано
chacha_key: chachaDecryptedKey.toString('hex'),
from_me: 1, //сообщение от нас
plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: messageAttachments
};
await runQuery(`
INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [fromPublicKey,
toPublicKey,
content,
timestamp,
0, //по умолчанию не прочитаны
"sync:" + aesChachaKey,
1, //Свои же сообщения всегда от нас
await encodeWithPassword(privatePlain, decryptedContent),
publicKey,
messageId,
DeliveredMessageState.DELIVERED,
JSON.stringify(attachmentsMeta)]);
updateDialog(toPublicKey);
let dialogCache = getDialogCache(toPublicKey);
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
}
});
}, [privatePlain, currentDialogPublicKeyView]);
/**
* Обработчик синхронизации прочтения личных сообщений
*/
usePacket(0x07, async (packet: PacketRead) => {
runTaskInQueue(async () => {
if (hasGroup(packet.getToPublicKey())) {
/**
* Если это относится к группам, то игнорируем здесь,
* для этого есть отдельный слушатель usePacket ниже
*/
return;
}
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
if (fromPublicKey != publicKey) {
/**
* Игнорируем если это не синхронизация нашего прочтения
*/
return;
}
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`,
[toPublicKey, fromPublicKey, publicKey]);
updateDialog(toPublicKey);
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
if (message.from_public_key == toPublicKey && !message.readed) {
console.info({ fromPublicKey, toPublicKey });
return {
...message,
readed: 1
}
}
return message;
}));
});
}, [updateDialog, publicKey]);
/**
* Обработчик синхронизации прочтения групповых сообщений
*/
usePacket(0x07, async (packet: PacketRead) => {
runTaskInQueue(async () => {
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
if (!hasGroup(toPublicKey)) {
/**
* Если это не относится к группам, то игнорируем здесь,
* для этого есть отдельный слушатель usePacket выше
*/
return;
}
if(fromPublicKey != publicKey){
/**
* Игнорируем если это наше прочтение
* которое получается при синхронизации
*/
return;
}
await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND from_public_key != ? AND account = ?`,
[toPublicKey, publicKey, publicKey]);
await updateSyncTime(Date.now());
updateDialog(toPublicKey);
addOrUpdateDialogCache(toPublicKey, getDialogCache(toPublicKey).map((message) => {
if (!message.readed && message.from_public_key != publicKey) {
return {
...message,
readed: 1
}
}
return message;
}));
});
}, [updateDialog]);
/**
* Обработчик сообщений для синхронизации своих же сообщений в группе
*/
usePacket(0x06, async (packet: PacketMessage) => {
runTaskInQueue(async () => {
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
const content = packet.getContent();
const timestamp = packet.getTimestamp();
const messageId = packet.getMessageId();
if (!hasGroup(toPublicKey)) {
/**
* Если это личное сообщение, то игнорируем его здесь
* для него есть отдельный слушатель usePacket (снизу)
*/
return;
}
if (fromPublicKey != publicKey) {
/**
* Игнорируем если это сообщения не от нас
*/
return;
}
await updateSyncTime(timestamp);
const groupKey = await getGroupKey(toPublicKey);
if (!groupKey) {
log("Group key not found for group " + toPublicKey);
error("Message dropped because group key not found for group " + toPublicKey);
return;
}
info("New group message packet received from " + fromPublicKey);
let decryptedContent = '';
try {
decryptedContent = await decodeWithPassword(groupKey, content);
} catch (e) {
decryptedContent = '';
}
let attachmentsMeta: AttachmentMeta[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
log("Attachment received id " + attachment.id + " type " + attachment.type);
let nextLength = messageAttachments.push({
...attachment,
blob: ""
});
if (attachment.type == AttachmentType.MESSAGES) {
/**
* Этот тип вложения приходит сразу в blob и не нуждается
* в последующем скачивании
*/
const decryptedBlob = await decodeWithPassword(groupKey, attachment.blob);
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
messageAttachments[nextLength - 1].blob = decryptedBlob;
}
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview,
transport: attachment.transport
});
}
const newMessage: Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: 0,
chacha_key: groupKey,
from_me: 1,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: messageAttachments
};
await runQuery(`
INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [fromPublicKey,
toPublicKey,
content,
timestamp,
0, //по умолчанию не прочитаны
"",
1, //Свои же сообщения всегда от нас
await encodeWithPassword(privatePlain, decryptedContent),
publicKey,
messageId,
DeliveredMessageState.DELIVERED,
JSON.stringify(attachmentsMeta)]);
/**
* Так как у нас в toPublicKey приходит ID группы,
* то обновляем диалог по этому ID, а не по fromPublicKey
* как это сделано в личных сообщениях
*/
updateDialog(toPublicKey);
let dialogCache = getDialogCache(toPublicKey);
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
}
});
}, [updateDialog, focused, currentDialogPublicKeyView, viewState, idle, protocolState]);
} }

View File

@@ -0,0 +1,33 @@
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { useDatabase } from "../DatabaseProvider/useDatabase";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
export function useUpdateSyncTime() : (timestamp: number) => Promise<void> {
const [protocolState] = useProtocolState();
const {runQuery} = useDatabase();
const publicKey = usePublicKey();
/**
* Обновляет время последней синхронизации для аккаунта
* @param timestamp время
*/
const updateSyncTime = async (timestamp: number) => {
if(protocolState == ProtocolState.SYNCHRONIZATION){
/**
* Если сейчас идет синхронизация то чтобы при синхронизации
* не создавать нагрузку на базу данных
* по постоянному обновлению, обновляем базу один раз - когда
* приходит пакет о том что синхронизация закончилась
*/
return;
}
await runQuery(
"INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " +
"ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?",
[publicKey, timestamp, timestamp, publicKey]
);
};
return updateSyncTime;
}

View File

@@ -87,6 +87,7 @@ export function ImageViewer(props : ImageViewerProps) {
// Wheel zoom (zoom to cursor) // Wheel zoom (zoom to cursor)
const onWheel = (e: React.WheelEvent<HTMLImageElement>) => { const onWheel = (e: React.WheelEvent<HTMLImageElement>) => {
//e.preventDefault(); //e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const mouseX = e.clientX - rect.left; const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top; const mouseY = e.clientY - rect.top;
@@ -148,6 +149,7 @@ export function ImageViewer(props : ImageViewerProps) {
userSelect: 'none', userSelect: 'none',
cursor: isDragging ? 'grabbing' : 'grab', cursor: isDragging ? 'grabbing' : 'grab',
transformOrigin: '0 0', transformOrigin: '0 0',
background: '#FFF',
transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`, transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`,
}} }}
onWheel={onWheel} onWheel={onWheel}

View File

@@ -0,0 +1,300 @@
import { createContext, useEffect, useRef, useState } from "react";
export interface PlayerContextValue {
playAudio: (
artist: string,
title: string,
audio: string | Blob | File,
messageId?: string | null
) => void;
playing: boolean;
pause: () => void;
resume: () => void;
stop: () => void;
setDuration: (duration: number) => void;
duration: number;
totalDuration: number;
currentMessageId: string | null;
lastMessageId: string | null;
lastError: string | null;
}
export const PlayerContext = createContext<PlayerContextValue | null>(null);
interface PlayerProviderProps {
children: React.ReactNode;
}
export function PlayerProvider(props: PlayerProviderProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const objectUrlRef = useRef<string | null>(null);
const rafTimeUpdateRef = useRef<number | null>(null);
const isLoadingRef = useRef(false);
const isSeekingRef = useRef(false);
const durationRef = useRef(0);
const totalDurationRef = useRef(0);
const isPlayingRef = useRef(false);
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDurationState] = useState(0);
const [totalDuration, setTotalDuration] = useState(0);
const [lastError, setLastError] = useState<string | null>(null);
const [currentMessageId, setCurrentMessageId] = useState<string | null>(null);
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
const commitPlaying = (next: boolean) => {
if (isPlayingRef.current === next) return;
isPlayingRef.current = next;
setIsPlaying(next);
};
const commitDuration = (next: number) => {
const safe = Number.isFinite(next) && next >= 0 ? next : 0;
if (Math.abs(safe - durationRef.current) < 0.033) return;
durationRef.current = safe;
setDurationState(safe);
};
const commitTotalDuration = (next: number) => {
const safe = Number.isFinite(next) && next > 0 ? next : 0;
if (Math.abs(safe - totalDurationRef.current) < 0.05) return;
totalDurationRef.current = safe;
setTotalDuration(safe);
};
const decodeMediaError = (err: MediaError | null) => {
if (!err) return "Unknown media error";
switch (err.code) {
case MediaError.MEDIA_ERR_ABORTED:
return "Playback aborted";
case MediaError.MEDIA_ERR_NETWORK:
return "Network error while loading audio";
case MediaError.MEDIA_ERR_DECODE:
return "Audio decode error";
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
return "Audio source is not supported";
default:
return `Unknown media error (${err.code})`;
}
};
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onPlay = () => {
if (isLoadingRef.current) return;
commitPlaying(true);
};
const onPause = () => {
if (isLoadingRef.current) return;
commitPlaying(false);
};
const onEnded = () => {
commitPlaying(false);
durationRef.current = 0;
setDurationState(0);
setCurrentMessageId(null);
};
const onTimeUpdate = () => {
if (isLoadingRef.current) return;
if (isSeekingRef.current) return;
if (rafTimeUpdateRef.current != null) return;
rafTimeUpdateRef.current = requestAnimationFrame(() => {
rafTimeUpdateRef.current = null;
if (!isLoadingRef.current && !isSeekingRef.current) {
commitDuration(audio.currentTime || 0);
}
});
};
const onLoadedMetadata = () => commitTotalDuration(audio.duration);
const onDurationChange = () => commitTotalDuration(audio.duration);
const onSeeked = () => {
if (isSeekingRef.current) {
isSeekingRef.current = false;
if (!isLoadingRef.current) commitDuration(audio.currentTime || 0);
return;
}
if (isLoadingRef.current) return;
commitDuration(audio.currentTime || 0);
};
const onCanPlay = () => {
if (isLoadingRef.current) isLoadingRef.current = false;
};
const onError = (_e: Event) => {
const message = decodeMediaError(audio.error);
setLastError(message);
console.error("Audio playback error", {
message,
mediaError: audio.error,
currentSrc: audio.currentSrc,
readyState: audio.readyState,
networkState: audio.networkState,
});
};
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
audio.addEventListener("ended", onEnded);
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("loadedmetadata", onLoadedMetadata);
audio.addEventListener("durationchange", onDurationChange);
audio.addEventListener("seeked", onSeeked);
audio.addEventListener("canplay", onCanPlay);
audio.addEventListener("error", onError);
return () => {
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
audio.removeEventListener("ended", onEnded);
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
audio.removeEventListener("durationchange", onDurationChange);
audio.removeEventListener("seeked", onSeeked);
audio.removeEventListener("canplay", onCanPlay);
audio.removeEventListener("error", onError);
if (rafTimeUpdateRef.current != null) {
cancelAnimationFrame(rafTimeUpdateRef.current);
rafTimeUpdateRef.current = null;
}
};
}, []);
useEffect(() => {
return () => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
};
}, []);
const playAudio = (
artist: string,
title: string,
audio: string | Blob | File,
messageId?: string | null
) => {
const el = audioRef.current;
if (!el) return;
// чтобы не было warning о неиспользуемых args при строгих правилах
void artist;
void title;
setLastError(null);
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
const audioSrc = typeof audio === "string" ? audio : URL.createObjectURL(audio);
if (typeof audio !== "string") {
objectUrlRef.current = audioSrc;
}
isLoadingRef.current = true;
isSeekingRef.current = false;
el.src = audioSrc;
durationRef.current = 0;
const msgId = messageId ?? null;
setCurrentMessageId(msgId);
if (msgId) setLastMessageId(msgId);
isPlayingRef.current = true;
setIsPlaying(true);
const prevDuration = durationRef.current;
requestAnimationFrame(() => {
if (durationRef.current === prevDuration) {
setDurationState(0);
}
});
void el.play().catch((err) => {
isLoadingRef.current = false;
commitPlaying(false);
setLastError(err instanceof Error ? err.message : "play() failed");
});
};
const pause = () => {
const el = audioRef.current;
if (!el) return;
el.pause();
};
const resume = () => {
const el = audioRef.current;
if (!el) return;
commitPlaying(true);
void el.play().catch((err) => {
commitPlaying(false);
setLastError(err instanceof Error ? err.message : "resume() failed");
});
};
const stop = () => {
const el = audioRef.current;
if (!el) return;
isLoadingRef.current = true;
el.pause();
el.currentTime = 0;
isLoadingRef.current = false;
durationRef.current = 0;
setDurationState(0);
commitPlaying(false);
setCurrentMessageId(null);
};
const setDuration = (sec: number) => {
const el = audioRef.current;
if (!el) return;
isSeekingRef.current = true;
el.currentTime = Math.max(0, sec);
commitDuration(el.currentTime || 0);
};
return (
<PlayerContext.Provider
value={{
playAudio,
playing: isPlaying,
pause,
resume,
stop,
setDuration,
duration,
totalDuration,
currentMessageId,
lastMessageId,
lastError,
}}
>
{props.children}
<audio ref={audioRef} />
</PlayerContext.Provider>
);
}

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { PlayerContext, PlayerContextValue } from "./PlayerProvider";
export function usePlayerContext() : PlayerContextValue {
const context = useContext(PlayerContext);
if (!context) {
throw new Error("useAudioPlayer must be used within a PlayerProvider");
}
return context;
}

View File

@@ -12,6 +12,14 @@ export class PacketGroupJoin extends Packet {
private groupId: string = ""; private groupId: string = "";
private groupStatus: GroupStatus = GroupStatus.NOT_JOINED; private groupStatus: GroupStatus = GroupStatus.NOT_JOINED;
/**
* Строка группы, которая содержит информацию о группе, такую как ее название, описание и ключ
* Строка зашифрована обратимым шифрованием, где ключом выступает - реальный приватный ключ
* входящего в группу клиента. Нужно это для будущей синхронзации, так как клиенту на его другом
* устройстве нужно получить ключ группы и ее информацию. Сервер расшифровать эту строку не может. Эту
* строку может расшифровать только клиент, так как она зашифрована его приватным ключом
*/
private groupString: string = "";
public getPacketId(): number { public getPacketId(): number {
return 0x14; return 0x14;
@@ -20,6 +28,7 @@ export class PacketGroupJoin extends Packet {
public _receive(stream: Stream): void { public _receive(stream: Stream): void {
this.groupId = stream.readString(); this.groupId = stream.readString();
this.groupStatus = stream.readInt8(); this.groupStatus = stream.readInt8();
this.groupString = stream.readString();
} }
public _send(): Promise<Stream> | Stream { public _send(): Promise<Stream> | Stream {
@@ -27,6 +36,7 @@ export class PacketGroupJoin extends Packet {
stream.writeInt16(this.getPacketId()); stream.writeInt16(this.getPacketId());
stream.writeString(this.groupId); stream.writeString(this.groupId);
stream.writeInt8(this.groupStatus); stream.writeInt8(this.groupStatus);
stream.writeString(this.groupString);
return stream; return stream;
} }
@@ -46,4 +56,12 @@ export class PacketGroupJoin extends Packet {
return this.groupStatus; return this.groupStatus;
} }
public setGroupString(groupString: string) {
this.groupString = groupString;
}
public getGroupString(): string {
return this.groupString;
}
} }

View File

@@ -0,0 +1,57 @@
import Packet from "../packet";
import Stream from "../stream";
export interface G365IceServer {
url: string;
username: string;
credential: string;
transport: string;
}
export class PacketIceServers extends Packet {
private iceServers: G365IceServer[] = [];
public getPacketId(): number {
return 28;
}
public _receive(stream: Stream): void {
const serversCount = stream.readInt16();
this.iceServers = [];
for(let i = 0; i < serversCount; i++){
const url = stream.readString();
const username = stream.readString();
const credential = stream.readString();
const transport = stream.readString();
this.iceServers.push({
url,
username,
credential,
transport
});
}
}
public _send(): Promise<Stream> | Stream {
const stream = new Stream();
stream.writeInt16(this.getPacketId());
stream.writeInt16(this.iceServers.length);
for(let i = 0; i < this.iceServers.length; i++){
const server = this.iceServers[i];
stream.writeString(server.url);
stream.writeString(server.username || "");
stream.writeString(server.credential || "");
stream.writeString(server.transport || "");
}
return stream;
}
public getIceServers(): G365IceServer[] {
return this.iceServers;
}
public setIceServers(servers: G365IceServer[]) {
this.iceServers = servers;
}
}

View File

@@ -5,14 +5,26 @@ export enum AttachmentType {
IMAGE = 0, IMAGE = 0,
MESSAGES = 1, MESSAGES = 1,
FILE = 2, FILE = 2,
AVATAR = 3 AVATAR = 3,
CALL = 4,
VOICE = 5
} }
/**
* Информация о транспортировке вложения, нужна для загрузки и скачивания вложений с транспортного сервера
*/
export interface AttachmentTransport {
transport_tag: string;
transport_server: string;
}
export interface Attachment { export interface Attachment {
id: string; id: string;
blob: string; blob: string;
type: AttachmentType; type: AttachmentType;
preview: string; preview: string;
transport: AttachmentTransport;
} }
export class PacketMessage extends Packet { export class PacketMessage extends Packet {
@@ -41,7 +53,7 @@ export class PacketMessage extends Packet {
this.toPublicKey = stream.readString(); this.toPublicKey = stream.readString();
this.content = stream.readString(); this.content = stream.readString();
this.chachaKey = stream.readString(); this.chachaKey = stream.readString();
this.timestamp = stream.readInt64(); this.timestamp = Number(stream.readInt64());
this.privateKey = stream.readString(); this.privateKey = stream.readString();
this.messageId = stream.readString(); this.messageId = stream.readString();
let attachmentsCount = stream.readInt8(); let attachmentsCount = stream.readInt8();
@@ -50,7 +62,11 @@ export class PacketMessage extends Packet {
let preview = stream.readString(); let preview = stream.readString();
let blob = stream.readString(); let blob = stream.readString();
let type = stream.readInt8() as AttachmentType; let type = stream.readInt8() as AttachmentType;
this.attachments.push({id, preview, type, blob}); const transport : AttachmentTransport = {
transport_tag: stream.readString(),
transport_server: stream.readString()
}
this.attachments.push({id, preview, type, blob, transport});
} }
this.aesChachaKey = stream.readString(); this.aesChachaKey = stream.readString();
} }
@@ -62,7 +78,7 @@ export class PacketMessage extends Packet {
stream.writeString(this.toPublicKey); stream.writeString(this.toPublicKey);
stream.writeString(this.content); stream.writeString(this.content);
stream.writeString(this.chachaKey); stream.writeString(this.chachaKey);
stream.writeInt64(this.timestamp); stream.writeInt64(BigInt(this.timestamp));
stream.writeString(this.privateKey); stream.writeString(this.privateKey);
stream.writeString(this.messageId); stream.writeString(this.messageId);
stream.writeInt8(this.attachments.length); stream.writeInt8(this.attachments.length);
@@ -71,6 +87,8 @@ export class PacketMessage extends Packet {
stream.writeString(this.attachments[i].preview); stream.writeString(this.attachments[i].preview);
stream.writeString(this.attachments[i].blob); stream.writeString(this.attachments[i].blob);
stream.writeInt8(this.attachments[i].type); stream.writeInt8(this.attachments[i].type);
stream.writeString(this.attachments[i].transport.transport_tag);
stream.writeString(this.attachments[i].transport.transport_server);
} }
stream.writeString(this.aesChachaKey); stream.writeString(this.aesChachaKey);
return stream; return stream;

View File

@@ -0,0 +1,131 @@
import Packet from "../packet";
import Stream from "../stream";
export enum SignalType {
CALL = 0,
KEY_EXCHANGE = 1,
ACTIVE_CALL = 2,
END_CALL = 3,
/**
* Переведен в стадию активного, значит комната на SFU уже создана и можно начинать обмен сигналами WebRTC
*/
ACTIVE = 4,
END_CALL_BECAUSE_PEER_DISCONNECTED = 5,
END_CALL_BECAUSE_BUSY = 6,
ACCEPT = 7,
RINGING_TIMEOUT = 8
}
/**
* Пакет сигналинга, для сигналов WebRTC используется отдельный пакет 27 PacketWebRTCExchange
*/
export class PacketSignalPeer extends Packet {
private src: string = "";
/**
* Назначение
*/
private dst: string = "";
/**
* Используется если SignalType == KEY_EXCHANGE, для идентификации сессии обмена ключами
*/
private sharedPublic: string = "";
private signalType: SignalType = SignalType.CALL;
private callId: string = "";
private joinToken: string = "";
public getPacketId(): number {
return 26;
}
public _receive(stream: Stream): void {
this.signalType = stream.readInt8();
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY
|| this.signalType == SignalType.RINGING_TIMEOUT
|| this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
return;
}
this.src = stream.readString();
this.dst = stream.readString();
if(this.signalType == SignalType.KEY_EXCHANGE){
this.sharedPublic = stream.readString();
}
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
this.callId = stream.readString();
this.joinToken = stream.readString();
}
}
public _send(): Promise<Stream> | Stream {
const stream = new Stream();
stream.writeInt16(this.getPacketId());
stream.writeInt8(this.signalType);
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY
|| this.signalType == SignalType.RINGING_TIMEOUT
|| this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
return stream;
}
stream.writeString(this.src);
stream.writeString(this.dst);
if(this.signalType == SignalType.KEY_EXCHANGE){
stream.writeString(this.sharedPublic);
}
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
stream.writeString(this.callId);
stream.writeString(this.joinToken);
}
return stream;
}
public setDst(dst: string) {
this.dst = dst;
}
public setSharedPublic(sharedPublic: string) {
this.sharedPublic = sharedPublic;
}
public setSignalType(signalType: SignalType) {
this.signalType = signalType;
}
public getDst(): string {
return this.dst;
}
public getSharedPublic(): string {
return this.sharedPublic;
}
public getSignalType(): SignalType {
return this.signalType;
}
public getSrc(): string {
return this.src;
}
public setSrc(src: string) {
this.src = src;
}
public getCallId(): string {
return this.callId;
}
public setCallId(callId: string) {
this.callId = callId;
}
public getJoinToken(): string {
return this.joinToken;
}
public setJoinToken(joinToken: string) {
this.joinToken = joinToken;
}
}

View File

@@ -18,14 +18,14 @@ export class PacketSync extends Packet {
public _receive(stream: Stream): void { public _receive(stream: Stream): void {
this.status = stream.readInt8() as SyncStatus; this.status = stream.readInt8() as SyncStatus;
this.timestamp = stream.readInt64(); this.timestamp = Number(stream.readInt64());
} }
public _send(): Promise<Stream> | Stream { public _send(): Promise<Stream> | Stream {
let stream = new Stream(); let stream = new Stream();
stream.writeInt16(this.getPacketId()); stream.writeInt16(this.getPacketId());
stream.writeInt8(this.status); stream.writeInt8(this.status);
stream.writeInt64(this.timestamp); stream.writeInt64(BigInt(this.timestamp));
return stream; return stream;
} }

View File

@@ -0,0 +1,52 @@
import Packet from "../packet";
import Stream from "../stream";
export enum WebRTCSignalType {
OFFER = 0,
ANSWER = 1,
ICE_CANDIDATE = 2
}
/**
* Пакет для обмена сигналами WebRTC, такими как оффер, ответ и ICE кандидаты.
* Используется на стадии WEB_RTC_EXCHANGE в сигналинге звонков.
*/
export class PacketWebRTC extends Packet {
private signalType: WebRTCSignalType = WebRTCSignalType.OFFER;
private sdpOrCandidate: string = "";
public getPacketId(): number {
return 27;
}
public _receive(stream: Stream): void {
this.signalType = stream.readInt8();
this.sdpOrCandidate = stream.readString();
}
public _send(): Promise<Stream> | Stream {
let stream = new Stream();
stream.writeInt16(this.getPacketId());
stream.writeInt8(this.signalType);
stream.writeString(this.sdpOrCandidate);
return stream;
}
public setSignalType(type: WebRTCSignalType) {
this.signalType = type;
}
public getSignalType(): WebRTCSignalType {
return this.signalType;
}
public setSdpOrCandidate(data: string) {
this.sdpOrCandidate = data;
}
public getSdpOrCandidate(): string {
return this.sdpOrCandidate;
}
}

View File

@@ -25,6 +25,9 @@ import { PacketDeviceNew } from "./packets/packet.device.new";
import { PacketDeviceList } from "./packets/packet.device.list"; import { PacketDeviceList } from "./packets/packet.device.list";
import { PacketDeviceResolve } from "./packets/packet.device.resolve"; import { PacketDeviceResolve } from "./packets/packet.device.resolve";
import { PacketSync } from "./packets/packet.sync"; import { PacketSync } from "./packets/packet.sync";
import { PacketSignalPeer } from "./packets/packet.signal.peer";
import { PacketWebRTC } from "./packets/packet.webrtc";
import { PacketIceServers } from "./packets/packet.ice.servers";
export default class Protocol extends EventEmitter { export default class Protocol extends EventEmitter {
private serverAddress: string; private serverAddress: string;
@@ -125,6 +128,9 @@ export default class Protocol extends EventEmitter {
this._supportedPackets.set(0x17, new PacketDeviceList()); this._supportedPackets.set(0x17, new PacketDeviceList());
this._supportedPackets.set(0x18, new PacketDeviceResolve()); this._supportedPackets.set(0x18, new PacketDeviceResolve());
this._supportedPackets.set(25, new PacketSync()); this._supportedPackets.set(25, new PacketSync());
this._supportedPackets.set(26, new PacketSignalPeer());
this._supportedPackets.set(27, new PacketWebRTC());
this._supportedPackets.set(28, new PacketIceServers());
} }
private _findWaiters(packetId: number): ((packet: Packet) => void)[] { private _findWaiters(packetId: number): ((packet: Packet) => void)[] {

View File

@@ -1,151 +1,372 @@
export default class Stream { export default class Stream {
private stream: Uint8Array;
private readPointer = 0; // bits
private writePointer = 0; // bits
private _stream: number[]; constructor(stream?: Uint8Array | number[]) {
private _readPoiner: number = 0; if (!stream) {
private _writePointer: number = 0; this.stream = new Uint8Array(0);
} else {
const src = stream instanceof Uint8Array ? stream : Uint8Array.from(stream);
this.stream = src;
this.writePointer = this.stream.length << 3;
}
}
constructor(stream : number[] = []) { getStream(): Uint8Array {
this._stream = stream; return this.stream.slice(0, this.length());
}
setStream(stream?: Uint8Array | number[]) {
if (!stream) {
this.stream = new Uint8Array(0);
this.readPointer = 0;
this.writePointer = 0;
return;
}
const src = stream instanceof Uint8Array ? stream : Uint8Array.from(stream);
this.stream = src;
this.readPointer = 0;
this.writePointer = this.stream.length << 3;
}
getBuffer(): Uint8Array {
return this.getStream();
}
isEmpty(): boolean {
return this.writePointer === 0;
}
length(): number {
return (this.writePointer + 7) >> 3;
}
// ---------- bit / boolean ----------
writeBit(value: number) {
this.writeBits(BigInt(value & 1), 1);
}
readBit(): number {
return Number(this.readBits(1));
}
writeBoolean(value: boolean) {
this.writeBit(value ? 1 : 0);
}
readBoolean(): boolean {
return this.readBit() === 1;
}
// ---------- byte ----------
writeByte(b: number) {
this.writeUInt8(b & 0xff);
}
readByte(): number {
const v = this.readUInt8();
return (v << 24) >> 24; // signed byte
}
// ---------- UInt / Int 8 ----------
writeUInt8(value: number) {
const v = value & 0xff;
if ((this.writePointer & 7) === 0) {
this.reserveBits(8);
this.stream[this.writePointer >> 3] = v;
this.writePointer += 8;
return;
} }
public getStream(): number[] { this.writeBits(BigInt(v), 8);
return this._stream; }
readUInt8(): number {
if (this.remainingBits() < 8n) {
throw new Error("Not enough bits to read UInt8");
} }
public setStream(stream: number[]) { if ((this.readPointer & 7) === 0) {
this._stream = stream; const v = this.stream[this.readPointer >> 3] & 0xff;
this.readPointer += 8;
return v;
} }
public writeInt8(value: number) { return Number(this.readBits(8));
const negationBit = value < 0 ? 1 : 0; }
const int8Value = Math.abs(value) & 0xFF;
this._stream[this._writePointer >> 3] |= negationBit << (7 - (this._writePointer & 7)); writeInt8(value: number) {
this._writePointer++; this.writeUInt8(value);
for (let i = 0; i < 8; i++) { }
const bit = (int8Value >> (7 - i)) & 1;
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7)); readInt8(): number {
this._writePointer++; const u = this.readUInt8();
} return (u << 24) >> 24;
}
// ---------- UInt / Int 16 ----------
writeUInt16(value: number) {
const v = value & 0xffff;
this.writeUInt8((v >>> 8) & 0xff);
this.writeUInt8(v & 0xff);
}
readUInt16(): number {
const hi = this.readUInt8();
const lo = this.readUInt8();
return (hi << 8) | lo;
}
writeInt16(value: number) {
this.writeUInt16(value);
}
readInt16(): number {
const u = this.readUInt16();
return (u << 16) >> 16;
}
// ---------- UInt / Int 32 ----------
writeUInt32(value: number) {
if (!Number.isFinite(value) || value < 0 || value > 0xffffffff) {
throw new Error(`UInt32 out of range: ${value}`);
} }
public readInt8(): number { const v = Math.floor(value);
let value = 0; this.writeUInt8((v >>> 24) & 0xff);
const negationBit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1; this.writeUInt8((v >>> 16) & 0xff);
this._readPoiner++; this.writeUInt8((v >>> 8) & 0xff);
for (let i = 0; i < 8; i++) { this.writeUInt8(v & 0xff);
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1; }
value |= bit << (7 - i);
this._readPoiner++; readUInt32(): number {
} const b1 = this.readUInt8();
return negationBit ? -value : value; const b2 = this.readUInt8();
const b3 = this.readUInt8();
const b4 = this.readUInt8();
return (((b1 * 0x1000000) + (b2 << 16) + (b3 << 8) + b4) >>> 0);
}
writeInt32(value: number) {
this.writeUInt32(value >>> 0);
}
readInt32(): number {
return this.readUInt32() | 0;
}
// ---------- UInt / Int 64 ----------
writeUInt64(value: bigint) {
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) {
throw new Error(`UInt64 out of range: ${value.toString()}`);
} }
public writeBit(value: number) { this.writeUInt8(Number((value >> 56n) & 0xffn));
const bit = value & 1; this.writeUInt8(Number((value >> 48n) & 0xffn));
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7)); this.writeUInt8(Number((value >> 40n) & 0xffn));
this._writePointer++; this.writeUInt8(Number((value >> 32n) & 0xffn));
this.writeUInt8(Number((value >> 24n) & 0xffn));
this.writeUInt8(Number((value >> 16n) & 0xffn));
this.writeUInt8(Number((value >> 8n) & 0xffn));
this.writeUInt8(Number(value & 0xffn));
}
readUInt64(): bigint {
const high = BigInt(this.readUInt32() >>> 0);
const low = BigInt(this.readUInt32() >>> 0);
return (high << 32n) | low;
}
writeInt64(value: bigint) {
const u = BigInt.asUintN(64, value);
this.writeUInt64(u);
}
readInt64(): bigint {
return BigInt.asIntN(64, this.readUInt64());
}
// ---------- float ----------
writeFloat32(value: number) {
const ab = new ArrayBuffer(4);
const dv = new DataView(ab);
dv.setFloat32(0, value, false); // big-endian
this.writeUInt8(dv.getUint8(0));
this.writeUInt8(dv.getUint8(1));
this.writeUInt8(dv.getUint8(2));
this.writeUInt8(dv.getUint8(3));
}
readFloat32(): number {
const ab = new ArrayBuffer(4);
const dv = new DataView(ab);
dv.setUint8(0, this.readUInt8());
dv.setUint8(1, this.readUInt8());
dv.setUint8(2, this.readUInt8());
dv.setUint8(3, this.readUInt8());
return dv.getFloat32(0, false); // big-endian
}
// ---------- string / bytes ----------
// String: length(UInt32) + chars(UInt16), как в Java charAt()
writeString(value: string | null | undefined) {
const s = value ?? "";
this.writeUInt32(s.length);
if (s.length === 0) return;
this.reserveBits(BigInt(s.length) * 16n);
for (let i = 0; i < s.length; i++) {
this.writeUInt16(s.charCodeAt(i) & 0xffff);
}
}
readString(): string {
const len = this.readUInt32();
if (len > 0x7fffffff) {
throw new Error(`String length too large: ${len}`);
} }
public readBit(): number { const requiredBits = BigInt(len) * 16n;
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1; if (requiredBits > this.remainingBits()) {
this._readPoiner++; throw new Error("Not enough bits to read string");
return bit;
} }
public writeBoolean(value: boolean) { const chars = new Array<number>(len);
this.writeBit(value ? 1 : 0); for (let i = 0; i < len; i++) {
chars[i] = this.readUInt16();
}
return String.fromCharCode(...chars);
}
// byte[]: length(UInt32) + payload
writeBytes(value: Uint8Array | number[] | null | undefined) {
const arr = value == null
? new Uint8Array(0)
: (value instanceof Uint8Array ? value : Uint8Array.from(value));
this.writeUInt32(arr.length);
if (arr.length === 0) return;
this.reserveBits(BigInt(arr.length) * 8n);
if ((this.writePointer & 7) === 0) {
const byteIndex = this.writePointer >> 3;
this.ensureCapacity(byteIndex + arr.length - 1);
this.stream.set(arr, byteIndex);
this.writePointer += arr.length << 3;
return;
} }
public readBoolean(): boolean { for (let i = 0; i < arr.length; i++) {
return this.readBit() === 1; this.writeUInt8(arr[i]);
}
}
readBytes(): Uint8Array {
const len = this.readUInt32();
if (len === 0) return new Uint8Array(0);
const requiredBits = BigInt(len) * 8n;
if (requiredBits > this.remainingBits()) {
return new Uint8Array(0);
} }
public writeInt16(value: number) { const out = new Uint8Array(len);
this.writeInt8(value >> 8);
this.writeInt8(value & 0xFF); if ((this.readPointer & 7) === 0) {
const byteIndex = this.readPointer >> 3;
out.set(this.stream.slice(byteIndex, byteIndex + len));
this.readPointer += len << 3;
return out;
} }
public readInt16(): number { for (let i = 0; i < len; i++) {
const value = this.readInt8() << 8; out[i] = this.readUInt8();
return value | this.readInt8(); }
return out;
}
// ---------- internals ----------
private remainingBits(): bigint {
return BigInt(this.writePointer - this.readPointer);
}
private writeBits(value: bigint, bits: number) {
if (bits <= 0) return;
this.reserveBits(bits);
for (let i = bits - 1; i >= 0; i--) {
const bit = Number((value >> BigInt(i)) & 1n);
const byteIndex = this.writePointer >> 3;
const shift = 7 - (this.writePointer & 7);
if (bit === 1) {
this.stream[byteIndex] = this.stream[byteIndex] | (1 << shift);
} else {
this.stream[byteIndex] = this.stream[byteIndex] & ~(1 << shift);
}
this.writePointer++;
}
}
private readBits(bits: number): bigint {
if (bits <= 0) return 0n;
if (this.remainingBits() < BigInt(bits)) {
throw new Error("Not enough bits to read");
} }
public writeInt32(value: number) { let value = 0n;
this.writeInt16(value >> 16); for (let i = 0; i < bits; i++) {
this.writeInt16(value & 0xFFFF); const bit = (this.stream[this.readPointer >> 3] >> (7 - (this.readPointer & 7))) & 1;
value = (value << 1n) | BigInt(bit);
this.readPointer++;
}
return value;
}
private reserveBits(bitsToWrite: number | bigint) {
const bits = typeof bitsToWrite === "number" ? BigInt(bitsToWrite) : bitsToWrite;
if (bits <= 0n) return;
const lastBitIndex = BigInt(this.writePointer) + bits - 1n;
if (lastBitIndex < 0n) throw new Error("Bit index overflow");
const byteIndex = lastBitIndex >> 3n;
if (byteIndex > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error("Stream too large");
} }
public readInt32(): number { this.ensureCapacity(Number(byteIndex));
const value = this.readInt16() << 16; }
return value | this.readInt16();
} private ensureCapacity(byteIndex: number) {
const requiredSize = byteIndex + 1;
public writeInt64(value: number) { if (requiredSize <= this.stream.length) return;
const high = Math.floor(value / 0x100000000);
const low = value >>> 0; let newSize = this.stream.length === 0 ? 32 : this.stream.length;
this.writeInt32(high); while (newSize < requiredSize) {
this.writeInt32(low); if (newSize > (0x7fffffff >> 1)) {
} newSize = requiredSize;
break;
public readInt64(): number { }
const high = this.readInt32(); newSize <<= 1;
const low = this.readInt32() >>> 0;
return high * 0x100000000 + low;
}
public writeFloat32(value: number) {
const buffer = new ArrayBuffer(4);
new DataView(buffer).setFloat32(0, value, true);
const float32Value = new Uint32Array(buffer)[0];
this.writeInt32(float32Value);
}
public readFloat32(): number {
const float32Value = this.readInt32();
const buffer = new ArrayBuffer(4);
new Uint32Array(buffer)[0] = float32Value;
return new DataView(buffer).getFloat32(0, true);
}
public writeString(value: string) {
let length = value.length;
this.writeInt32(length);
for (let i = 0; i < value.length; i++) {
this.writeInt16(value.charCodeAt(i));
}
}
public readString(): string {
let length = this.readInt32();
/**
* Фикс уязвимости с длинной строки, превышающей
* возможность для чтения _stream
*/
if (length < 0 || length > (this._stream.length - (this._readPoiner >> 3))) {
console.info("Stream readString length invalid", length, this._stream.length, this._readPoiner);
return "";
}
let value = "";
for (let i = 0; i < length; i++) {
value += String.fromCharCode(this.readInt16());
}
return value;
}
public writeBytes(value: number[]) {
this.writeInt32(value.length);
for (let i = 0; i < value.length; i++) {
this.writeInt8(value[i]);
}
}
public readBytes(): number[] {
let length = this.readInt32();
let value : any = [];
for (let i = 0; i < length; i++) {
value.push(this.readInt8());
}
return value;
} }
const next = new Uint8Array(newSize);
next.set(this.stream);
this.stream = next;
}
} }

View File

@@ -3,7 +3,7 @@ import { createContext } from "react";
import { useSystemAccount } from "./useSystemAccount"; import { useSystemAccount } from "./useSystemAccount";
import { usePublicKey } from "../AccountProvider/usePublicKey"; import { usePublicKey } from "../AccountProvider/usePublicKey";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain"; 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 { generateRandomKey } from "@/app/utils/utils";
import { DeliveredMessageState } from "../DialogProvider/DialogProvider"; import { DeliveredMessageState } from "../DialogProvider/DialogProvider";
import { UserInformation } from "../InformationProvider/InformationProvider"; import { UserInformation } from "../InformationProvider/InformationProvider";

View File

@@ -1,7 +1,8 @@
import { decodeWithPassword, encodeWithPassword } from "@/app/crypto/crypto"; import { decodeWithPassword, encodeWithPassword } from "@/app/workers/crypto/crypto";
import { useFileStorage } from "@/app/hooks/useFileStorage"; import { useFileStorage } from "@/app/hooks/useFileStorage";
import { generateRandomKey } from "@/app/utils/utils"; import { generateRandomKey } from "@/app/utils/utils";
import { createContext, useEffect, useState } from "react"; import { createContext, useEffect, useState } from "react";
import { useCore } from "@/app/hooks/useCore";
interface SystemProviderContextValue { interface SystemProviderContextValue {
id: string; id: string;
@@ -21,7 +22,10 @@ export interface SystemProviderProps {
*/ */
export function SystemProvider(props: SystemProviderProps) { export function SystemProvider(props: SystemProviderProps) {
const [deviceId, setDeviceId] = useState<string>(""); const [deviceId, setDeviceId] = useState<string>("");
const [deviceName, setDeviceName] = useState<string>("");
const [deviceOs, setDeviceOs] = useState<string>("");
const {writeFile, readFile} = useFileStorage(); const {writeFile, readFile} = useFileStorage();
const { getDeviceId, getDeviceName, getPlatform } = useCore();
useEffect(() => { useEffect(() => {
fetchDeviceId(); fetchDeviceId();
@@ -29,6 +33,10 @@ export function SystemProvider(props: SystemProviderProps) {
const fetchDeviceId = async () => { const fetchDeviceId = async () => {
const device = await readFile("device"); const device = await readFile("device");
const name = await getDeviceName();
const platform = await getPlatform();
setDeviceName(name);
setDeviceOs(platform);
if(device){ if(device){
const decoded = await decodeDevice(Buffer.from(device).toString('utf-8')); const decoded = await decodeDevice(Buffer.from(device).toString('utf-8'));
if(decoded){ if(decoded){
@@ -47,12 +55,11 @@ export function SystemProvider(props: SystemProviderProps) {
} }
const decodeDevice = async (data: string) => { const decodeDevice = async (data: string) => {
const hwid = window.deviceId; const hwid = await getDeviceId();
const platform = window.deviceName; const deviceName = await getDeviceName();
const salt = "rosetta-device-salt"; const salt = "rosetta-device-salt";
try { try {
const decoded = await decodeWithPassword(hwid + platform + salt, data); const decoded = await decodeWithPassword(hwid + deviceName + salt, data);
return decoded; return decoded;
} catch (e) { } catch (e) {
console.error("Failed to decode device data:", e); console.error("Failed to decode device data:", e);
@@ -61,12 +68,12 @@ export function SystemProvider(props: SystemProviderProps) {
} }
const encodeDevice = async (data: string) => { const encodeDevice = async (data: string) => {
const hwid = window.deviceId; const hwid = await getDeviceId();
const platform = window.deviceName; const deviceName = await getDeviceName();
const salt = "rosetta-device-salt"; const salt = "rosetta-device-salt";
try { try {
const encoded = await encodeWithPassword(hwid + platform + salt, data); const encoded = await encodeWithPassword(hwid + deviceName + salt, data);
return encoded; return encoded;
} catch (e) { } catch (e) {
console.error("Failed to encode device data:", e); console.error("Failed to encode device data:", e);
@@ -74,16 +81,11 @@ export function SystemProvider(props: SystemProviderProps) {
} }
} }
const systemName = window.deviceName || "Unknown Device";
const systemOs = window.platform || "Unknown OS";
return ( return (
<SystemProviderContext.Provider value={{ <SystemProviderContext.Provider value={{
id: deviceId, id: deviceId,
name: systemName, name: deviceName || "Unknown Device",
os: systemOs os: deviceOs || "Unknown OS"
}}> }}>
{props.children} {props.children}
</SystemProviderContext.Provider> </SystemProviderContext.Provider>

View File

@@ -7,7 +7,7 @@ import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
interface TransportContextValue { interface TransportContextValue {
transportServer: string | null; transportServer: string | null;
uploadFile: (id: string, content: string) => Promise<any>; uploadFile: (id: string, content: string) => Promise<any>;
downloadFile: (id: string, tag: string) => Promise<string>; downloadFile: (id: string, tag: string, transportServer: string) => Promise<string>;
uploading: TransportState[]; uploading: TransportState[];
downloading: TransportState[]; downloading: TransportState[];
} }
@@ -86,14 +86,14 @@ export function TransportProvider(props: TransportProviderProps) {
* @param tag тег файла * @param tag тег файла
* @param chachaDecryptedKey ключ для расшифровки файла * @param chachaDecryptedKey ключ для расшифровки файла
*/ */
const downloadFile = (id: string, tag : string) : Promise<string> => { const downloadFile = (id: string, tag : string, transportServer: string) : Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!transportServerRef.current) { if (!transportServer) {
throw new Error("Transport server is not set"); throw new Error("Transport server is not set");
} }
setDownloading(prev => [...prev, { id: id, progress: 0 }]); setDownloading(prev => [...prev, { id: id, progress: 0 }]);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('GET', `${transportServerRef.current}/d/${tag}`); xhr.open('GET', `${transportServer}/d/${tag}`);
xhr.responseType = 'text'; xhr.responseType = 'text';
xhr.onprogress = (event) => { xhr.onprogress = (event) => {

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
import { TransportContext } from "./TransportProvider";
export function useTransportServer() {
const context = useContext(TransportContext);
if(!context){
throw new Error("useTransportServer must be used within a TransportProvider");
}
const { transportServer } = context;
return transportServer;
}

View File

@@ -4,8 +4,8 @@ import { useSender } from "../ProtocolProvider/useSender";
import { usePacket } from "../ProtocolProvider/usePacket"; import { usePacket } from "../ProtocolProvider/usePacket";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger"; import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { useFileStorage } from "@/app/hooks/useFileStorage"; import { useFileStorage } from "@/app/hooks/useFileStorage";
import { APPLICATION_ARCH, APPLICATION_PLATFROM, CORE_VERSION } from "@/app/constants";
import { APP_VERSION } from "@/app/version"; import { APP_VERSION } from "@/app/version";
import { useCore } from "@/app/hooks/useCore";
export interface UpdateProviderProps { export interface UpdateProviderProps {
children: React.ReactNode; children: React.ReactNode;
@@ -39,6 +39,7 @@ export interface UpdateContextValue {
checkForUpdates: () => void; checkForUpdates: () => void;
downloadLastApplicationUpdate: () => void; downloadLastApplicationUpdate: () => void;
restartAppForUpdateApply: () => void; restartAppForUpdateApply: () => void;
updateServer: string | null;
} }
export const UpdateProviderContext = createContext<UpdateContextValue | null>(null); export const UpdateProviderContext = createContext<UpdateContextValue | null>(null);
@@ -57,6 +58,7 @@ export function UpdateProvider(props: UpdateProviderProps) {
const [appUpdateUrl, setAppUpdateUrl] = useState<string>(""); const [appUpdateUrl, setAppUpdateUrl] = useState<string>("");
const [appActualVersion, setAppActualVersion] = useState<string>(""); const [appActualVersion, setAppActualVersion] = useState<string>("");
const {writeFile} = useFileStorage(); const {writeFile} = useFileStorage();
const {getCoreVersion, getArch, getPlatform} = useCore();
useEffect(() => { useEffect(() => {
let packet = new PacketRequestUpdate(); let packet = new PacketRequestUpdate();
@@ -74,6 +76,9 @@ export function UpdateProvider(props: UpdateProviderProps) {
}, []); }, []);
const checkForUpdates = async () => { const checkForUpdates = async () => {
const coreVersion = await getCoreVersion();
const arch = await getArch();
const platform = await getPlatform();
if(updateServerRef.current == null){ if(updateServerRef.current == null){
/** /**
* SDU еще не определен * SDU еще не определен
@@ -84,7 +89,7 @@ export function UpdateProvider(props: UpdateProviderProps) {
* Запрашиваем обновления с SDU сервера * Запрашиваем обновления с SDU сервера
*/ */
let response = await fetch let response = await fetch
(`${updateServerRef.current}/updates/get?app=${APP_VERSION}&kernel=${CORE_VERSION}&arch=${APPLICATION_ARCH}&platform=${APPLICATION_PLATFROM}`).catch((e) => { (`${updateServerRef.current}/updates/get?app=${APP_VERSION}&kernel=${coreVersion}&arch=${arch}&platform=${platform}`).catch((e) => {
error("Failed to check for updates: " + e.message); error("Failed to check for updates: " + e.message);
}); });
if(!response || response.status != 200){ if(!response || response.status != 200){
@@ -167,7 +172,8 @@ export function UpdateProvider(props: UpdateProviderProps) {
kernelUpdateUrl, kernelUpdateUrl,
checkForUpdates, checkForUpdates,
downloadLastApplicationUpdate, downloadLastApplicationUpdate,
restartAppForUpdateApply restartAppForUpdateApply,
updateServer: updateServerRef.current
}}> }}>
{props.children} {props.children}
</UpdateProviderContext.Provider> </UpdateProviderContext.Provider>

View File

@@ -1,7 +1,7 @@
export const SERVERS = [ export const SERVERS = [
//'wss://cdn.rosetta-im.com', //'wss://cdn.rosetta-im.com',
//'ws://10.211.55.2:3000', //'ws://10.211.55.2:3000',
//'ws://127.0.0.1:3000', //'ws://192.168.6.82:3000',
'wss://wss.rosetta.im' 'wss://wss.rosetta.im'
]; ];

View File

@@ -15,6 +15,10 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
return "$a=File"; return "$a=File";
case AttachmentType.AVATAR: case AttachmentType.AVATAR:
return "$a=Avatar"; return "$a=Avatar";
case AttachmentType.CALL:
return "$a=Call";
case AttachmentType.VOICE:
return "$a=Voice message";
default: default:
return "[Unsupported attachment]"; return "[Unsupported attachment]";
} }

View File

@@ -1,6 +1,5 @@
import { MantineColor } from "@mantine/core"; import { MantineColor } from "@mantine/core";
import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "../constants"; import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "../constants";
import { decode, encode } from "blurhash";
export function generateRandomKey(length: number): string { export function generateRandomKey(length: number): string {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@@ -243,47 +242,3 @@ export function isImage(blob : string) : boolean {
} }
return blob.startsWith('data:image/'); 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,11 +1,7 @@
export const APP_VERSION = "1.0.2"; export const APP_VERSION = "1.2.2";
export const CORE_MIN_REQUIRED_VERSION = "1.4.9"; export const CORE_MIN_REQUIRED_VERSION = "1.5.5";
export const RELEASE_NOTICE = ` export const RELEASE_NOTICE = `
**Update v1.0.2** :emoji_1f631: **Обновление v1.2.2** :emoji_1f631:
- Support multiple file downloads - Поддержка записи и прослушивания голосовых сообщений
- Fix fallback after boot loading
- Fix corss-chat reading messages
- Support sync attachments on other devices
- Fix UI bugs
`; `;

View File

@@ -3,7 +3,7 @@ import { InternalScreen } from "@/app/components/InternalScreen/InternalScreen";
import { SettingsAlert } from "@/app/components/SettingsAlert/SettingsAlert"; import { SettingsAlert } from "@/app/components/SettingsAlert/SettingsAlert";
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput"; import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
import { TextChain } from "@/app/components/TextChain/TextChain"; 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 { useAccount } from "@/app/providers/AccountProvider/useAccount";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";

View File

@@ -9,12 +9,13 @@ import { useEffect } from "react";
import { useViewPanelsState, ViewPanelsState } from "@/app/hooks/useViewPanelsState"; import { useViewPanelsState, ViewPanelsState } from "@/app/hooks/useViewPanelsState";
import { GroupHeader } from "@/app/components/GroupHeader/GroupHeader"; import { GroupHeader } from "@/app/components/GroupHeader/GroupHeader";
import { useGroups } from "@/app/providers/DialogProvider/useGroups"; import { useGroups } from "@/app/providers/DialogProvider/useGroups";
import { ActiveCall } from "@/app/components/ActiveCall/ActiveCall";
export function Chat() { export function Chat() {
const params = useParams(); const params = useParams();
const dialog = params.id || "DELETED"; const dialog = params.id || "DELETED";
const {lg} = useRosettaBreakpoints(); const {lg} = useRosettaBreakpoints();
const [__, setViewState] = useViewPanelsState(); const [viewState, setViewState] = useViewPanelsState();
const {hasGroup} = useGroups(); const {hasGroup} = useGroups();
useEffect(() => { useEffect(() => {
@@ -30,6 +31,9 @@ export function Chat() {
return (<> return (<>
<DialogProvider dialog={dialog} key={dialog}> <DialogProvider dialog={dialog} key={dialog}>
<Flex direction={'column'} justify={'space-between'} h={'100%'}> <Flex direction={'column'} justify={'space-between'} h={'100%'}>
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && (
<ActiveCall></ActiveCall>
)}
{/* Group Header */} {/* Group Header */}
{hasGroup(dialog) && <GroupHeader></GroupHeader>} {hasGroup(dialog) && <GroupHeader></GroupHeader>}
{/* Dialog peer to peer Header */} {/* Dialog peer to peer Header */}

View File

@@ -10,11 +10,14 @@ import { AnimatedButton } from "@/app/components/AnimatedButton/AnimatedButton";
import { useLogout } from "@/app/providers/AccountProvider/useLogout"; import { useLogout } from "@/app/providers/AccountProvider/useLogout";
import { usePacket } from "@/app/providers/ProtocolProvider/usePacket"; import { usePacket } from "@/app/providers/ProtocolProvider/usePacket";
import { PacketDeviceResolve, Solution } from "@/app/providers/ProtocolProvider/protocol/packets/packet.device.resolve"; import { PacketDeviceResolve, Solution } from "@/app/providers/ProtocolProvider/protocol/packets/packet.device.resolve";
import { useCoreDevice } from "@/app/providers/DeviceProvider/useCoreDevice";
export function DeviceConfirm() { export function DeviceConfirm() {
const [protocolState] = useProtocolState(); const [protocolState] = useProtocolState();
const navigate = useNavigate(); const navigate = useNavigate();
const logout = useLogout(); const logout = useLogout();
const {deviceName} = useCoreDevice();
useEffect(() => { useEffect(() => {
if(protocolState == ProtocolState.CONNECTED) { if(protocolState == ProtocolState.CONNECTED) {
@@ -60,7 +63,7 @@ export function DeviceConfirm() {
<Flex justify={'center'} mt={'xl'} px={'lg'} align={'center'}> <Flex justify={'center'} mt={'xl'} px={'lg'} align={'center'}>
<Flex justify={'center'} gap={'sm'} align={'center'}> <Flex justify={'center'} gap={'sm'} align={'center'}>
<Text ta={'center'} c={'dimmed'} fz={12}> <Text ta={'center'} c={'dimmed'} fz={12}>
Confirm device <strong>{window.deviceName}</strong> on your first device to loading your chats. Confirm device <strong>{deviceName}</strong> on your first device to loading your chats.
</Text> </Text>
</Flex> </Flex>
</Flex> </Flex>

View File

@@ -3,7 +3,7 @@ import classes from './Lockscreen.module.css'
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import useWindow from "@/app/hooks/useWindow"; 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 { useAccountProvider } from "@/app/providers/AccountProvider/useAccountProvider";
import { Account, AccountBase } from "@/app/providers/AccountProvider/AccountProvider"; import { Account, AccountBase } from "@/app/providers/AccountProvider/AccountProvider";
import { useUserCache } from "@/app/providers/InformationProvider/useUserCache"; import { useUserCache } from "@/app/providers/InformationProvider/useUserCache";

View File

@@ -31,6 +31,7 @@ import { useUpdateMessage } from "@/app/hooks/useUpdateMessage";
import { useDeviceMessage } from "@/app/hooks/useDeviceMessage"; import { useDeviceMessage } from "@/app/hooks/useDeviceMessage";
import { UpdateProvider } from "@/app/providers/UpdateProvider/UpdateProvider"; import { UpdateProvider } from "@/app/providers/UpdateProvider/UpdateProvider";
import { useSynchronize } from "@/app/providers/DialogProvider/useSynchronize"; import { useSynchronize } from "@/app/providers/DialogProvider/useSynchronize";
import { CallProvider } from "@/app/providers/CallProvider/CallProvider";
export function Main() { export function Main() {
const { mainColor, borderColor } = useRosettaColors(); const { mainColor, borderColor } = useRosettaColors();
@@ -154,52 +155,56 @@ export function Main() {
<SystemAccountProvider> <SystemAccountProvider>
<TransportProvider> <TransportProvider>
<UpdateProvider> <UpdateProvider>
<Flex direction={'row'} style={{ <CallProvider>
height: '100%', <Flex direction={'row'} style={{
width: '100vw', height: '100%',
}}> width: '100vw',
<div style={{
display: viewState != ViewPanelsState.DIALOGS_PANEL_HIDE ? 'block' : 'none',
width: viewState == ViewPanelsState.DIALOGS_PANEL_ONLY ? '100%' : '300px',
}}> }}>
<DialogsPanel></DialogsPanel> <div style={{
</div> display: viewState != ViewPanelsState.DIALOGS_PANEL_HIDE ? 'block' : 'none',
<Divider color={borderColor} orientation={'vertical'}></Divider> width: viewState == ViewPanelsState.DIALOGS_PANEL_ONLY ? '100%' : '300px',
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && <Box }}>
bg={mainColor} <DialogsPanel></DialogsPanel>
style={{ </div>
flexGrow: 1, {lg && (
height: 'calc(100vh - 27px)', <Divider color={borderColor} orientation={'vertical'}></Divider>
width: `calc(100% - 300px)`, )}
minWidth: 0 {viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && <Box
}} bg={mainColor}
> style={{
<Routes> flexGrow: 1,
<Route path={'/chat/:id'} element={<Chat />}></Route> height: 'calc(100vh - 27px)',
<Route path={'/profile/:id'} element={<Profile />}></Route> width: `calc(100% - 300px)`,
<Route path={'/'} element={<DialogPreview />}></Route> minWidth: 0
<Route path={'/theme'} element={<Theme />}></Route> }}
<Route path={'/safety'} element={<Safety />}></Route> >
<Route path={'/update'} element={<Update />}></Route> <Routes>
<Route path={'/backup'} element={<Backup />}></Route> <Route path={'/chat/:id'} element={<Chat />}></Route>
<Route path={'/dialogs'} element={<Dialogs />}></Route> <Route path={'/profile/:id'} element={<Profile />}></Route>
<Route path={'/newgroup'} element={<CreateGroup />}></Route> <Route path={'/'} element={<DialogPreview />}></Route>
<Route path={'/group/:id'} element={<GroupInfo />}></Route> <Route path={'/theme'} element={<Theme />}></Route>
<Route path={'/groupencrypt/:key'} element={<GroupEncryption />}></Route> <Route path={'/safety'} element={<Safety />}></Route>
</Routes> <Route path={'/update'} element={<Update />}></Route>
</Box>} <Route path={'/backup'} element={<Backup />}></Route>
</Flex> <Route path={'/dialogs'} element={<Dialogs />}></Route>
{oldPublicKey && ( <Route path={'/newgroup'} element={<CreateGroup />}></Route>
<Overlay blur={8} color="#333"> <Route path={'/group/:id'} element={<GroupInfo />}></Route>
<Flex direction={'column'} align={'center'} justify={'center'} h={'100%'}> <Route path={'/groupencrypt/:key'} element={<GroupEncryption />}></Route>
<Alert w={400} variant="filled" color="red" title="Old account"> </Routes>
Your account uses an old format public key which is no longer supported. Please create a new account to continue using the application. </Box>}
<br></br>After press "OK" button, the application will close and remove all data. </Flex>
</Alert> {oldPublicKey && (
<Button w={400} mt={'md'} color="red" onClick={dropAccountsAndMessages}>OK</Button> <Overlay blur={8} color="#333">
</Flex> <Flex direction={'column'} align={'center'} justify={'center'} h={'100%'}>
</Overlay> <Alert w={400} variant="filled" color="red" title="Old account">
)} Your account uses an old format public key which is no longer supported. Please create a new account to continue using the application.
<br></br>After press "OK" button, the application will close and remove all data.
</Alert>
<Button w={400} mt={'md'} color="red" onClick={dropAccountsAndMessages}>OK</Button>
</Flex>
</Overlay>
)}
</CallProvider>
</UpdateProvider> </UpdateProvider>
</TransportProvider> </TransportProvider>
</SystemAccountProvider> </SystemAccountProvider>

View File

@@ -7,7 +7,7 @@ import { mnemonicToSeed } from "web-bip39";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { Buffer } from 'buffer' 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 { useAccountProvider } from "@/app/providers/AccountProvider/useAccountProvider";
import { Account } from "@/app/providers/AccountProvider/AccountProvider"; import { Account } from "@/app/providers/AccountProvider/AccountProvider";
import { useRosettaColors } from "@/app/hooks/useRosettaColors"; import { useRosettaColors } from "@/app/hooks/useRosettaColors";

View File

@@ -4,14 +4,25 @@ import { RosettaPower } from "@/app/components/RosettaPower/RosettaPower";
import { SettingsAlert } from "@/app/components/SettingsAlert/SettingsAlert"; import { SettingsAlert } from "@/app/components/SettingsAlert/SettingsAlert";
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput"; import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
import { UpdateAlert } from "@/app/components/UpdateAlert/UpdateAlert"; import { UpdateAlert } from "@/app/components/UpdateAlert/UpdateAlert";
import { CORE_VERSION } from "@/app/constants"; import { useCore } from "@/app/hooks/useCore";
import { UpdateStatus } from "@/app/providers/UpdateProvider/UpdateProvider"; import { UpdateStatus } from "@/app/providers/UpdateProvider/UpdateProvider";
import { useUpdater } from "@/app/providers/UpdateProvider/useUpdater"; import { useUpdater } from "@/app/providers/UpdateProvider/useUpdater";
import { APP_VERSION } from "@/app/version"; import { APP_VERSION } from "@/app/version";
import { Box, Text } from "@mantine/core"; import { Box, Text } from "@mantine/core";
import { useEffect, useState } from "react";
export function Update() { export function Update() {
const {updateStatus} = useUpdater(); const {updateStatus} = useUpdater();
const {getCoreVersion} = useCore();
const [coreVersion, setCoreVersion] = useState<string>("");
useEffect(() => {
const fetchCoreVersion = async () => {
const version = await getCoreVersion();
setCoreVersion(version);
}
fetchCoreVersion();
}, [getCoreVersion]);
return ( return (
<> <>
@@ -23,7 +34,7 @@ export function Update() {
<Box mt={'sm'}> <Box mt={'sm'}>
<UpdateAlert radius={'sm'}></UpdateAlert> <UpdateAlert radius={'sm'}></UpdateAlert>
</Box> </Box>
<SettingsInput.Copy mt={'sm'} hit="Kernel" value={CORE_VERSION}></SettingsInput.Copy> <SettingsInput.Copy mt={'sm'} hit="Kernel" value={coreVersion}></SettingsInput.Copy>
<Text fz={10} mt={3} c={'gray'} pl={'xs'} pr={'xs'}> <Text fz={10} mt={3} c={'gray'} pl={'xs'} pr={'xs'}>
If the kernel version is outdated, you need to reinstall the application so that this kernel continues to receive current updates. If the kernel version is outdated, you need to reinstall the application so that this kernel continues to receive current updates.
</Text> </Text>

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,35 @@ import * as secp256k1 from '@noble/secp256k1';
self.onmessage = async (event: MessageEvent) => { self.onmessage = async (event: MessageEvent) => {
const { action, data } = event.data; const { id, type, payload } = event.data;
switch (action) { try {
case 'encodeWithPassword': { let result;
const { password, payload, task } = data; switch (type) {
const result = await encodeWithPassword(password, payload); case 'encodeWithPassword':
self.postMessage({ action: 'encodeWithPasswordResult', result, task }); result = await encodeWithPassword(payload.password, payload.data);
break; break;
case 'decodeWithPassword':
result = await decodeWithPassword(payload.password, payload.data);
break;
case 'encrypt':
result = await encrypt(payload.data, payload.publicKey);
break;
case 'decrypt':
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': { self.postMessage({ id, ok: true, data: result });
const { payload, task } = data; } catch (error) {
const result = await chacha20Encrypt(payload); self.postMessage({ id, ok: false, error: String(error) });
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}`);
} }
}; };

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

View File

@@ -1,8 +1,7 @@
#/bin/bash #/bin/bash
echo "Using directory: $(pwd)" echo "Using directory: $(pwd)"
current_dir=$(pwd) current_dir=$(pwd)
# Run npm with a timeout using perl (cross-platform alternative to 'timeout')
perl -e 'alarm shift; $SIG{ALRM}=sub{kill INT => -$$}; exec @ARGV' 10 npm run start
echo "Build complete. Packing service packs..." echo "Build complete. Packing service packs..."
APP_VERSION=$(grep -o 'APP_VERSION *= *"[^"]*' "$current_dir/app/version.ts" | sed 's/APP_VERSION *= *"//') APP_VERSION=$(grep -o 'APP_VERSION *= *"[^"]*' "$current_dir/app/version.ts" | sed 's/APP_VERSION *= *"//')

View File

@@ -1,4 +1,4 @@
import { BrowserWindow, shell, app, ipcMain, nativeTheme, screen, powerMonitor } from 'electron' import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor, app } from 'electron'
import { join } from 'path' import { join } from 'path'
import fs from 'fs' import fs from 'fs'
import { WORKING_DIR } from './constants'; import { WORKING_DIR } from './constants';
@@ -28,8 +28,8 @@ export function createPreloaderWindow() {
export function createAppWindow(preloaderWindow?: BrowserWindow): void { export function createAppWindow(preloaderWindow?: BrowserWindow): void {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 900, width: 385,
height: 670, height: 555,
minWidth: 385, minWidth: 385,
minHeight: 555, minHeight: 555,
show: false, show: false,
@@ -45,7 +45,8 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
nodeIntegrationInSubFrames: true, nodeIntegrationInSubFrames: true,
nodeIntegrationInWorker: true, nodeIntegrationInWorker: true,
webSecurity: false, webSecurity: false,
allowRunningInsecureContent: true allowRunningInsecureContent: true,
autoplayPolicy: 'no-user-gesture-required'
} }
}); });
@@ -73,17 +74,13 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
} }
export function foundationIpcRegistration(mainWindow: BrowserWindow) { export function foundationIpcRegistration(mainWindow: BrowserWindow) {
let bounceId: number | null = null;
ipcMain.removeAllListeners('window-resize'); ipcMain.removeAllListeners('window-resize');
ipcMain.removeAllListeners('window-resizeble'); ipcMain.removeAllListeners('window-resizeble');
ipcMain.removeAllListeners('window-theme'); ipcMain.removeAllListeners('window-theme');
ipcMain.removeAllListeners("write-file"); ipcMain.removeAllListeners("write-file");
ipcMain.removeAllListeners("read-file"); ipcMain.removeAllListeners("read-file");
ipcMain.removeAllListeners("mkdir"); ipcMain.removeAllListeners("mkdir");
ipcMain.removeHandler("get-core-version");
ipcMain.removeHandler("get-arch");
ipcMain.removeAllListeners("get-user-dir");
ipcMain.removeHandler("get-downloads-path")
ipcMain.removeHandler("get-app-path");
ipcMain.removeHandler('open-dev-tools'); ipcMain.removeHandler('open-dev-tools');
ipcMain.removeHandler('window-state'); ipcMain.removeHandler('window-state');
ipcMain.removeHandler('window-toggle'); ipcMain.removeHandler('window-toggle');
@@ -91,14 +88,38 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
ipcMain.removeHandler('window-minimize'); ipcMain.removeHandler('window-minimize');
ipcMain.removeHandler('showItemInFolder'); ipcMain.removeHandler('showItemInFolder');
ipcMain.removeHandler('openExternal'); ipcMain.removeHandler('openExternal');
ipcMain.removeHandler('window-top');
ipcMain.removeHandler('window-priority-normal');
ipcMain.handle('showItemInFolder', (_, fullPath: string) => { ipcMain.handle('window-top', () => {
shell.showItemInFolder(fullPath); if (mainWindow.isMinimized()) {
}); mainWindow.restore();
}
mainWindow.setAlwaysOnTop(true, "screen-saver"); // самый высокий уровень
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
ipcMain.handle('openExternal', (_, url: string) => { mainWindow.show();
shell.openExternal(url); mainWindow.focus();
});
if (process.platform === "darwin") {
/**
* Только в macos! Подпрыгивание иконки в Dock
*/
bounceId = app.dock!.bounce("critical");
}
})
ipcMain.handle('window-priority-normal', () => {
mainWindow.setAlwaysOnTop(false);
mainWindow.setVisibleOnAllWorkspaces(false);
if (process.platform === "darwin" && bounceId !== null) {
/**
* Только в macos! Отмена подпрыгивания иконки в Dock
*/
app.dock!.cancelBounce(bounceId);
bounceId = null;
}
})
ipcMain.handle('open-dev-tools', () => { ipcMain.handle('open-dev-tools', () => {
if (mainWindow.webContents.isDevToolsOpened()) { if (mainWindow.webContents.isDevToolsOpened()) {
@@ -208,27 +229,4 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
mainWindow.webContents.send("mkdir-reply"); mainWindow.webContents.send("mkdir-reply");
}); });
}); });
/**
* Change to get-core-version
*/
ipcMain.handle("get-core-version", () => {
return app.getVersion();
});
ipcMain.handle("get-arch", () => {
return process.arch;
})
ipcMain.on("get-user-dir", () => {
const userDir = app.getPath("userData");
mainWindow.webContents.send("get-user-dir-reply", userDir);
});
ipcMain.handle("get-app-path", () => {
return app.getAppPath();
});
ipcMain.handle("get-downloads-path", () => {
return app.getPath("downloads");
});
} }

34
lib/main/ipcs/ipcCore.ts Normal file
View File

@@ -0,0 +1,34 @@
import { app, ipcMain, shell } from "electron";
ipcMain.handle("ipcCore:getCoreVersion", () => {
return app.getVersion();
});
ipcMain.handle("ipcCore:getArch", () => {
return process.arch;
})
ipcMain.handle("ipcCore:getUserDir", () => {
const userDir = app.getPath("userData");
return userDir;
});
ipcMain.handle("ipcCore:getAppPath", () => {
return app.getAppPath();
});
ipcMain.handle("ipcCore:getDownloadsPath", () => {
return app.getPath("downloads");
});
ipcMain.handle('ipcCore:showItemInFolder', (_, fullPath: string) => {
shell.showItemInFolder(fullPath);
});
ipcMain.handle('ipcCore:openExternal', (_, url: string) => {
shell.openExternal(url);
});
ipcMain.handle('ipcCore:getPlatform', () => {
return process.platform;
});

View File

@@ -0,0 +1,13 @@
import { app, ipcMain } from "electron";
import path from "path";
/**
* Получить директорию с ресурсами приложения
*/
ipcMain.handle('runtime:get-resources', () => {
const isDev = !app.isPackaged && process.env['ELECTRON_RENDERER_URL'];
if(isDev){
return path.join(process.cwd(), "resources")
}
return path.join(process.resourcesPath, "resources");
});

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, Menu, nativeImage } from 'electron' import { app, BrowserWindow, Menu, Tray, nativeImage } from 'electron'
import { electronApp, optimizer } from '@electron-toolkit/utils' import { electronApp, optimizer } from '@electron-toolkit/utils'
import { createAppWindow, startApplication } from './app' import { createAppWindow, startApplication } from './app'
import './ipcs/ipcDatabase' import './ipcs/ipcDatabase'
@@ -7,96 +7,139 @@ import './ipcs/ipcFilestorage'
import './ipcs/ipcUpdate' import './ipcs/ipcUpdate'
import './ipcs/ipcNotification' import './ipcs/ipcNotification'
import './ipcs/ipcDevice' import './ipcs/ipcDevice'
import { Tray } from 'electron/main' import './ipcs/ipcCore'
import './ipcs/ipcRuntime'
import { join } from 'path' import { join } from 'path'
import { Logger } from './logger' import { Logger } from './logger'
let lockInstance = app.requestSingleInstanceLock(); const lockInstance = app.requestSingleInstanceLock()
let tray : Tray | null = null; let tray: Tray | null = null
const size = process.platform === 'darwin' ? 18 : 22; const size = process.platform === 'darwin' ? 18 : 22
const logger = Logger('main'); const logger = Logger('main')
const icon = nativeImage
.createFromPath(join(__dirname, '../../resources/R.png'))
.resize({ width: size, height: size })
const icon = nativeImage.createFromPath( if (!lockInstance) {
join(__dirname, '../../resources/R.png') app.quit()
).resize({ width: size, height: size }); process.exit(0)
if(!lockInstance){
app.quit();
process.exit(0);
} }
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
logger.log(`main thread error, reason: ${reason}`); logger.log(`main thread error, reason: ${reason}`)
}); })
app.disableHardwareAcceleration(); app.disableHardwareAcceleration()
app.on('second-instance', () => { app.on('second-instance', () => {
// Someone tried to run a second instance, we should focus our window. const allWindows = BrowserWindow.getAllWindows()
const allWindows = BrowserWindow.getAllWindows(); if (allWindows.length) {
if (allWindows.length) { const mainWindow = allWindows[0]
const mainWindow = allWindows[0]; if (mainWindow.isMinimized()) mainWindow.restore()
if (mainWindow.isMinimized()) mainWindow.restore(); if (!mainWindow.isVisible()) mainWindow.show()
if (mainWindow.isVisible() === false) mainWindow.show(); mainWindow.focus()
mainWindow.focus(); }
} })
});
export const restoreApplicationAfterClickOnTrayOrDock = () => { export const restoreApplicationAfterClickOnTrayOrDock = () => {
const allWindows = BrowserWindow.getAllWindows(); const allWindows = BrowserWindow.getAllWindows()
if (allWindows.length > 0) { if (allWindows.length > 0) {
const mainWindow = allWindows[0]; const mainWindow = allWindows[0]
if (mainWindow.isMinimized()){ if (mainWindow.isMinimized()) {
mainWindow.restore(); mainWindow.restore()
return; return
} }
if(mainWindow.isVisible() === false){ if (!mainWindow.isVisible()) {
mainWindow.show(); mainWindow.show()
} }
mainWindow.focus(); mainWindow.focus()
} else { } else {
createAppWindow(); createAppWindow()
} }
} }
//Menu.setApplicationMenu(null);
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => { app.whenReady().then(async () => {
electronApp.setAppUserModelId('Rosetta'); electronApp.setAppUserModelId('Rosetta')
tray = new Tray(icon);
const contextMenu = Menu.buildFromTemplate([
{ label: 'Open App', click: () => restoreApplicationAfterClickOnTrayOrDock() },
{ label: 'Quit', click: () => app.quit() }
]);
tray.setContextMenu(contextMenu);
tray.setToolTip('Rosetta');
tray.on('click', () => {
restoreApplicationAfterClickOnTrayOrDock();
});
startApplication();
// Default open or close DevTools by F12 in development // Убираем File/View и оставляем только app + минимальный Edit (roles)
// and ignore CommandOrControl + R in production. if (process.platform === 'darwin') {
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils const minimalMenu = Menu.buildFromTemplate([
app.on('browser-window-created', (_, window) => { {
optimizer.watchWindowShortcuts(window) label: app.name,
}) submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' }
]
}
])
Menu.setApplicationMenu(minimalMenu)
} else {
Menu.setApplicationMenu(null)
}
app.on('activate', function () { tray = new Tray(icon)
restoreApplicationAfterClickOnTrayOrDock(); const contextMenu = Menu.buildFromTemplate([
}); { label: 'Open App', click: () => restoreApplicationAfterClickOnTrayOrDock() },
{ label: 'Quit', click: () => app.quit() }
])
tray.setContextMenu(contextMenu)
tray.setToolTip('Rosetta')
tray.on('click', () => {
restoreApplicationAfterClickOnTrayOrDock()
})
startApplication()
const isDevBuild =
!app.isPackaged ||
process.env.NODE_ENV === 'development' ||
Boolean(process.env.ELECTRON_RENDERER_URL)
app.on('browser-window-created', (_, window) => {
// В production оставляем стандартную защиту шорткатов
if (!isDevBuild) {
optimizer.watchWindowShortcuts(window)
return
}
// В dev явно разрешаем Ctrl+R и Cmd+R для перезагрузки, так как в режиме разработки это часто нужно
window.webContents.on('before-input-event', (event, input) => {
const key = input.key?.toLowerCase?.() ?? ''
const isReload = input.type === 'keyDown' && (input.meta || input.control) && key === 'r'
if (isReload) {
event.preventDefault()
window.webContents.reloadIgnoringCache()
}
})
})
app.on('activate', () => {
restoreApplicationAfterClickOnTrayOrDock()
})
}) })
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform == 'darwin') { if (process.platform === 'darwin') {
app.hide(); app.hide()
} }
}) })
// In this file, you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.

View File

@@ -13,5 +13,8 @@ declare global {
downloadsPath: string; downloadsPath: string;
deviceName: string; deviceName: string;
deviceId: string; deviceId: string;
mediaApi: {
getSoundUrl: (fileName: string) => Promise<string>;
};
} }
} }

View File

@@ -1,49 +1,50 @@
import { contextBridge, ipcRenderer, shell } from 'electron' import { contextBridge, ipcRenderer, shell } from 'electron'
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload'
import api from './api' import api from './api'
import { pathToFileURL } from 'node:url'
import path from 'node:path'
import fs from "node:fs";
async function resolveSound(fileName: string) {
const resourcesPath = await ipcRenderer.invoke('runtime:get-resources');
const fullPath = path.join(resourcesPath, "sounds", fileName);
if (!fs.existsSync(fullPath)) {
throw new Error(`Sound not found: ${fullPath}`);
}
return pathToFileURL(fullPath).toString();
}
const exposeContext = async () => { const exposeContext = async () => {
let version = await ipcRenderer.invoke("get-core-version");
let appPath = await ipcRenderer.invoke("get-app-path");
let arch = await ipcRenderer.invoke("get-arch");
let deviceName = await ipcRenderer.invoke("device:name");
let deviceId = await ipcRenderer.invoke("device:id");
let downloadsPath = await ipcRenderer.invoke("get-downloads-path");
if (process.contextIsolated) { if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('version', version);
contextBridge.exposeInMainWorld('platform', process.platform);
contextBridge.exposeInMainWorld('appPath', appPath);
contextBridge.exposeInMainWorld('arch', arch);
contextBridge.exposeInMainWorld('deviceName', deviceName);
contextBridge.exposeInMainWorld('deviceId', deviceId);
contextBridge.exposeInMainWorld('shell', { contextBridge.exposeInMainWorld('shell', {
openExternal: (url: string) => { openExternal: (url: string) => {
ipcRenderer.invoke('openExternal', url); ipcRenderer.invoke('ipcCore:openExternal', url);
}, },
showItemInFolder: (fullPath: string) => { showItemInFolder: (fullPath: string) => {
ipcRenderer.invoke('showItemInFolder', fullPath); ipcRenderer.invoke('ipcCore:showItemInFolder', fullPath);
}
});
contextBridge.exposeInMainWorld("mediaApi", {
getSoundUrl: async (fileName: string) => {
return resolveSound(fileName);
} }
}); });
contextBridge.exposeInMainWorld('downloadsPath', downloadsPath)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
} else { } else {
window.electron = electronAPI window.electron = electronAPI
window.api = api window.api = api;
window.version = version;
window.platform = process.platform;
window.appPath = appPath;
window.arch = arch;
window.shell = shell; window.shell = shell;
window.downloadsPath = downloadsPath; window.mediaApi = {
window.deviceName = deviceName; getSoundUrl: async (fileName: string) => {
window.deviceId = deviceId; return resolveSound(fileName);
}
}
} }
} }

View File

@@ -1,11 +1,17 @@
{ {
"name": "Rosetta", "name": "Rosetta",
"version": "1.4.9", "version": "1.5.5",
"description": "Rosetta Messenger", "description": "Rosetta Messenger",
"main": "./out/main/main.js", "main": "./out/main/main.js",
"license": "MIT", "license": "MIT",
"build": { "build": {
"electronUpdaterCompatibility": false, "electronUpdaterCompatibility": false,
"extraResources": [
{
"from": "resources/",
"to": "resources/"
}
],
"files": [ "files": [
"node_modules/sqlite3/**/*", "node_modules/sqlite3/**/*",
"out/main/**/*", "out/main/**/*",
@@ -17,10 +23,9 @@
"appId": "im.rosetta", "appId": "im.rosetta",
"icon": "icons/mac/icon.icns", "icon": "icons/mac/icon.icns",
"target": [ "target": [
"zip",
"pkg" "pkg"
], ],
"artifactName": "/builds/darwin/${arch}/Rosetta-${version}.${ext}", "artifactName": "builds/darwin/${arch}/Rosetta-${version}.${ext}",
"publish": null "publish": null
}, },
"pkg": { "pkg": {
@@ -34,15 +39,15 @@
"icon": "icons/png/256x256.png", "icon": "icons/png/256x256.png",
"target": [ "target": [
"AppImage" "AppImage"
] ],
"artifactName": "builds/linux/${arch}/Rosetta-${version}.${ext}"
}, },
"win": { "win": {
"icon": "icons/win/icon.ico", "icon": "icons/win/icon.ico",
"target": [ "target": [
"zip",
"nsis" "nsis"
], ],
"artifactName": "/builds/win/${arch}/Rosetta-${version}.${ext}" "artifactName": "builds/win/${arch}/Rosetta-${version}.${ext}"
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
@@ -60,11 +65,11 @@
"start": "electron-vite preview", "start": "electron-vite preview",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "electron-vite build && electron-builder --dir", "build:unpack": "electron-vite build && electron-builder --dir",
"kernel:win": "electron-vite build && electron-builder --win --x64", "kernel:win": "mkdir \"dist/builds/win/x64\" && electron-vite build && electron-builder --win --x64",
"kernel:darwin-arm64": "electron-vite build && electron-builder --mac --arm64", "kernel:darwin-arm64": "electron-vite build && electron-builder --mac --arm64",
"kernel:darwin-x64": "electron-vite build && electron-builder --mac --x64", "kernel:darwin-x64": "electron-vite build && electron-builder --mac --x64",
"kernel:mac": "npm run kernel:darwin-x64 && npm run kernel:darwin-arm64", "kernel:mac": "mkdir -p dist/builds/darwin/arm64 dist/builds/darwin/x64 && npm run kernel:darwin-x64 && npm run kernel:darwin-arm64",
"kernel:linux": "electron-vite build && electron-builder --linux --x64 --arm64" "kernel:linux": "mkdir -p dist/builds/linux/x64 dist/builds/linux/arm64 dist/builds/linux/x86_64 && electron-vite build && electron-builder --linux --x64 --arm64 && if [ -d dist/builds/linux/x86_64 ]; then mkdir -p dist/builds/linux/x64 && mv dist/builds/linux/x86_64/* dist/builds/linux/x64/ 2>/dev/null || true; rmdir dist/builds/linux/x86_64 2>/dev/null || true; fi"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
@@ -79,9 +84,11 @@
"@mantine/form": "^8.3.12", "@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.3.12", "@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.3.12", "@mantine/modals": "^8.3.12",
"@noble/ciphers": "^1.2.1", "@noble/ciphers": "^1.3.0",
"@noble/secp256k1": "^3.0.0", "@noble/secp256k1": "^3.0.0",
"@tabler/icons-react": "^3.31.0", "@tabler/icons-react": "^3.31.0",
"@types/crypto-js": "^4.2.2",
"@types/diffie-hellman": "^5.0.3",
"@types/elliptic": "^6.4.18", "@types/elliptic": "^6.4.18",
"@types/node-forge": "^1.3.11", "@types/node-forge": "^1.3.11",
"@types/npm": "^7.19.3", "@types/npm": "^7.19.3",
@@ -91,7 +98,6 @@
"bip39": "^3.1.0", "bip39": "^3.1.0",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"crypto-browserify": "^3.12.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"elliptic": "^6.6.1", "elliptic": "^6.6.1",
@@ -104,9 +110,13 @@
"i": "^0.3.7", "i": "^0.3.7",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"libsodium": "^0.8.2",
"libsodium-wrappers": "^0.8.2",
"libsodium-wrappers-sumo": "^0.8.2",
"lottie-react": "^2.4.1", "lottie-react": "^2.4.1",
"node-forge": "^1.3.1", "node-forge": "^1.3.1",
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"npm": "^11.11.0",
"pako": "^2.1.0", "pako": "^2.1.0",
"react-router-dom": "^7.4.0", "react-router-dom": "^7.4.0",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
@@ -116,6 +126,8 @@
"recharts": "^2.15.1", "recharts": "^2.15.1",
"sql.js": "^1.13.0", "sql.js": "^1.13.0",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"wa-sqlite": "^1.0.0", "wa-sqlite": "^1.0.0",
"web-bip39": "^0.0.3" "web-bip39": "^0.0.3"
}, },
@@ -123,8 +135,10 @@
"@electron-toolkit/eslint-config": "^2.0.0", "@electron-toolkit/eslint-config": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
"@electron/rebuild": "^4.0.3",
"@rushstack/eslint-patch": "^1.10.5", "@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.0.9",
"@types/libsodium-wrappers": "^0.7.14",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -133,7 +147,6 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"electron": "^38.3.0", "electron": "^38.3.0",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"@electron/rebuild": "^4.0.3",
"electron-vite": "^3.0.0", "electron-vite": "^3.0.0",
"eslint": "^9.21.0", "eslint": "^9.21.0",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",

Some files were not shown because too many files have changed in this diff Show More