From f41e9ac8763db3e1129bb5d646b50dbf7d24bad6 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Sep 2026 16:22:00 +0200 Subject: [PATCH 01/23] feat: NethVoice chat chat-island in its own always-on-top window, like the phone island; Chat menu with the conversations and new chat; nethlink://chat?to= opens a conversation. --- package-lock.json | 463 ++++++++++++++++++ package.json | 1 + public/locales/en/translations.json | 6 + public/locales/it/translations.json | 6 + src/main/classes/controllers/AppController.ts | 6 + .../controllers/ChatIslandController.ts | 54 ++ src/main/classes/controllers/index.ts | 1 + src/main/classes/windows/ChatIslandWindow.ts | 42 ++ src/main/classes/windows/index.ts | 1 + src/main/lib/ipcEvents.ts | 31 ++ src/main/main.ts | 13 + src/renderer/src/App.tsx | 8 + .../Modules/NethVoice/BaseModule/Sidebar.tsx | 13 + .../Modules/NethVoice/Chat/ChatModule.tsx | 81 +++ .../components/Modules/NethlinkModules.tsx | 3 + src/renderer/src/hooks/useChatBridge.ts | 50 ++ src/renderer/src/pages/ChatIslandPage.tsx | 125 +++++ src/renderer/src/pages/NethLinkPage.tsx | 2 + src/renderer/src/store.ts | 3 + src/shared/constants.ts | 4 + src/shared/types.ts | 15 + 21 files changed, 928 insertions(+) create mode 100644 src/main/classes/controllers/ChatIslandController.ts create mode 100644 src/main/classes/windows/ChatIslandWindow.ts create mode 100644 src/renderer/src/components/Modules/NethVoice/Chat/ChatModule.tsx create mode 100644 src/renderer/src/hooks/useChatBridge.ts create mode 100644 src/renderer/src/pages/ChatIslandPage.tsx diff --git a/package-lock.json b/package-lock.json index ce721b4a..909dc773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", + "@nethesis/chat-island": "^0.1.2", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", @@ -2524,6 +2525,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@nethesis/chat-island": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.2.tgz", + "integrity": "sha512-LESp0zs79EWsjM3W7oidf8hPwEIcquL5gVLxYbtAoefe1Cw/skt/ZdkS+nrWoFF3mJRPmY7PadHIFvVdnuSaeg==", + "dev": true, + "license": "AGPL-3.0-or-later", + "dependencies": { + "@xmpp/client": "^0.14.0", + "use-sync-external-store": "^1.2.0", + "zustand": "^4.5.7" + }, + "peerDependencies": { + "react": "^18.2.0 || ^19.0.0", + "react-dom": "^18.2.0 || ^19.0.0" + } + }, "node_modules/@nethesis/nethesis-brands-svg-icons": { "version": "6.2.1", "resolved": "git+ssh://git@github.com/nethesis/Font-Awesome.git#dbcf431acf0e504145486c9ac70f14946279e2e7", @@ -4456,6 +4473,401 @@ "node": ">=10.0.0" } }, + "node_modules/@xmpp/base64": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/base64/-/base64-0.14.0.tgz", + "integrity": "sha512-tz2LuzLMtjGSVVeuXDnDw19H+uOrqhrRFcQKJ3THV0HVjerDewT8WKXdrtBVSQ7n4Ue0k+awYcIE7qDD9rwMMQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/client": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/client/-/client-0.14.0.tgz", + "integrity": "sha512-Y6k77iifYOGuWncOLRr6dVmbsQvcsIFjYI9pXhkSzQDDNnd3hVJGgo/Xx+W/0/o1+kg/tPm/NwX5Yq77NTd9xA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/client-core": "^0.14.0", + "@xmpp/iq": "^0.14.0", + "@xmpp/middleware": "^0.14.0", + "@xmpp/reconnect": "^0.14.0", + "@xmpp/resolve": "^0.14.0", + "@xmpp/resource-binding": "^0.14.0", + "@xmpp/sasl": "^0.14.0", + "@xmpp/sasl-anonymous": "^0.14.0", + "@xmpp/sasl-ht-sha-256-none": "^0.14.0", + "@xmpp/sasl-plain": "^0.14.0", + "@xmpp/sasl-scram-sha-1": "^0.14.0", + "@xmpp/sasl2": "^0.14.0", + "@xmpp/starttls": "^0.14.0", + "@xmpp/stream-features": "^0.14.0", + "@xmpp/stream-management": "^0.14.0", + "@xmpp/tcp": "^0.14.0", + "@xmpp/tls": "^0.14.0", + "@xmpp/websocket": "^0.14.0", + "saslmechanisms": "^0.1.1" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/client-core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/client-core/-/client-core-0.14.0.tgz", + "integrity": "sha512-fW0C6vn4y8Jp1g8uBmCETuOmEDXpjFc/mfY8P0J88nhK2AdPOB5m0ntz0yyAXcPd16smnHVckiOsk9qBjF9dDA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/connection": "^0.14.0", + "@xmpp/events": "^0.14.0", + "@xmpp/jid": "^0.14.0", + "@xmpp/sasl": "^0.14.0", + "@xmpp/xml": "^0.14.0", + "saslmechanisms": "^0.1.1" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/connection": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/connection/-/connection-0.14.0.tgz", + "integrity": "sha512-VRnukXwiXWCsRVQVISUiAxHHgRWH37nxdUaz50o0+cUAYyqtlHIJ2zy+ZHMlxkEWtl+XciNLxwkJKbypJFQheQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/error": "^0.14.0", + "@xmpp/events": "^0.14.0", + "@xmpp/jid": "^0.14.0", + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/connection-tcp": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/connection-tcp/-/connection-tcp-0.14.0.tgz", + "integrity": "sha512-gvYyUghuCHW0qalEYaZvbHeTnEoXc+hCCWP/qvoVGjjSQNwkYRUsy1/8RP/sA6RlQ2sR0nhpkJkJE6K/4rsFIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/connection": "^0.14.0", + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/error": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/error/-/error-0.14.0.tgz", + "integrity": "sha512-b4W/MwAZl8basfmYhcemK3De92xBOiSrIz0VY2cnN2ss1VMsa4d32GuaJGBIpa4ulN/2DFb3vPCeTn5jXvHAJw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/events": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/events/-/events-0.14.0.tgz", + "integrity": "sha512-6mKRIEi69sYr8H9SrkJKaXobOgCq/sU6/pKTQS5cuQZCy0qLuyx9uttkOyAYmtUbVD+HSrXYS72KhMpdQQh88Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "events": "^3.3.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/id": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/id/-/id-0.14.0.tgz", + "integrity": "sha512-0n8OFYPWkBYDi5fHGiJT6SLg9ncOflTmINzjHzt5A3NxEMVrYmqxbRB44u3NLMk1gBNqGtEkc2wl0WHjS0tHWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/iq": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/iq/-/iq-0.14.0.tgz", + "integrity": "sha512-0r3QVKR4XAvkZ4shQwPBkSM21sSfj26Cg8+AYBkd0BKJTde7mRQCISBNidt1xiXA5VPH1+6Qx7fmtPzk3uel+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/events": "^0.14.0", + "@xmpp/id": "^0.14.0", + "@xmpp/middleware": "^0.14.0", + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/jid": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/jid/-/jid-0.14.0.tgz", + "integrity": "sha512-ggiNgjblkeHPbHJ7JLOMIqxc1qQQt0gV+LhXI16afib2wLr120YgvRChY04gjkvQ+IF2sGz7mzPvcAukTmNGHA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/middleware": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/middleware/-/middleware-0.14.0.tgz", + "integrity": "sha512-UGm7ed5NEMapE/z0jqY5daGSBqto/iciDjtyncoXHZdOnR4iFYFF9gSO/65Z1HsL2pbHQpcK0WS+Kn+FGOxPTw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/error": "^0.14.0", + "@xmpp/jid": "^0.14.0", + "@xmpp/xml": "^0.14.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/reconnect": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/reconnect/-/reconnect-0.14.0.tgz", + "integrity": "sha512-XaaT3KrFLf1ZfYZCIx11zauT8bIVXRz9FMaGj4mofq1tC8SiKvIIqJXSgrouhkibb9vPeM9XSiGkH47B/1hc6g==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/events": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/resolve": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/resolve/-/resolve-0.14.0.tgz", + "integrity": "sha512-S4Rhupb2zS4EtT4EFUJLPl2pPSFPfrjp/1bxPil+zBt9sNcbrvIwSNaYeQAAfLD/yYb8/DhaxC/blvdHyM1Xzw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/events": "^0.14.0", + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/resource-binding": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/resource-binding/-/resource-binding-0.14.0.tgz", + "integrity": "sha512-b8EyHUOpkAS25b6cpOQ8fv7cCLfOpJq4dkgs6Td0MdODMmcfWEh38siZKaP0OoNmYWZCxBSANdsBmeidozJxqA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/sasl": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/sasl/-/sasl-0.14.0.tgz", + "integrity": "sha512-C6mWvtRhJCCjv0Q6uuc3FUuDdgUhnX7lCF9afmNT5umy1XB0l1iRmBJYTriSHNBwgErM3i3mydafeRjpuQfP5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/base64": "^0.14.0", + "@xmpp/error": "^0.14.0", + "@xmpp/events": "^0.14.0", + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/sasl-anonymous": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/sasl-anonymous/-/sasl-anonymous-0.14.0.tgz", + "integrity": "sha512-lZ8jaYuKBpfQ2T5Bv+7yxII9OduV1a+mlNkKtG94WchrVzR8fU6VpbTrez88KajA42ngcVCtiO3i6tPepYyK8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "sasl-anonymous": "^0.1.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/sasl-ht-sha-256-none": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/sasl-ht-sha-256-none/-/sasl-ht-sha-256-none-0.14.0.tgz", + "integrity": "sha512-LmMSzX35bI/V2lV26k/hYPm8Pn7InsBEX72b86+ugIBQpzD8/jmB1F/Mfm5vrbWkMLl2KWe61gE+L/p0mvLZdA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/sasl-plain": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/sasl-plain/-/sasl-plain-0.14.0.tgz", + "integrity": "sha512-TGQX6gsCi6LP2lPNkkd+HoZ1ynCesRMQrtJrWbDAa6oMyCwie9xuBcG/5lDJ5fn03G7xxRh1YSQp+x8yFK+q4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "sasl-plain": "^0.1.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/sasl-scram-sha-1": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/sasl-scram-sha-1/-/sasl-scram-sha-1-0.14.0.tgz", + "integrity": "sha512-0ZrsMDx9Haou2yTCzDCwvMsYPvHCxF2bkpyBuiPoYAOWRsIQH1TssZK3d88N7wSvvojter905DNwLPizdaF3og==", + "dev": true, + "license": "ISC", + "dependencies": { + "sasl-scram-sha-1": "^1.3.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/sasl2": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/sasl2/-/sasl2-0.14.0.tgz", + "integrity": "sha512-OcDIDr9xwiOO58yqr6hj0eh9CfKOvwBbNDNhC0wByA07cwOsoojLb1dv3WR7pKk1cETv6wHWQ1o/leRoDB/Yzg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/base64": "^0.14.0", + "@xmpp/error": "^0.14.0", + "@xmpp/events": "^0.14.0", + "@xmpp/jid": "^0.14.0", + "@xmpp/sasl": "^0.14.0", + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@xmpp/starttls": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/starttls/-/starttls-0.14.0.tgz", + "integrity": "sha512-n21Oy5pyD6Cipo96SAVI1ASx/5qo/z3W7J1TWZSG3/gGgrTaCB+VSV0xI+FqRexVJysg3lw3cBidYQL+Lp/pHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/events": "^0.14.0", + "@xmpp/tls": "^0.14.0", + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/stream-features": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/stream-features/-/stream-features-0.14.0.tgz", + "integrity": "sha512-XoogP53qv1lzq/TNnzydT0KmmDKsJ7vpbV1c33fcuBDY0LCmPWA2U5th+Atn3JjEMlyznpVDFco9BF1NgFTHzg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/stream-management": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/stream-management/-/stream-management-0.14.0.tgz", + "integrity": "sha512-i7qLB8KXzLtm2TZXgtVlBprI4Vm8lykJ7Gth2TFavBTky+PQFNr2BH4lGxxvZ7/WkYbhx2jsmDT9JjI7sUCRdw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/error": "^0.14.0", + "@xmpp/events": "^0.14.0", + "@xmpp/time": "^0.14.0", + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/tcp": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/tcp/-/tcp-0.14.0.tgz", + "integrity": "sha512-vJb5Y60ub+YClK01l+653+qpIe6vedw/82szuB4IU50eTDTL8voer6eGK7AMJOJzoZzRofbLh8Ow5txAyZ8vXw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/connection-tcp": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/time": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/time/-/time-0.14.0.tgz", + "integrity": "sha512-KlOLwZXXYrRiIZ+Lg0Rg+iDxIBLfCgVQOkIaYG2nieLVtbrHItI5gv2eI2I6m0cPsX8LajGkCpvQTqUCNZclag==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/tls": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/tls/-/tls-0.14.0.tgz", + "integrity": "sha512-nY/nHlHYgs3i2+xwt/5Ff3J4aCdZADdTOH+NwdfgqBIK9kWDJPW43nnIrbrZgmUL3YOjW5sb1lUI20bFUJACww==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/connection": "^0.14.0", + "@xmpp/connection-tcp": "^0.14.0", + "@xmpp/events": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/websocket": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/websocket/-/websocket-0.14.0.tgz", + "integrity": "sha512-mRjpRHtOujrPIT7I/X2xvn8w6X/7J8gGPfGIKNGLJHBQu1OlT5noUd++mslCAcGdCOf5TGwtJvE7a8CPtSnTFg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/connection": "^0.14.0", + "@xmpp/events": "^0.14.0", + "@xmpp/xml": "^0.14.0" + }, + "engines": { + "node": ">= 20.10" + } + }, + "node_modules/@xmpp/xml": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/xml/-/xml-0.14.0.tgz", + "integrity": "sha512-1rrj3SaIM51wtJUn1l2n4FK2e1QLfEmma9bsD1utaaosA8elpCyWi12QDPfaYoTyWSXNvQ7mGzvFevUJUs1mcQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@xmpp/events": "^0.14.0", + "ltx": "^3.1.2" + }, + "engines": { + "node": ">= 20.10" + } + }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -9894,6 +10306,13 @@ "json-buffer": "3.0.1" } }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "dev": true, + "license": "MIT" + }, "node_modules/koffi": { "version": "2.16.3", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.3.tgz", @@ -10039,6 +10458,16 @@ "yallist": "^3.0.2" } }, + "node_modules/ltx": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/ltx/-/ltx-3.1.2.tgz", + "integrity": "sha512-tFSKojN92FqNK6eRTmKK/ROUTUYVWKAxgohz523TPhF1G3nR3DXQS/I7/705rEPrDSloKDgMdRlh0qgMFQoVYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.4.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -12526,6 +12955,40 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "node_modules/sasl-anonymous": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sasl-anonymous/-/sasl-anonymous-0.1.0.tgz", + "integrity": "sha512-x+0sdsV0Gie2EexxAUsx6ZoB+X6OCthlNBvAQncQxreEWQJByAPntj0EAgTlJc2kZicoc+yFzeR6cl8VfsQGfA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/sasl-plain": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sasl-plain/-/sasl-plain-0.1.0.tgz", + "integrity": "sha512-X8mCSfR8y0NryTu0tuVyr4IS2jBunBgyG+3a0gEEkd0nlHGiyqJhlc4EIkzmSwaa7F8S4yo+LS6Cu5qxRkJrmg==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/sasl-scram-sha-1": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/sasl-scram-sha-1/-/sasl-scram-sha-1-1.4.0.tgz", + "integrity": "sha512-kdP8uAFkak8flnmKTldWnQ98IuC15ASdKxNHWwbqexRj7szHs7y+JEtigLkPoq9gnp/SAxxU4O2huL8OLqSr9w==", + "dev": true, + "license": "MIT" + }, + "node_modules/saslmechanisms": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/saslmechanisms/-/saslmechanisms-0.1.1.tgz", + "integrity": "sha512-pVlvK5ysevz8MzybRnDIa2YMxn0OJ7b9lDiWhMoaKPoJ7YkAg/7YtNjUgaYzElkwHxsw8dBMhaEn7UP6zxEwPg==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", diff --git a/package.json b/package.json index 64cbc5e1..218bc41d 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", + "@nethesis/chat-island": "^0.1.2", "@nethesis/phone-island": "^1.0.15", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 5caee65b..ff726f58 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -695,6 +695,12 @@ "Z-A label": "Z-A", "EXT label": "Extension" }, + "Chat": { + "Chat": "Chat", + "New chat": "New chat", + "No conversations": "No conversations", + "You": "You" + }, "LastCalls": { "Last calls": "Last calls", "No calls": "No calls", diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index 6cd3bb1b..34dce9c3 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -695,6 +695,12 @@ "Z-A label": "Z-A", "EXT label": "Interno" }, + "Chat": { + "Chat": "Chat", + "New chat": "Nuova chat", + "No conversations": "Nessuna conversazione", + "You": "Tu" + }, "LastCalls": { "Last calls": "Ultime chiamate", "No calls": "Nessuna chiamata", diff --git a/src/main/classes/controllers/AppController.ts b/src/main/classes/controllers/AppController.ts index 83756f98..76f11a21 100644 --- a/src/main/classes/controllers/AppController.ts +++ b/src/main/classes/controllers/AppController.ts @@ -1,6 +1,7 @@ import { LoginController } from './LoginController' import { NethLinkController } from './NethLinkController' import { PhoneIslandController } from './PhoneIslandController' +import { ChatIslandController } from './ChatIslandController' import { TrayController } from './TrayController' import { Log } from '@shared/utils/logger' import { DevToolsController } from './DevToolsController' @@ -32,6 +33,11 @@ export class AppController { Log.warning('unable to correctly close the PhoneIslandController:', e) } } + try { + await ChatIslandController.instance?.safeQuit() + } catch (e) { + Log.warning('unable to correctly close the ChatIslandController:', e) + } if (NethLinkController.instance) { try { await NethLinkController.instance.safeQuit() diff --git a/src/main/classes/controllers/ChatIslandController.ts b/src/main/classes/controllers/ChatIslandController.ts new file mode 100644 index 00000000..3ee75529 --- /dev/null +++ b/src/main/classes/controllers/ChatIslandController.ts @@ -0,0 +1,54 @@ +import { ChatIslandWindow } from '../windows' +import { Log } from '@shared/utils/logger' +import { screen } from 'electron' + +export class ChatIslandController { + static instance: ChatIslandController | undefined + window: ChatIslandWindow + private wantFocus = false + + constructor() { + ChatIslandController.instance = this + this.window = new ChatIslandWindow() + } + + // Sized to the island, in the bottom-right corner; hidden when there is nothing to show. + resize(size: { w: number; h: number }) { + try { + const window = this.window.getWindow() + if (!window) return + if (size.w < 8 || size.h < 8) { + window.hide() + return + } + const { x, y, width, height } = screen.getPrimaryDisplay().workArea + const w = Math.min(Math.ceil(size.w), width) + const h = Math.min(Math.ceil(size.h), height) + window.setBounds({ + x: x + width - w, + y: y + height - h, + width: w, + height: h, + }) + if (this.wantFocus) { + this.wantFocus = false + window.show() + window.focus() + } else if (!window.isVisible()) window.showInactive() + } catch (e) { + Log.warning('error during resizing ChatIslandWindow:', e) + } + } + + // Focus once the island has sized the window for what it opens. + focus() { + const window = this.window.getWindow() + if (window?.isVisible()) window.focus() + this.wantFocus = true + } + + async safeQuit() { + ChatIslandController.instance = undefined + await this.window.quit(true) + } +} diff --git a/src/main/classes/controllers/index.ts b/src/main/classes/controllers/index.ts index fc8760ba..10e6d278 100644 --- a/src/main/classes/controllers/index.ts +++ b/src/main/classes/controllers/index.ts @@ -4,3 +4,4 @@ export * from './PhoneIslandController' export * from './TrayController' export * from './DevToolsController' export * from './CommandBarController' +export * from './ChatIslandController' diff --git a/src/main/classes/windows/ChatIslandWindow.ts b/src/main/classes/windows/ChatIslandWindow.ts new file mode 100644 index 00000000..ae8d6ca4 --- /dev/null +++ b/src/main/classes/windows/ChatIslandWindow.ts @@ -0,0 +1,42 @@ +import { PAGES } from '@shared/types' +import { BaseWindow } from './BaseWindow' + +// Transparent, always on top: the chat stays in view with NethLink closed. +export class ChatIslandWindow extends BaseWindow { + constructor() { + super(PAGES.CHATISLAND, { + width: 440, + height: 600, + show: false, + focusable: true, + acceptFirstMouse: true, + fullscreenable: false, + autoHideMenuBar: true, + closable: false, + alwaysOnTop: true, + minimizable: false, + maximizable: false, + movable: false, + resizable: false, + skipTaskbar: true, + roundedCorners: false, + parent: undefined, + transparent: true, + hiddenInMissionControl: true, + hasShadow: false, + fullscreen: false, + enableLargerThanScreen: false, + frame: false, + thickFrame: false, + trafficLightPosition: { x: 0, y: 0 }, + webPreferences: { + nodeIntegration: true, + backgroundThrottling: false, + }, + }) + + this.addOnBuildListener(() => { + this.getWindow()?.setAlwaysOnTop(true, 'screen-saver') + }) + } +} diff --git a/src/main/classes/windows/index.ts b/src/main/classes/windows/index.ts index 7cc92b28..2c435f92 100644 --- a/src/main/classes/windows/index.ts +++ b/src/main/classes/windows/index.ts @@ -4,3 +4,4 @@ export * from './NethLinkWindow' export * from './PhoneIslandWindow' export * from './DevToolsWindow' export * from './CommandBarWindow' +export * from './ChatIslandWindow' diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index dffaeaa3..fe2aad6a 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -2,6 +2,7 @@ import { AccountController, DevToolsController } from '@/classes/controllers' import { LoginController } from '@/classes/controllers/LoginController' import { PhoneIslandController } from '@/classes/controllers/PhoneIslandController' import { CommandBarController } from '@/classes/controllers/CommandBarController' +import { ChatIslandController } from '@/classes/controllers/ChatIslandController' import { IPC_EVENTS } from '@shared/constants' import { Account, OnDraggingWindow, PAGES } from '@shared/types' import { @@ -349,6 +350,36 @@ export function registerIpcEvents() { clipboard.writeText(text) }) + // Chat island window: size, and island events between it and NethLink. + ipcMain.on(IPC_EVENTS.CHAT_ISLAND_RESIZE, (_, size) => { + ChatIslandController.instance?.resize(size) + }) + ipcMain.on(IPC_EVENTS.CHAT_TO_ISLAND, (_, name, detail) => { + try { + ChatIslandController.instance?.window.emit( + IPC_EVENTS.CHAT_TO_ISLAND, + name, + detail, + ) + if (name === 'chat-island-open' || name === 'chat-island-new') { + ChatIslandController.instance?.focus() + } + } catch (e) { + Log.warning('chat island unreachable:', e) + } + }) + ipcMain.on(IPC_EVENTS.CHAT_FROM_ISLAND, (_, name, detail) => { + try { + NethLinkController.instance?.window.emit( + IPC_EVENTS.CHAT_FROM_ISLAND, + name, + detail, + ) + } catch (e) { + Log.warning('nethlink unreachable:', e) + } + }) + ipcMain.on(IPC_EVENTS.PHONE_ISLAND_RESIZE, (_, size) => { PhoneIslandController.instance.resize(size) }) diff --git a/src/main/main.ts b/src/main/main.ts index d8827f12..a60d2f86 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -21,6 +21,7 @@ import { import { AccountController } from './classes/controllers' import { PhoneIslandController } from './classes/controllers/PhoneIslandController' import { CommandBarController } from './classes/controllers/CommandBarController' +import { ChatIslandController } from './classes/controllers/ChatIslandController' import { Account, AuthAppData, @@ -135,6 +136,7 @@ function startup() { } await PhoneIslandController.instance.logout() + await ChatIslandController.instance?.safeQuit() NethLinkController.instance.logout() AccountController.instance.logout() await delay(1000) @@ -759,6 +761,16 @@ async function attachProtocolListeners() { } break } + case 'chat': { + // The chat opens in its own window, not in NethLink. + ipcMain.emit( + IPC_EVENTS.CHAT_TO_ISLAND, + undefined, + to ? 'chat-island-open' : 'chat-island-new', + to ? { username: to } : undefined, + ) + return new Promise((resolve) => resolve) + } default: Log.warning('Unknown nethlink action:', action) } @@ -960,6 +972,7 @@ async function createNethLink(show: boolean = true) { if (show) NethLinkController.instance.show() await delay(1000) new PhoneIslandController() + new ChatIslandController() checkForUpdate() const account = store.get('account') as Account if (account) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5140e346..81d1018d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,6 +14,7 @@ import { AvailableThemes, PAGES } from '@shared/types' import { delay } from '@shared/utils/utils' import i18next from 'i18next' import { DevToolsPage } from './pages/DevToolsPage' +import { ChatIslandPage } from './pages/ChatIslandPage' import { parseThemeToClassName } from './utils' import { useRegisterStoreHook, useSharedState } from '@renderer/store' import { PageContext, usePageCtx } from './contexts/pageContext' @@ -143,6 +144,10 @@ const RequestStateComponent = () => { path: PAGES.COMMANDBAR, element: , }, + { + path: PAGES.CHATISLAND, + element: , + }, ], }, ]) @@ -162,6 +167,9 @@ const Layout = ({ theme, page }: { theme?: AvailableThemes; page?: PAGES }) => { if (page === PAGES.PHONEISLAND) { await import('@nethesis/phone-island/dist/index.css') } + if (page === PAGES.CHATISLAND) { + await import('@nethesis/chat-island/dist/index.css') + } setIsCSSLoaded(true) } diff --git a/src/renderer/src/components/Modules/NethVoice/BaseModule/Sidebar.tsx b/src/renderer/src/components/Modules/NethVoice/BaseModule/Sidebar.tsx index 9756fde3..e18aa38c 100644 --- a/src/renderer/src/components/Modules/NethVoice/BaseModule/Sidebar.tsx +++ b/src/renderer/src/components/Modules/NethVoice/BaseModule/Sidebar.tsx @@ -5,6 +5,7 @@ import { faInfoCircle as InfoMenuIcon, faStar as FavouriteMenuIcon, faSquareParking as ParkedCallMenuIcon, + faComments as ChatMenuIcon, } from '@fortawesome/free-solid-svg-icons' import { useNethlinkData, useSharedState } from '@renderer/store' import { ParkingType } from '@shared/types' @@ -27,6 +28,8 @@ export function Sidebar({ onChangeMenu }: SidebarProps): JSX.Element { ) const [, setPhonebookSearchModule] = useNethlinkData('phonebookSearchModule') const [missedCalls] = useNethlinkData('missedCalls') + const [chatStatus] = useNethlinkData('chatStatus') + const [chatUnread] = useNethlinkData('chatUnread') const [notifications] = useSharedState('notifications') const [lastMenu, setLastMenu] = useState( MENU_ELEMENT.FAVOURITES, @@ -113,6 +116,16 @@ export function Sidebar({ onChangeMenu }: SidebarProps): JSX.Element { isSelected={selectedSidebarMenu === MENU_ELEMENT.PARKED_CALLS} /> )} + {/* CHAT: shown once the chat window is connecting */} + {chatStatus && ( + 0} + onClick={() => handleSidebarMenuSelection(MENU_ELEMENT.CHAT)} + isSelected={selectedSidebarMenu === MENU_ELEMENT.CHAT} + /> + )} {/* APP UPDATE */} { + const d = new Date(ts) + return d.toDateString() === new Date().toDateString() + ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : d.toLocaleDateString() +} + +export function ChatModule() { + const [conversations] = useNethlinkData('chatConversations') + + return ( + <> + openChat()} + actionIcon={NewChatIcon} + actionText={t('Chat.New chat')} + /> + + {conversations?.length ? ( + conversations.map((c) => ( +
openChat(c.peer)} + className='flex items-center gap-3 px-5 py-2 cursor-pointer dark:hover:bg-hoverDark hover:bg-hoverLight' + > + +
+
+ + {c.name} + + {c.last && ( + + {time(c.last.ts)} + + )} +
+
+ + {c.last && + (c.last.mine + ? `${t('Chat.You')}: ` + : c.kind === 'group' && c.last.nick + ? `${c.last.nick}: ` + : '')} + {c.last?.body} + + {c.unread > 0 && ( + + {c.unread} + + )} +
+
+
+ )) + ) : ( + + )} +
+ + ) +} diff --git a/src/renderer/src/components/Modules/NethlinkModules.tsx b/src/renderer/src/components/Modules/NethlinkModules.tsx index 9e6fb8df..241fa6a2 100644 --- a/src/renderer/src/components/Modules/NethlinkModules.tsx +++ b/src/renderer/src/components/Modules/NethlinkModules.tsx @@ -6,6 +6,7 @@ import { AboutModule } from './NethVoice/About/AboutModule' import classNames from 'classnames' import { FavouritesModule } from './NethVoice/Speeddials/Favourites/FavouritesModule' import { ParkingModule } from './NethVoice/Parking/ParkingModule' +import { ChatModule } from './NethVoice/Chat/ChatModule' import { PhoneBookSearchModule } from './NethVoice/SearchResults/PhoneBookSearchModule' import { usePhonebookSearchModule } from './NethVoice/SearchResults/hook/usePhoneBookSearchModule' @@ -42,6 +43,8 @@ export const NethLinkModules = () => { return case MENU_ELEMENT.ABOUT: return + case MENU_ELEMENT.CHAT: + return default: ;<>modules } diff --git a/src/renderer/src/hooks/useChatBridge.ts b/src/renderer/src/hooks/useChatBridge.ts new file mode 100644 index 00000000..fa17c053 --- /dev/null +++ b/src/renderer/src/hooks/useChatBridge.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react' +import { useNethlinkData } from '@renderer/store' +import { IPC_EVENTS } from '@shared/constants' + +/** Opens a conversation, or the new chat panel, in the chat window. */ +export const openChat = (username?: string) => + window.electron.send( + IPC_EVENTS.CHAT_TO_ISLAND, + username ? 'chat-island-open' : 'chat-island-new', + username ? { username } : undefined, + ) + +/** Island state for NethLink, and the colleagues for the island. */ +export function useChatBridge(username?: string) { + const [chatStatus, setChatStatus] = useNethlinkData('chatStatus') + const [, setChatUnread] = useNethlinkData('chatUnread') + const [, setChatConversations] = useNethlinkData('chatConversations') + const [operators] = useNethlinkData('operators') + const up = !!chatStatus + + useEffect(() => { + window.electron.receive(IPC_EVENTS.CHAT_FROM_ISLAND, (name, detail) => { + if (name === 'chat-island-status') setChatStatus(detail?.status) + if (name === 'chat-island-unread') setChatUnread(detail?.total ?? 0) + if (name === 'chat-island-conversations') + setChatConversations(detail?.conversations ?? []) + }) + return () => window.electron.removeAllListeners(IPC_EVENTS.CHAT_FROM_ISLAND) + }, []) + + // Names, avatars, presence and number; sent again once the island is up. + useEffect(() => { + const ops: any = operators?.operators || {} + const avatars: any = operators?.avatars || {} + const contacts = Object.values(ops) + .filter((op: any) => op?.username && op.username !== username) + .map((op: any) => ({ + username: op.username, + name: op.name || op.username, + avatar: avatars[op.username], + presence: op.mainPresence, + number: op.endpoints?.mainextension?.[0]?.id, + })) + if (contacts.length && up) { + window.electron.send(IPC_EVENTS.CHAT_TO_ISLAND, 'chat-island-contacts', { + contacts, + }) + } + }, [operators, username, up]) +} diff --git a/src/renderer/src/pages/ChatIslandPage.tsx b/src/renderer/src/pages/ChatIslandPage.tsx new file mode 100644 index 00000000..b6709cf9 --- /dev/null +++ b/src/renderer/src/pages/ChatIslandPage.tsx @@ -0,0 +1,125 @@ +import { ChatIsland } from '@nethesis/chat-island' +import { useSharedState } from '@renderer/store' +import { IPC_EVENTS } from '@shared/constants' +import { Log } from '@shared/utils/logger' +import { useEffect, useMemo, useState } from 'react' + +// Room around the island for its shadows. +const PAD = 24 +// Island events NethLink listens to. +const OUT = [ + 'chat-island-status', + 'chat-island-unread', + 'chat-island-conversations', +] + +export function ChatIslandPage() { + const [account] = useSharedState('account') + const [theme] = useSharedState('theme') + const [enabled, setEnabled] = useState(false) + + const allowed = + !!account?.data?.profile?.macro_permissions?.nethvoice_cti?.permissions + ?.chat?.value + + // The chat is on for this NethVoice when its gateway answers. + useEffect(() => { + setEnabled(false) + if (!account?.host || !allowed) return + fetch(`https://${account.host}/chat-gw/healthz`) + .then((r) => setEnabled(r.ok)) + .catch(() => setEnabled(false)) + }, [account?.host, allowed]) + + const dataConfig = useMemo( + () => + account?.jwtToken + ? btoa(`${account.host}:${account.username}:${account.jwtToken}`) + : '', + [account?.host, account?.username, account?.jwtToken], + ) + const on = enabled && !!dataConfig + + // The window follows the island's size. + useEffect(() => { + if (!on) { + window.electron.send(IPC_EVENTS.CHAT_ISLAND_RESIZE, { w: 0, h: 0 }) + return + } + let observer: ResizeObserver | undefined + const timer = setInterval(() => { + const root = document.querySelector('.chat-island-root') + if (!root || observer) return + observer = new ResizeObserver(() => { + const { width, height } = root.getBoundingClientRect() + window.electron.send( + IPC_EVENTS.CHAT_ISLAND_RESIZE, + width < 8 + ? { w: 0, h: 0 } + : { w: width + 16 + PAD, h: height + 20 + PAD }, + ) + }) + observer.observe(root) + }, 200) + return () => { + clearInterval(timer) + observer?.disconnect() + } + }, [on]) + + // Events between the island and NethLink. + useEffect(() => { + if (!on) return + window.electron.receive(IPC_EVENTS.CHAT_TO_ISLAND, (name, detail) => + window.dispatchEvent(new CustomEvent(name, { detail })), + ) + const forward = (e: Event) => + window.electron.send( + IPC_EVENTS.CHAT_FROM_ISLAND, + e.type, + (e as CustomEvent).detail, + ) + const call = (e: Event) => { + const number = (e as CustomEvent).detail?.number + number && window.electron.send(IPC_EVENTS.EMIT_START_CALL, number) + } + const notify = (e: Event) => { + const { peer, name, body } = (e as CustomEvent).detail || {} + if (!body) return + const n = new Notification(name || peer, { body }) + n.onclick = () => + window.electron.send(IPC_EVENTS.CHAT_TO_ISLAND, 'chat-island-open', { + username: peer, + }) + } + const error = (e: Event) => + Log.warning('chat island', (e as CustomEvent).detail) + OUT.forEach((n) => window.addEventListener(n, forward)) + window.addEventListener('chat-island-call', call) + window.addEventListener('chat-island-notify', notify) + window.addEventListener('chat-island-error', error) + return () => { + window.electron.removeAllListeners(IPC_EVENTS.CHAT_TO_ISLAND) + OUT.forEach((n) => window.removeEventListener(n, forward)) + window.removeEventListener('chat-island-call', call) + window.removeEventListener('chat-island-notify', notify) + window.removeEventListener('chat-island-error', error) + } + }, [on]) + + if (!on) return null + return ( + <> + {/* Fixed widths: the island must not shrink to the window it sizes. */} + + + + ) +} diff --git a/src/renderer/src/pages/NethLinkPage.tsx b/src/renderer/src/pages/NethLinkPage.tsx index a9b4d040..8b84f3cb 100644 --- a/src/renderer/src/pages/NethLinkPage.tsx +++ b/src/renderer/src/pages/NethLinkPage.tsx @@ -13,6 +13,7 @@ import { debouncer } from '@shared/utils/utils' import { useAccount } from '@renderer/hooks/useAccount' import { Sidebar } from '@renderer/components/Modules/NethVoice/BaseModule/Sidebar' import { sendNotification } from '@renderer/utils' +import { useChatBridge } from '@renderer/hooks/useChatBridge' export interface NethLinkPageProps { handleRefreshConnection: () => void @@ -38,6 +39,7 @@ export function NethLinkPage({ handleRefreshConnection }: NethLinkPageProps) { const { NethVoiceAPI } = useLoggedNethVoiceAPI() const accountMeInterval = useRef() + useChatBridge(account?.username) useInitialize(() => { Log.info('INITIALIZE NETHLINK FRONTEND') diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index c1007344..713d7cd1 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -180,6 +180,9 @@ export const useNethlinkData = createGlobalStateHook({ }, showAddContactModule: false, showPhonebookSearchModule: false, + chatStatus: undefined, + chatUnread: 0, + chatConversations: [], } as NethLinkPageData).useGlobalState export const useLoginPageData = createGlobalStateHook({ diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d81643d9..c8877a06 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -40,6 +40,7 @@ export enum MENU_ELEMENT { LAST_CALLS, PARKED_CALLS, ABOUT, + CHAT, } export enum IPC_EVENTS { LOGIN = 'LOGIN', @@ -48,6 +49,9 @@ export enum IPC_EVENTS { EMIT_START_CALL = 'EMIT_START_CALL', INITIALIZATION_COMPELTED = 'INITIALIZATION_COMPELTED', PHONE_ISLAND_RESIZE = 'PHONE_ISLAND_RESIZE', + CHAT_ISLAND_RESIZE = 'CHAT_ISLAND_RESIZE', + CHAT_TO_ISLAND = 'CHAT_TO_ISLAND', + CHAT_FROM_ISLAND = 'CHAT_FROM_ISLAND', LOGIN_WINDOW_RESIZE = 'LOGIN_WINDOW_RESIZE', HIDE_LOGIN_WINDOW = 'HIDE_LOGIN_WINDOW', CHANGE_THEME = 'CHANGE_THEME', diff --git a/src/shared/types.ts b/src/shared/types.ts index a216bedf..96fd9dcb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -21,6 +21,7 @@ export enum PAGES { NETHLINK = 'NethLink', DEVTOOLS = 'devtoolspage', COMMANDBAR = 'commandbarpage', + CHATISLAND = 'chatislandpage', } export type StateType = [T | undefined, (value: T | undefined) => void] @@ -502,6 +503,20 @@ export type NethLinkPageData = { speeddialsModule?: SpeedDialModuleData phonebookSearchModule?: PhonebookSearchModuleData phonebookModule?: PhonebookModuleData + chatStatus?: string + chatUnread?: number + chatConversations?: ChatConversation[] +} + +// One row of the chat-island-conversations event. +export type ChatConversation = { + peer: string + kind: 'chat' | 'group' + name: string + avatar?: string + online: boolean + unread: number + last?: { body: string; ts: number; mine: boolean; nick?: string } } export type SpeedDialModuleData = { From a809cf3f4e8f8f3a745358f74fc1b60a4da340d9 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Sep 2026 16:27:54 +0200 Subject: [PATCH 02/23] chat: drag the chat window from the dock and the header --- package-lock.json | 8 ++--- package.json | 2 +- .../controllers/ChatIslandController.ts | 29 ++++++++++++--- src/main/lib/ipcEvents.ts | 3 ++ src/renderer/src/pages/ChatIslandPage.tsx | 35 ++++++++++++++++++- 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 909dc773..7fccc901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.2", + "@nethesis/chat-island": "^0.1.3", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", @@ -2526,9 +2526,9 @@ } }, "node_modules/@nethesis/chat-island": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.2.tgz", - "integrity": "sha512-LESp0zs79EWsjM3W7oidf8hPwEIcquL5gVLxYbtAoefe1Cw/skt/ZdkS+nrWoFF3mJRPmY7PadHIFvVdnuSaeg==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.3.tgz", + "integrity": "sha512-FqVJplSp3VhtM6q6iHk/mOMUldPD+C26BkSPFSLxMU+Vrc/nGCHjSUI8yANvLpnfn+yjzs+kAFjB3fHY3JLSBA==", "dev": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 218bc41d..d7cbd98a 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/chat-island": "^0.1.2", + "@nethesis/chat-island": "^0.1.3", "@nethesis/phone-island": "^1.0.15", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", diff --git a/src/main/classes/controllers/ChatIslandController.ts b/src/main/classes/controllers/ChatIslandController.ts index 3ee75529..74b9ee8e 100644 --- a/src/main/classes/controllers/ChatIslandController.ts +++ b/src/main/classes/controllers/ChatIslandController.ts @@ -6,13 +6,15 @@ export class ChatIslandController { static instance: ChatIslandController | undefined window: ChatIslandWindow private wantFocus = false + // Distance from the work area's bottom-right corner, kept after a drag. + private anchor = { right: 0, bottom: 0 } constructor() { ChatIslandController.instance = this this.window = new ChatIslandWindow() } - // Sized to the island, in the bottom-right corner; hidden when there is nothing to show. + // Sized to the island, grown from its bottom-right corner; hidden when there is nothing to show. resize(size: { w: number; h: number }) { try { const window = this.window.getWindow() @@ -21,12 +23,12 @@ export class ChatIslandController { window.hide() return } - const { x, y, width, height } = screen.getPrimaryDisplay().workArea + const { x, y, width, height } = this.workArea() const w = Math.min(Math.ceil(size.w), width) const h = Math.min(Math.ceil(size.h), height) window.setBounds({ - x: x + width - w, - y: y + height - h, + x: Math.max(x, x + width - this.anchor.right - w), + y: Math.max(y, y + height - this.anchor.bottom - h), width: w, height: h, }) @@ -40,6 +42,25 @@ export class ChatIslandController { } } + private workArea() { + const window = this.window.getWindow() + return window?.isVisible() + ? screen.getDisplayMatching(window.getBounds()).workArea + : screen.getPrimaryDisplay().workArea + } + + // After a drag: the new corner is where the island grows from. + keepPosition() { + const window = this.window.getWindow() + if (!window) return + const b = window.getBounds() + const { x, y, width, height } = this.workArea() + this.anchor = { + right: Math.max(0, x + width - b.x - b.width), + bottom: Math.max(0, y + height - b.y - b.height), + } + } + // Focus once the island has sized the window for what it opens. focus() { const window = this.window.getWindow() diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index fe2aad6a..52d66d13 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -241,6 +241,9 @@ export function registerIpcEvents() { const cursorPosition = screen.getCursorScreenPoint() const deltaX = cursorPosition.x - draggingWindow.startMousePosition.x const deltaY = cursorPosition.y - draggingWindow.startMousePosition.y + if (window.title === PAGES.CHATISLAND) { + ChatIslandController.instance?.keepPosition() + } if (window.title === PAGES.PHONEISLAND) { const bounds = window.getBounds() Log.info( diff --git a/src/renderer/src/pages/ChatIslandPage.tsx b/src/renderer/src/pages/ChatIslandPage.tsx index b6709cf9..77d39cc6 100644 --- a/src/renderer/src/pages/ChatIslandPage.tsx +++ b/src/renderer/src/pages/ChatIslandPage.tsx @@ -2,7 +2,7 @@ import { ChatIsland } from '@nethesis/chat-island' import { useSharedState } from '@renderer/store' import { IPC_EVENTS } from '@shared/constants' import { Log } from '@shared/utils/logger' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' // Room around the island for its shadows. const PAD = 24 @@ -107,6 +107,38 @@ export function ChatIslandPage() { } }, [on]) + // The dock and the window header move the whole window; a drag is not a click. + const moved = useRef(false) + const onDragStart = (e: React.PointerEvent) => { + if (e.button !== 0) return + const from = { x: e.screenX, y: e.screenY } + moved.current = false + window.electron.send(IPC_EVENTS.START_DRAG) + const move = (ev: PointerEvent) => { + if (Math.abs(ev.screenX - from.x) + Math.abs(ev.screenY - from.y) > 4) + moved.current = true + } + const stop = () => { + window.electron.send(IPC_EVENTS.STOP_DRAG) + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', stop) + window.removeEventListener('blur', stop) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', stop) + window.addEventListener('blur', stop) + } + useEffect(() => { + const click = (e: MouseEvent) => { + if (!moved.current) return + moved.current = false + e.stopPropagation() + e.preventDefault() + } + document.addEventListener('click', click, true) + return () => document.removeEventListener('click', click, true) + }, []) + if (!on) return null return ( <> @@ -116,6 +148,7 @@ export function ChatIslandPage() { dataConfig={dataConfig} theme={theme} drag={false} + onDragStart={onDragStart} newChatButton={false} maxHeads={5} notifications='auto' From b4c9acfa85592606f0622b7827e37de363f697e9 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Sep 2026 16:39:31 +0200 Subject: [PATCH 03/23] chat: keep the chat window on screen while dragging; island 0.1.4 --- package-lock.json | 8 ++++---- package.json | 2 +- src/main/lib/ipcEvents.ts | 13 +++++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7fccc901..45cc77fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.3", + "@nethesis/chat-island": "^0.1.4", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", @@ -2526,9 +2526,9 @@ } }, "node_modules/@nethesis/chat-island": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.3.tgz", - "integrity": "sha512-FqVJplSp3VhtM6q6iHk/mOMUldPD+C26BkSPFSLxMU+Vrc/nGCHjSUI8yANvLpnfn+yjzs+kAFjB3fHY3JLSBA==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.4.tgz", + "integrity": "sha512-Mra7P1MBAcJD3Uoh/1zaZDOC7AVj7NSYgMfzgd/+JIyWleD+1IxXE2tBrMfPkxmhYEag7bC8MLmy0Xu3gtloYw==", "dev": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index d7cbd98a..884bea4e 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/chat-island": "^0.1.3", + "@nethesis/chat-island": "^0.1.4", "@nethesis/phone-island": "^1.0.15", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index 52d66d13..7f289d67 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -283,6 +283,19 @@ export function registerIpcEvents() { }, false, ) + } else if (window.title === PAGES.CHATISLAND) { + // Kept inside the screen under the cursor, like the phone island. + const { width, height } = window.getBounds() + const wa = screen.getDisplayNearestPoint(cursorPosition).workArea + window.setBounds( + { + x: Math.min(Math.max(newX, wa.x), wa.x + wa.width - width), + y: Math.min(Math.max(newY, wa.y), wa.y + wa.height - height), + width, + height, + }, + false, + ) } else { const [w, h] = window.getContentSize() window.setBounds( From c7832107dea966aa46eeac66ef1a75aff99cdc47 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Sep 2026 16:58:59 +0200 Subject: [PATCH 04/23] chat: retry the chat check, resend its state when NethLink reloads --- src/renderer/src/hooks/useChatBridge.ts | 2 ++ src/renderer/src/pages/ChatIslandPage.tsx | 28 ++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/hooks/useChatBridge.ts b/src/renderer/src/hooks/useChatBridge.ts index fa17c053..53c44dca 100644 --- a/src/renderer/src/hooks/useChatBridge.ts +++ b/src/renderer/src/hooks/useChatBridge.ts @@ -25,6 +25,8 @@ export function useChatBridge(username?: string) { if (name === 'chat-island-conversations') setChatConversations(detail?.conversations ?? []) }) + // The chat window may be up already: ask for its state. + window.electron.send(IPC_EVENTS.CHAT_TO_ISLAND, 'chat-sync') return () => window.electron.removeAllListeners(IPC_EVENTS.CHAT_FROM_ISLAND) }, []) diff --git a/src/renderer/src/pages/ChatIslandPage.tsx b/src/renderer/src/pages/ChatIslandPage.tsx index 77d39cc6..8552fecd 100644 --- a/src/renderer/src/pages/ChatIslandPage.tsx +++ b/src/renderer/src/pages/ChatIslandPage.tsx @@ -22,13 +22,17 @@ export function ChatIslandPage() { !!account?.data?.profile?.macro_permissions?.nethvoice_cti?.permissions ?.chat?.value - // The chat is on for this NethVoice when its gateway answers. + // The chat is on for this NethVoice when its gateway answers; asked again every minute until it does. useEffect(() => { setEnabled(false) if (!account?.host || !allowed) return - fetch(`https://${account.host}/chat-gw/healthz`) - .then((r) => setEnabled(r.ok)) - .catch(() => setEnabled(false)) + let timer: ReturnType | undefined + const probe = () => + fetch(`https://${account.host}/chat-gw/healthz`) + .then((r) => (r.ok ? setEnabled(true) : Promise.reject())) + .catch(() => (timer = setTimeout(probe, 60 * 1000))) + probe() + return () => clearTimeout(timer) }, [account?.host, allowed]) const dataConfig = useMemo( @@ -70,15 +74,23 @@ export function ChatIslandPage() { // Events between the island and NethLink. useEffect(() => { if (!on) return - window.electron.receive(IPC_EVENTS.CHAT_TO_ISLAND, (name, detail) => - window.dispatchEvent(new CustomEvent(name, { detail })), - ) - const forward = (e: Event) => + // Last value of each event, sent again when NethLink (re)loads. + const last: Record = {} + window.electron.receive(IPC_EVENTS.CHAT_TO_ISLAND, (name, detail) => { + if (name !== 'chat-sync') + return window.dispatchEvent(new CustomEvent(name, { detail })) + Object.entries(last).forEach(([n, d]) => + window.electron.send(IPC_EVENTS.CHAT_FROM_ISLAND, n, d), + ) + }) + const forward = (e: Event) => { + last[e.type] = (e as CustomEvent).detail window.electron.send( IPC_EVENTS.CHAT_FROM_ISLAND, e.type, (e as CustomEvent).detail, ) + } const call = (e: Event) => { const number = (e as CustomEvent).detail?.number number && window.electron.send(IPC_EVENTS.EMIT_START_CALL, number) From 1ef0a907ca1b5be59611897c6270004a63a0dcd1 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Sep 2026 17:15:39 +0200 Subject: [PATCH 05/23] command bar: chat with a colleague Chat button and a chat icon on colleague rows while the chat is online; Ctrl/Cmd+Enter chats, Enter still calls. --- public/locales/en/translations.json | 4 +- public/locales/it/translations.json | 4 +- src/main/lib/ipcEvents.ts | 12 ++++ src/renderer/src/pages/CommandBarPage.tsx | 67 ++++++++++++++++++++++- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index ff726f58..089ec5d2 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -994,7 +994,9 @@ }, "CommandBar": { "Placeholder": "Search contact or dial number...", - "Call": "Call" + "Call": "Call", + "Chat": "Chat", + "Chat hint": "Chat (Ctrl/⌘+Enter)" }, "Errors": { "browser_permissions": "Browser permissions error", diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index 34dce9c3..cce5eb8b 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -994,7 +994,9 @@ }, "CommandBar": { "Placeholder": "Cerca contatto o componi numero...", - "Call": "Chiama" + "Call": "Chiama", + "Chat": "Chat", + "Chat hint": "Chat (Ctrl/⌘+Invio)" }, "Errors": { "browser_permissions": "Errore nei permessi del browser", diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index 7f289d67..ad5bf294 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -394,6 +394,18 @@ export function registerIpcEvents() { } catch (e) { Log.warning('nethlink unreachable:', e) } + // The command bar only needs to know whether the chat is there. + if (name === 'chat-island-status') { + try { + CommandBarController.instance?.window.emit( + IPC_EVENTS.CHAT_FROM_ISLAND, + name, + detail, + ) + } catch (e) { + Log.warning('command bar unreachable:', e) + } + } }) ipcMain.on(IPC_EVENTS.PHONE_ISLAND_RESIZE, (_, size) => { diff --git a/src/renderer/src/pages/CommandBarPage.tsx b/src/renderer/src/pages/CommandBarPage.tsx index 8c6c1029..46bc2185 100644 --- a/src/renderer/src/pages/CommandBarPage.tsx +++ b/src/renderer/src/pages/CommandBarPage.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faPhone, + faComment, faSearch, faXmark, faUser, @@ -90,6 +91,9 @@ export function CommandBarPage() { const avatarsRef = useRef(null) const lastFetchRef = useRef(0) // Timestamp of last successful operators/avatars fetch const [operatorVersion, setOperatorVersion] = useState(0) // Bumped when operators data changes, triggers useMemo + // Chat window status: colleagues can be chatted with while it is online. + const [chatStatus, setChatStatus] = useState() + const canChat = chatStatus === 'online' const isPhoneNumber = searchText.trim().length > 0 && getIsPhoneNumber(searchText.trim()) @@ -228,6 +232,14 @@ export function CommandBarPage() { [NethVoiceAPI], ) + useEffect(() => { + window.electron.receive(IPC_EVENTS.CHAT_FROM_ISLAND, (name, detail) => { + if (name === 'chat-island-status') setChatStatus(detail?.status) + }) + window.electron.send(IPC_EVENTS.CHAT_TO_ISLAND, 'chat-sync') + return () => window.electron.removeAllListeners(IPC_EVENTS.CHAT_FROM_ISLAND) + }, []) + useEffect(() => { window.electron.receive(IPC_EVENTS.SHOW_COMMAND_BAR, () => { setSearchText('') @@ -302,6 +314,22 @@ export function CommandBarPage() { [searchText], ) + const handleChat = useCallback((username?: string) => { + if (!username) return + window.electron.send(IPC_EVENTS.CHAT_TO_ISLAND, 'chat-island-open', { + username, + }) + window.electron.send(IPC_EVENTS.HIDE_COMMAND_BAR) + }, []) + + // The selected colleague, when there is one to chat with. + const chatTarget = + canChat && selectedIndex >= 0 + ? allItems[selectedIndex]?.contact?.isOperator + ? allItems[selectedIndex]?.username + : undefined + : undefined + const handleCallSelected = useCallback(() => { if (selectedIndex >= 0 && selectedIndex < allItems.length) { const item = allItems[selectedIndex] @@ -344,7 +372,9 @@ export function CommandBarPage() { if (e.key === 'Enter') { e.preventDefault() - handleCallSelected() + // Ctrl/Cmd+Enter chats with the selected colleague, Enter calls. + if ((e.ctrlKey || e.metaKey) && chatTarget) handleChat(chatTarget) + else handleCallSelected() return } } @@ -440,6 +470,24 @@ export function CommandBarPage() { {t('CommandBar.Call')} + + {canChat && ( + + )} {/* Dropdown results */} @@ -522,7 +570,7 @@ export function CommandBarPage() { className='h-4 w-4 text-gray-500 dark:text-gray-400' /> )} -
+
{contact.displayName} @@ -535,6 +583,21 @@ export function CommandBarPage() { )}
+ {isOperator && canChat && item.username && ( + + )}
) })} From a224eaa44a45a977a6b6201add1030af846ce167 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Sep 2026 17:17:07 +0200 Subject: [PATCH 06/23] chat: richer desktop notification; island 0.1.5 --- package-lock.json | 8 ++++---- package.json | 2 +- src/renderer/src/pages/ChatIslandPage.tsx | 7 +++++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 45cc77fd..a4ae1589 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.4", + "@nethesis/chat-island": "^0.1.5", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", @@ -2526,9 +2526,9 @@ } }, "node_modules/@nethesis/chat-island": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.4.tgz", - "integrity": "sha512-Mra7P1MBAcJD3Uoh/1zaZDOC7AVj7NSYgMfzgd/+JIyWleD+1IxXE2tBrMfPkxmhYEag7bC8MLmy0Xu3gtloYw==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.5.tgz", + "integrity": "sha512-Z6ZBMhPuZU78ukEnth4C5E/apzbVo0QPzl7/h6IkW3FBuEfmthsBZ/GnismUOJ+s4aaUC63T0j7mgmjywxVsRg==", "dev": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 884bea4e..ed68821b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/chat-island": "^0.1.4", + "@nethesis/chat-island": "^0.1.5", "@nethesis/phone-island": "^1.0.15", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", diff --git a/src/renderer/src/pages/ChatIslandPage.tsx b/src/renderer/src/pages/ChatIslandPage.tsx index 8552fecd..a085e17f 100644 --- a/src/renderer/src/pages/ChatIslandPage.tsx +++ b/src/renderer/src/pages/ChatIslandPage.tsx @@ -96,9 +96,12 @@ export function ChatIslandPage() { number && window.electron.send(IPC_EVENTS.EMIT_START_CALL, number) } const notify = (e: Event) => { - const { peer, name, body } = (e as CustomEvent).detail || {} + const { peer, name, body, kind, author, text, avatar } = + (e as CustomEvent).detail || {} if (!body) return - const n = new Notification(name || peer, { body }) + // Groups: "Author · Group"; the avatar as icon. + const title = kind === 'group' ? `${author} · ${name}` : name || peer + const n = new Notification(title, { body: text || body, icon: avatar }) n.onclick = () => window.electron.send(IPC_EVENTS.CHAT_TO_ISLAND, 'chat-island-open', { username: peer, From 9835b906b3b00a9306432f657a7f278031b6b6d5 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Sep 2026 17:25:26 +0200 Subject: [PATCH 07/23] search: chat with a colleague from the results --- .../NethVoice/SearchResults/SearchNumber.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx b/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx index 8134049a..2f6ce557 100644 --- a/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx +++ b/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx @@ -7,6 +7,9 @@ import { usePhonebookSearchModule } from './hook/usePhoneBookSearchModule' import { ContactNameAndActions } from '@renderer/components/Modules/NethVoice/BaseModule/ContactNameAndAction' import { useFavouriteModule } from '../Speeddials/hook/useFavouriteModule' import { ClassNames } from '@renderer/utils' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faComment } from '@fortawesome/free-solid-svg-icons' +import { openChat } from '@renderer/hooks/useChatBridge' export interface SearchNumberProps { user: SearchData @@ -18,6 +21,7 @@ export function SearchNumber({ user, className, onClick }: SearchNumberProps) { const phoneBookModule = usePhonebookSearchModule() const [searchText] = phoneBookModule.searchTextState const [operators] = useNethlinkData('operators') + const [chatStatus] = useNethlinkData('chatStatus') const { isSearchAlsoAFavourite } = useFavouriteModule() const getUsernameFromPhoneNumber = (number: string) => { @@ -127,6 +131,17 @@ export function SearchNumber({ user, className, onClick }: SearchNumberProps) { } /> + {/* A colleague: chat with them */} + {username && chatStatus === 'online' && ( + + )} {onClick && ( - - {canChat && ( - - )} {/* Dropdown results */} From 9de38a8098a3522baa1784bbd13b55266f50a35d Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 1 Oct 2026 08:47:35 +0200 Subject: [PATCH 09/23] chat: delete or leave from the list, translations, group names; island 0.1.6 Chat strings go in src/renderer/public/locales too (the copy the windows read). --- package-lock.json | 8 +- package.json | 2 +- public/locales/en/translations.json | 7 +- public/locales/it/translations.json | 7 +- .../public/locales/en/translations.json | 14 +- .../public/locales/it/translations.json | 14 +- .../Modules/NethVoice/Chat/ChatModule.tsx | 128 ++++++++++++++++-- src/shared/types.ts | 1 + 8 files changed, 163 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index a4ae1589..8078a04d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.5", + "@nethesis/chat-island": "^0.1.6", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", @@ -2526,9 +2526,9 @@ } }, "node_modules/@nethesis/chat-island": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.5.tgz", - "integrity": "sha512-Z6ZBMhPuZU78ukEnth4C5E/apzbVo0QPzl7/h6IkW3FBuEfmthsBZ/GnismUOJ+s4aaUC63T0j7mgmjywxVsRg==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.6.tgz", + "integrity": "sha512-fpckIIhc5TTtMeMVycp7z5ozg0H9Eip69QkP2tFz2YCLrgj5F+kF7IFXDhOz1nx8UKV/Kg9H4xyEchu7euKnmA==", "dev": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index ed68821b..4bd18f2c 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,10 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", + "@nethesis/chat-island": "^0.1.6", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/chat-island": "^0.1.5", "@nethesis/phone-island": "^1.0.15", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index c805a50a..5719cd9e 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -699,7 +699,12 @@ "Chat": "Chat", "New chat": "New chat", "No conversations": "No conversations", - "You": "You" + "You": "You", + "Delete": "Delete", + "Leave": "Leave", + "Delete confirm": "Delete the conversation with \"{{name}}\"? Your copy of the messages is removed.", + "Delete group confirm": "Delete the group \"{{name}}\" for everyone? Its messages are removed for all members.", + "Leave confirm": "Leave the group \"{{name}}\"? You stop receiving its messages and your copy is removed." }, "LastCalls": { "Last calls": "Last calls", diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index 5de460fd..8bc9c1e5 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -699,7 +699,12 @@ "Chat": "Chat", "New chat": "Nuova chat", "No conversations": "Nessuna conversazione", - "You": "Tu" + "You": "Tu", + "Delete": "Elimina", + "Leave": "Abbandona", + "Delete confirm": "Eliminare la conversazione con \"{{name}}\"? La tua copia dei messaggi viene rimossa.", + "Delete group confirm": "Eliminare il gruppo \"{{name}}\" per tutti? I suoi messaggi vengono rimossi per tutti i membri.", + "Leave confirm": "Abbandonare il gruppo \"{{name}}\"? Non riceverai più i suoi messaggi e la tua copia viene rimossa." }, "LastCalls": { "Last calls": "Ultime chiamate", diff --git a/src/renderer/public/locales/en/translations.json b/src/renderer/public/locales/en/translations.json index fbc96a82..6cd93815 100644 --- a/src/renderer/public/locales/en/translations.json +++ b/src/renderer/public/locales/en/translations.json @@ -695,6 +695,17 @@ "Z-A label": "Z-A", "EXT label": "Extension" }, + "Chat": { + "Chat": "Chat", + "New chat": "New chat", + "No conversations": "No conversations", + "You": "You", + "Delete": "Delete", + "Leave": "Leave", + "Delete confirm": "Delete the conversation with \"{{name}}\"? Your copy of the messages is removed.", + "Delete group confirm": "Delete the group \"{{name}}\" for everyone? Its messages are removed for all members.", + "Leave confirm": "Leave the group \"{{name}}\"? You stop receiving its messages and your copy is removed." + }, "LastCalls": { "Last calls": "Last calls", "No calls": "No calls", @@ -989,7 +1000,8 @@ }, "CommandBar": { "Placeholder": "Search contact or dial number...", - "Call": "Call" + "Call": "Call", + "Chat hint": "Chat (Ctrl/⌘+Enter)" }, "Errors": { "browser_permissions": "Browser permissions error", diff --git a/src/renderer/public/locales/it/translations.json b/src/renderer/public/locales/it/translations.json index da90b8f8..d22b7311 100644 --- a/src/renderer/public/locales/it/translations.json +++ b/src/renderer/public/locales/it/translations.json @@ -695,6 +695,17 @@ "Z-A label": "Z-A", "EXT label": "Interno" }, + "Chat": { + "Chat": "Chat", + "New chat": "Nuova chat", + "No conversations": "Nessuna conversazione", + "You": "Tu", + "Delete": "Elimina", + "Leave": "Abbandona", + "Delete confirm": "Eliminare la conversazione con \"{{name}}\"? La tua copia dei messaggi viene rimossa.", + "Delete group confirm": "Eliminare il gruppo \"{{name}}\" per tutti? I suoi messaggi vengono rimossi per tutti i membri.", + "Leave confirm": "Abbandonare il gruppo \"{{name}}\"? Non riceverai più i suoi messaggi e la tua copia viene rimossa." + }, "LastCalls": { "Last calls": "Ultime chiamate", "No calls": "Nessuna chiamata", @@ -989,7 +1000,8 @@ }, "CommandBar": { "Placeholder": "Cerca contatto o componi numero...", - "Call": "Chiama" + "Call": "Chiama", + "Chat hint": "Chat (Ctrl/⌘+Invio)" }, "Errors": { "browser_permissions": "Errore nei permessi del browser", diff --git a/src/renderer/src/components/Modules/NethVoice/Chat/ChatModule.tsx b/src/renderer/src/components/Modules/NethVoice/Chat/ChatModule.tsx index 5a38caad..2473a534 100644 --- a/src/renderer/src/components/Modules/NethVoice/Chat/ChatModule.tsx +++ b/src/renderer/src/components/Modules/NethVoice/Chat/ChatModule.tsx @@ -1,9 +1,20 @@ +import { createRef, useState } from 'react' import { faComments as EmptyIcon, faPenToSquare as NewChatIcon, + faRightFromBracket as LeaveIcon, + faTrash as DeleteIcon, + faTriangleExclamation as WarningIcon, + faUsers as GroupIcon, } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { t } from 'i18next' -import { useNethlinkData } from '@renderer/store' +import { useNethlinkData, useSharedState } from '@renderer/store' +import { IPC_EVENTS } from '@shared/constants' +import { ChatConversation } from '@shared/types' +import { Modal } from '@renderer/components' +import { Button } from '@renderer/components/Nethesis' +import { parseThemeToClassName, truncate } from '@renderer/utils' import { ModuleTitle } from '@renderer/components/ModuleTitle' import { Scrollable } from '@renderer/components/Scrollable' import { EmptyList } from '@renderer/components/EmptyList' @@ -17,8 +28,82 @@ const time = (ts: number) => { : d.toLocaleDateString() } +// Leaving a group I do not own; deleting a chat, or a group I own for everyone. +const leaves = (c: ChatConversation) => c.kind === 'group' && !c.owner + +function DeleteDialog({ + conversation, + close, +}: { + conversation: ChatConversation + close: () => void +}) { + const [theme] = useSharedState('theme') + const cancelRef = createRef() + const name = truncate(conversation.name, 30) + const confirm = () => { + window.electron.send(IPC_EVENTS.CHAT_TO_ISLAND, 'chat-island-delete', { + username: conversation.peer, + }) + close() + } + return ( + + +
+
+
+

+ {leaves(conversation) ? t('Chat.Leave') : t('Chat.Delete')} +

+

+ {conversation.kind === 'group' + ? conversation.owner + ? t('Chat.Delete group confirm', { name }) + : t('Chat.Leave confirm', { name }) + : t('Chat.Delete confirm', { name })} +

+
+
+ + + + +
+ ) +} + export function ChatModule() { const [conversations] = useNethlinkData('chatConversations') + const [operators] = useNethlinkData('operators') + const [toDelete, setToDelete] = useState() + const nameOf = (u: string) => operators?.operators?.[u]?.name || u return ( <> @@ -34,14 +119,20 @@ export function ChatModule() {
openChat(c.peer)} - className='flex items-center gap-3 px-5 py-2 cursor-pointer dark:hover:bg-hoverDark hover:bg-hoverLight' + className='group flex items-center gap-3 px-5 py-2 cursor-pointer dark:hover:bg-hoverDark hover:bg-hoverLight' > - + {c.kind === 'group' && !c.avatar ? ( + + + + ) : ( + + )}
@@ -59,7 +150,7 @@ export function ChatModule() { (c.last.mine ? `${t('Chat.You')}: ` : c.kind === 'group' && c.last.nick - ? `${c.last.nick}: ` + ? `${nameOf(c.last.nick)}: ` : '')} {c.last?.body} @@ -70,12 +161,31 @@ export function ChatModule() { )}
+
)) ) : ( )} + {toDelete && ( + setToDelete(undefined)} + /> + )} ) } diff --git a/src/shared/types.ts b/src/shared/types.ts index 96fd9dcb..1e299d9f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -515,6 +515,7 @@ export type ChatConversation = { name: string avatar?: string online: boolean + owner?: boolean unread: number last?: { body: string; ts: number; mine: boolean; nick?: string } } From 0cd3020591f8fef085e2d1fe544ea3946d088e50 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 1 Oct 2026 09:11:22 +0200 Subject: [PATCH 10/23] chat: Messages list like the last calls, one chat icon, chat after last calls Dividers and spacing of the last calls, short dates, comment-dots everywhere, New chat with comment-medical. Italian months and years in full also in the last calls ("2 mesi fa"). Island 0.1.7. --- package-lock.json | 8 +- package.json | 2 +- public/locales/en/translations.json | 1 + public/locales/it/translations.json | 1 + .../public/locales/en/translations.json | 1 + .../public/locales/it/translations.json | 1 + .../Modules/NethVoice/BaseModule/Sidebar.tsx | 22 +-- .../Modules/NethVoice/Chat/ChatModule.tsx | 169 +++++++++++------- .../NethVoice/SearchResults/SearchNumber.tsx | 4 +- .../src/components/Nethesis/CallsDate.tsx | 4 +- src/renderer/src/pages/CommandBarPage.tsx | 4 +- 11 files changed, 133 insertions(+), 84 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8078a04d..4068139a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.6", + "@nethesis/chat-island": "^0.1.7", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", @@ -2526,9 +2526,9 @@ } }, "node_modules/@nethesis/chat-island": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.6.tgz", - "integrity": "sha512-fpckIIhc5TTtMeMVycp7z5ozg0H9Eip69QkP2tFz2YCLrgj5F+kF7IFXDhOz1nx8UKV/Kg9H4xyEchu7euKnmA==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.7.tgz", + "integrity": "sha512-4LeBhaqtDLUAPDTvbX8tQDte0Gb/IR598TWpkWWlgYrKMpj3IGCSie70qoudhRNdlnFUFp7XKj5FvFp9KM6uWg==", "dev": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 4bd18f2c..9b0b598a 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.6", + "@nethesis/chat-island": "^0.1.7", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 5719cd9e..1edf7063 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -696,6 +696,7 @@ "EXT label": "Extension" }, "Chat": { + "Messages": "Messages", "Chat": "Chat", "New chat": "New chat", "No conversations": "No conversations", diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index 8bc9c1e5..674ccead 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -696,6 +696,7 @@ "EXT label": "Interno" }, "Chat": { + "Messages": "Messaggi", "Chat": "Chat", "New chat": "Nuova chat", "No conversations": "Nessuna conversazione", diff --git a/src/renderer/public/locales/en/translations.json b/src/renderer/public/locales/en/translations.json index 6cd93815..da3d9301 100644 --- a/src/renderer/public/locales/en/translations.json +++ b/src/renderer/public/locales/en/translations.json @@ -696,6 +696,7 @@ "EXT label": "Extension" }, "Chat": { + "Messages": "Messages", "Chat": "Chat", "New chat": "New chat", "No conversations": "No conversations", diff --git a/src/renderer/public/locales/it/translations.json b/src/renderer/public/locales/it/translations.json index d22b7311..fa583810 100644 --- a/src/renderer/public/locales/it/translations.json +++ b/src/renderer/public/locales/it/translations.json @@ -696,6 +696,7 @@ "EXT label": "Interno" }, "Chat": { + "Messages": "Messaggi", "Chat": "Chat", "New chat": "Nuova chat", "No conversations": "Nessuna conversazione", diff --git a/src/renderer/src/components/Modules/NethVoice/BaseModule/Sidebar.tsx b/src/renderer/src/components/Modules/NethVoice/BaseModule/Sidebar.tsx index e18aa38c..c3a351d0 100644 --- a/src/renderer/src/components/Modules/NethVoice/BaseModule/Sidebar.tsx +++ b/src/renderer/src/components/Modules/NethVoice/BaseModule/Sidebar.tsx @@ -5,7 +5,7 @@ import { faInfoCircle as InfoMenuIcon, faStar as FavouriteMenuIcon, faSquareParking as ParkedCallMenuIcon, - faComments as ChatMenuIcon, + faCommentDots as ChatMenuIcon, } from '@fortawesome/free-solid-svg-icons' import { useNethlinkData, useSharedState } from '@renderer/store' import { ParkingType } from '@shared/types' @@ -102,6 +102,16 @@ export function Sidebar({ onChangeMenu }: SidebarProps): JSX.Element { onClick={() => handleSidebarMenuSelection(MENU_ELEMENT.LAST_CALLS)} isSelected={selectedSidebarMenu === MENU_ELEMENT.LAST_CALLS} /> + {/* CHAT: shown once the chat window is connecting */} + {chatStatus && ( + 0} + onClick={() => handleSidebarMenuSelection(MENU_ELEMENT.CHAT)} + isSelected={selectedSidebarMenu === MENU_ELEMENT.CHAT} + /> + )} {/* PARKED CALLS */} {hasPermission(PERMISSION.PARKINGS) && ( )} - {/* CHAT: shown once the chat window is connecting */} - {chatStatus && ( - 0} - onClick={() => handleSidebarMenuSelection(MENU_ELEMENT.CHAT)} - isSelected={selectedSidebarMenu === MENU_ELEMENT.CHAT} - /> - )} {/* APP UPDATE */} { - const d = new Date(ts) - return d.toDateString() === new Date().toDateString() - ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : d.toLocaleDateString() +// Like the last calls: "15h ago (30 Sep 2026 18:08)". +// Italian months and years in full: "2 mesi fa", not "2mes fa". +const SHORT: Record Record> = { + en: () => ({ + xSeconds: 's', + xMinutes: 'm', + xHours: 'h', + xDays: 'd', + xMonths: 'mo', + xYears: 'y', + }), + it: (n) => ({ + xSeconds: 's', + xMinutes: 'm', + xHours: 'h', + xDays: 'g', + xMonths: n === 1 ? ' mese' : ' mesi', + xYears: n === 1 ? ' anno' : ' anni', + }), +} +const when = (ts: number) => { + const itLang = i18next.languages?.[0] === 'it' + const short = { + ...(itLang ? it : enGB), + formatDistance: (token: string, count: number) => + `${count}${SHORT[itLang ? 'it' : 'en'](count)[token] || ''}${itLang ? ' fa' : ' ago'}`, + } + return `${formatDistanceToNowStrict(ts, { addSuffix: true, locale: short })} (${format(ts, 'd MMM yyyy HH:mm', { locale: itLang ? it : enGB })})` } // Leaving a group I do not own; deleting a chat, or a group I own for everyone. @@ -108,72 +133,92 @@ export function ChatModule() { return ( <> openChat()} actionIcon={NewChatIcon} actionText={t('Chat.New chat')} /> {conversations?.length ? ( - conversations.map((c) => ( + conversations.map((c, idx) => (
openChat(c.peer)} - className='group flex items-center gap-3 px-5 py-2 cursor-pointer dark:hover:bg-hoverDark hover:bg-hoverLight' + className='group cursor-pointer dark:hover:bg-hoverDark hover:bg-hoverLight hover:shadow-[0px_-1px_0px_0px_#E5E7EB] dark:hover:shadow-[0px_-1px_0px_0px_#374151]' > - {c.kind === 'group' && !c.avatar ? ( - - - - ) : ( - - )} -
-
- - {c.name} - - {c.last && ( - - {time(c.last.ts)} - - )} -
-
- - {c.last && - (c.last.mine - ? `${t('Chat.You')}: ` - : c.kind === 'group' && c.last.nick - ? `${nameOf(c.last.nick)}: ` - : '')} - {c.last?.body} - - {c.unread > 0 && ( - - {c.unread} - - )} +
+
+ {/* Same layout as the last calls */} +
+
+ {c.kind === 'group' && !c.avatar ? ( + + + + ) : ( + + )} +
+
+

+ {c.name} +

+

+ {c.last && + (c.last.mine + ? `${t('Chat.You')}: ` + : c.kind === 'group' && c.last.nick + ? `${nameOf(c.last.nick)}: ` + : '')} + {c.last?.body} +

+ {c.last && ( +

+ {when(c.last.ts)} +

+ )} +
+
+ {c.unread > 0 && ( + + {c.unread} + + )} + +
+
-
)) ) : ( diff --git a/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx b/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx index 2f6ce557..15e51aa4 100644 --- a/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx +++ b/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx @@ -8,7 +8,7 @@ import { ContactNameAndActions } from '@renderer/components/Modules/NethVoice/Ba import { useFavouriteModule } from '../Speeddials/hook/useFavouriteModule' import { ClassNames } from '@renderer/utils' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faComment } from '@fortawesome/free-solid-svg-icons' +import { faCommentDots } from '@fortawesome/free-solid-svg-icons' import { openChat } from '@renderer/hooks/useChatBridge' export interface SearchNumberProps { @@ -138,7 +138,7 @@ export function SearchNumber({ user, className, onClick }: SearchNumberProps) { className='self-center ml-2 shrink-0' onClick={() => openChat(username)} > - + {t('Chat.Chat')} )} diff --git a/src/renderer/src/components/Nethesis/CallsDate.tsx b/src/renderer/src/components/Nethesis/CallsDate.tsx index b82c6ec8..ba83e2de 100644 --- a/src/renderer/src/components/Nethesis/CallsDate.tsx +++ b/src/renderer/src/components/Nethesis/CallsDate.tsx @@ -75,8 +75,8 @@ export const CallsDate: FC = ({ xMinutes: 'm', xHours: 'h', xDays: 'g', - xMonths: 'mes', - xYears: 'a', + xMonths: count === 1 ? ' mese' : ' mesi', + xYears: count === 1 ? ' anno' : ' anni', } const unit = map[token] || '' diff --git a/src/renderer/src/pages/CommandBarPage.tsx b/src/renderer/src/pages/CommandBarPage.tsx index be65588d..a79ab7a4 100644 --- a/src/renderer/src/pages/CommandBarPage.tsx +++ b/src/renderer/src/pages/CommandBarPage.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faPhone, - faComment, + faCommentDots, faSearch, faXmark, faUser, @@ -575,7 +575,7 @@ export function CommandBarPage() { className='p-2 rounded-lg text-gray-500 hover:text-primary dark:text-gray-400 dark:hover:text-primaryDark dark:hover:bg-bgDark hover:bg-bgLight' > From 9c0552b564efc1d3546efdcdb3512b8f8bd31f23 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 1 Oct 2026 09:54:36 +0200 Subject: [PATCH 11/23] chat: island 0.1.8 (replies) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4068139a..1049ceb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.7", + "@nethesis/chat-island": "^0.1.8", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", @@ -2526,9 +2526,9 @@ } }, "node_modules/@nethesis/chat-island": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.7.tgz", - "integrity": "sha512-4LeBhaqtDLUAPDTvbX8tQDte0Gb/IR598TWpkWWlgYrKMpj3IGCSie70qoudhRNdlnFUFp7XKj5FvFp9KM6uWg==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.8.tgz", + "integrity": "sha512-e78saupIs7QbbW5kogRt0wrA4MAAILedqIf8dA7ZZqFWJpR/NvoCKLHyc11MbxccMrbiJL2N2svSnFOVceT0Hw==", "dev": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 9b0b598a..8c94c714 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.7", + "@nethesis/chat-island": "^0.1.8", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", From aed64708dd234ffff8692f8967f4b67c66d4b536 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 1 Oct 2026 10:24:31 +0200 Subject: [PATCH 12/23] chat: shared contacts mapping and short dates; island 0.1.9 contactsFromOperators from the island; the short date locale lives in lib/dateTime for the last calls and the messages list. --- package-lock.json | 8 +-- package.json | 2 +- .../Modules/NethVoice/Chat/ChatModule.tsx | 32 ++---------- .../src/components/Nethesis/CallsDate.tsx | 49 +------------------ src/renderer/src/hooks/useChatBridge.ts | 17 +++---- src/renderer/src/lib/dateTime.ts | 42 ++++++++++++++++ 6 files changed, 59 insertions(+), 91 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1049ceb9..49a2de69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.8", + "@nethesis/chat-island": "^0.1.9", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", @@ -2526,9 +2526,9 @@ } }, "node_modules/@nethesis/chat-island": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.8.tgz", - "integrity": "sha512-e78saupIs7QbbW5kogRt0wrA4MAAILedqIf8dA7ZZqFWJpR/NvoCKLHyc11MbxccMrbiJL2N2svSnFOVceT0Hw==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.9.tgz", + "integrity": "sha512-ERweHcQRZ5J3ehAj1Fs6Df4QEHnvajxWtzGZt0GKKhWfI+Add+zTOa+webRLVITVbjfmvimieSCGDcC8Gp/+oA==", "dev": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 8c94c714..1094a6d6 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.8", + "@nethesis/chat-island": "^0.1.9", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", diff --git a/src/renderer/src/components/Modules/NethVoice/Chat/ChatModule.tsx b/src/renderer/src/components/Modules/NethVoice/Chat/ChatModule.tsx index 2f984add..d3ce3eda 100644 --- a/src/renderer/src/components/Modules/NethVoice/Chat/ChatModule.tsx +++ b/src/renderer/src/components/Modules/NethVoice/Chat/ChatModule.tsx @@ -11,46 +11,22 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import i18next, { t } from 'i18next' import { format, formatDistanceToNowStrict } from 'date-fns' import { enGB, it } from 'date-fns/locale' +import { shortLocale } from '@renderer/lib/dateTime' import { useNethlinkData, useSharedState } from '@renderer/store' import { IPC_EVENTS } from '@shared/constants' import { ChatConversation } from '@shared/types' import { Modal } from '@renderer/components' -import { Button } from '@renderer/components/Nethesis' import { parseThemeToClassName, truncate } from '@renderer/utils' import { ModuleTitle } from '@renderer/components/ModuleTitle' import { Scrollable } from '@renderer/components/Scrollable' import { EmptyList } from '@renderer/components/EmptyList' -import { Avatar } from '@renderer/components/Nethesis' +import { Avatar, Button } from '@renderer/components/Nethesis' import { openChat } from '@renderer/hooks/useChatBridge' // Like the last calls: "15h ago (30 Sep 2026 18:08)". -// Italian months and years in full: "2 mesi fa", not "2mes fa". -const SHORT: Record Record> = { - en: () => ({ - xSeconds: 's', - xMinutes: 'm', - xHours: 'h', - xDays: 'd', - xMonths: 'mo', - xYears: 'y', - }), - it: (n) => ({ - xSeconds: 's', - xMinutes: 'm', - xHours: 'h', - xDays: 'g', - xMonths: n === 1 ? ' mese' : ' mesi', - xYears: n === 1 ? ' anno' : ' anni', - }), -} const when = (ts: number) => { - const itLang = i18next.languages?.[0] === 'it' - const short = { - ...(itLang ? it : enGB), - formatDistance: (token: string, count: number) => - `${count}${SHORT[itLang ? 'it' : 'en'](count)[token] || ''}${itLang ? ' fa' : ' ago'}`, - } - return `${formatDistanceToNowStrict(ts, { addSuffix: true, locale: short })} (${format(ts, 'd MMM yyyy HH:mm', { locale: itLang ? it : enGB })})` + const language = i18next.languages?.[0] || 'en' + return `${formatDistanceToNowStrict(ts, { addSuffix: true, locale: shortLocale(language) })} (${format(ts, 'd MMM yyyy HH:mm', { locale: language === 'it' ? it : enGB })})` } // Leaving a group I do not own; deleting a chat, or a group I own for everyone. diff --git a/src/renderer/src/components/Nethesis/CallsDate.tsx b/src/renderer/src/components/Nethesis/CallsDate.tsx index ba83e2de..20ac82a4 100644 --- a/src/renderer/src/components/Nethesis/CallsDate.tsx +++ b/src/renderer/src/components/Nethesis/CallsDate.tsx @@ -3,6 +3,7 @@ import { formatDistanceToNowStrict } from 'date-fns' import { format, utcToZonedTime } from 'date-fns-tz' import { enGB, it } from 'date-fns/locale' import { + shortLocale, formatDateLocIsAnnouncement, getCallTimeToDisplayIsAnnouncement, getTimeDifference, @@ -67,53 +68,7 @@ export const CallsDate: FC = ({ ) const currentLanguage = selectedLanguage || i18next?.languages[0] || 'en' - const shortIt = { - ...it, - formatDistance: (token: string, count: number, options?: any) => { - const map: Record = { - xSeconds: 's', - xMinutes: 'm', - xHours: 'h', - xDays: 'g', - xMonths: count === 1 ? ' mese' : ' mesi', - xYears: count === 1 ? ' anno' : ' anni', - } - - const unit = map[token] || '' - const suffix = options?.addSuffix - ? options.comparison && options.comparison > 0 - ? ' tra' - : ' fa' - : '' - - return `${count}${unit}${suffix}` - }, - } - - const shortEn = { - ...enGB, - formatDistance: (token: string, count: number, options?: any) => { - const map: Record = { - xSeconds: 's', - xMinutes: 'm', - xHours: 'h', - xDays: 'd', - xMonths: 'mo', - xYears: 'y', - } - - const unit = map[token] || '' - const suffix = options?.addSuffix - ? options.comparison && options.comparison > 0 - ? ' in' - : ' ago' - : '' - - return `${count}${unit}${suffix}` - }, - } - - const locale = currentLanguage === 'it' ? shortIt : shortEn + const locale = shortLocale(currentLanguage) if (isInAnnouncement) { const dateParts = call?.date_creation.split('/') diff --git a/src/renderer/src/hooks/useChatBridge.ts b/src/renderer/src/hooks/useChatBridge.ts index 53c44dca..f7b96de3 100644 --- a/src/renderer/src/hooks/useChatBridge.ts +++ b/src/renderer/src/hooks/useChatBridge.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { useNethlinkData } from '@renderer/store' import { IPC_EVENTS } from '@shared/constants' +import { contactsFromOperators } from '@nethesis/chat-island' /** Opens a conversation, or the new chat panel, in the chat window. */ export const openChat = (username?: string) => @@ -32,17 +33,11 @@ export function useChatBridge(username?: string) { // Names, avatars, presence and number; sent again once the island is up. useEffect(() => { - const ops: any = operators?.operators || {} - const avatars: any = operators?.avatars || {} - const contacts = Object.values(ops) - .filter((op: any) => op?.username && op.username !== username) - .map((op: any) => ({ - username: op.username, - name: op.name || op.username, - avatar: avatars[op.username], - presence: op.mainPresence, - number: op.endpoints?.mainextension?.[0]?.id, - })) + const contacts = contactsFromOperators( + operators?.operators as any, + operators?.avatars as any, + username || '', + ) if (contacts.length && up) { window.electron.send(IPC_EVENTS.CHAT_TO_ISLAND, 'chat-island-contacts', { contacts, diff --git a/src/renderer/src/lib/dateTime.ts b/src/renderer/src/lib/dateTime.ts index d7b540b7..81fb5198 100644 --- a/src/renderer/src/lib/dateTime.ts +++ b/src/renderer/src/lib/dateTime.ts @@ -185,3 +185,45 @@ export const getTimeDifference = (account: Account, isInQueue: boolean) => { return differenceValueBetweenTimezone } + +// Short distances, as in the last calls: "15h ago", "2 mesi fa". +const SHORT_UNITS: Record Record> = { + it: (count) => ({ + xSeconds: 's', + xMinutes: 'm', + xHours: 'h', + xDays: 'g', + xMonths: count === 1 ? ' mese' : ' mesi', + xYears: count === 1 ? ' anno' : ' anni', + }), + en: () => ({ + xSeconds: 's', + xMinutes: 'm', + xHours: 'h', + xDays: 'd', + xMonths: 'mo', + xYears: 'y', + }), +} + +/** A date-fns locale for formatDistanceToNowStrict with the short units above. */ +export const shortLocale = (language: string) => { + const itLang = language === 'it' + return { + ...(itLang ? it : enGB), + formatDistance: (token: string, count: number, options?: any) => { + const unit = SHORT_UNITS[itLang ? 'it' : 'en'](count)[token] || '' + const future = options?.comparison && options.comparison > 0 + const suffix = options?.addSuffix + ? itLang + ? future + ? ' tra' + : ' fa' + : future + ? ' in' + : ' ago' + : '' + return `${count}${unit}${suffix}` + }, + } +} From c82c323cd6534bb2519586b351b21d4c7848f57e Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 1 Oct 2026 10:52:06 +0200 Subject: [PATCH 13/23] chat: operator instead of colleague; island 0.1.10 --- package-lock.json | 8 ++++---- package.json | 2 +- .../Modules/NethVoice/SearchResults/SearchNumber.tsx | 2 +- src/renderer/src/hooks/useChatBridge.ts | 2 +- src/renderer/src/pages/CommandBarPage.tsx | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49a2de69..ec1e0306 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.9", + "@nethesis/chat-island": "^0.1.10", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", @@ -2526,9 +2526,9 @@ } }, "node_modules/@nethesis/chat-island": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.9.tgz", - "integrity": "sha512-ERweHcQRZ5J3ehAj1Fs6Df4QEHnvajxWtzGZt0GKKhWfI+Add+zTOa+webRLVITVbjfmvimieSCGDcC8Gp/+oA==", + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@nethesis/chat-island/-/chat-island-0.1.10.tgz", + "integrity": "sha512-awpGLSHdqGQhFdaGwCyq1NoAJYqSq3AC4FPeVC3lHLE7fGbNQA/yH7/6/J3i9ZhI0iRiZSEjjwJ/crrRzSdVxQ==", "dev": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 1094a6d6..2b24c2e1 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^2.2.8", "@hookform/resolvers": "^3.3.4", - "@nethesis/chat-island": "^0.1.9", + "@nethesis/chat-island": "^0.1.10", "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", diff --git a/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx b/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx index 15e51aa4..75ab9b32 100644 --- a/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx +++ b/src/renderer/src/components/Modules/NethVoice/SearchResults/SearchNumber.tsx @@ -131,7 +131,7 @@ export function SearchNumber({ user, className, onClick }: SearchNumberProps) { } />
- {/* A colleague: chat with them */} + {/* An operator: chat with them */} {username && chatStatus === 'online' && (