From 01fcab047c2d5fca863c5e77e7d6f3574f9ac7d9 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:50 +0200 Subject: [PATCH 01/62] ADD - include complete emoji dataset --- frontend/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/package.json b/frontend/package.json index 944bab1..fe7b694 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "test": "vitest run" }, "dependencies": { + "emoji-picker-element-data": "^1.8.0", "konsta": "~5.2.0", "lucide-vue-next": "^0.525.0", "pinia": "^3.0.3", From 7095bbe76ee7a79d99f947892071c6a1ecd04613 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:52 +0200 Subject: [PATCH 02/62] BLD - lock emoji picker dependency --- frontend/pnpm-lock.yaml | 214 +++++++++++++++++++++++----------------- 1 file changed, 123 insertions(+), 91 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3620d6d..85fcda8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + emoji-picker-element-data: + specifier: ^1.8.0 + version: 1.8.0 konsta: specifier: ~5.2.0 version: 5.2.0 @@ -38,10 +41,10 @@ importers: version: 6.0.8(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.40(typescript@5.8.3)) '@vue/eslint-config-prettier': specifier: ^10.2.0 - version: 10.2.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.5.3) + version: 10.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.5.3) '@vue/eslint-config-typescript': specifier: ^14.5.1 - version: 14.9.0(eslint-plugin-vue@10.2.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) + version: 14.9.0(eslint-plugin-vue@10.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) '@vue/tsconfig': specifier: ^0.7.0 version: 0.7.0(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)) @@ -53,13 +56,13 @@ importers: version: 2.8.6 eslint: specifier: ^9.29.0 - version: 9.39.5(jiti@2.7.0) + version: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) eslint-plugin-vue: specifier: ~10.2.0 - version: 10.2.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))) + version: 10.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)) express: specifier: ^5.1.0 - version: 5.2.1 + version: 5.2.1(supports-color@8.1.1) prettier: specifier: 3.5.3 version: 3.5.3 @@ -74,7 +77,7 @@ importers: version: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0) + version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@8.1.1) vue-tsc: specifier: ^2.2.10 version: 2.2.12(typescript@5.8.3) @@ -333,6 +336,7 @@ packages: engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] + libc: [glibc] '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -387,66 +391,79 @@ packages: resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.4': resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.4': resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.4': resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.4': resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.4': resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.4': resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.4': resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.4': resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.4': resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.4': resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.4': resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.4': resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.62.4': resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} @@ -516,24 +533,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.3': resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.3': resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.3': resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.3': resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} @@ -963,6 +984,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-picker-element-data@1.8.0: + resolution: {integrity: sha512-VfRuRJNEDLS1JKlNS4olaqhjX5S1nnZ+ZHG73b/dV8QeZyi0yPruTPEE72EmF6XO3k/9hj3lybMIYMOYXb/57A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1348,24 +1372,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2042,17 +2070,17 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))': dependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@8.1.1)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -2065,10 +2093,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.6': + '@eslint/eslintrc@3.3.6(supports-color@8.1.1)': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -2304,15 +2332,15 @@ snapshots: dependencies: undici-types: 6.21.0 - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.8.3) @@ -2320,23 +2348,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)': + '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@8.1.1)(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 - eslint: 9.39.5(jiti@2.7.0) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@5.8.3)': + '@typescript-eslint/project-service@8.65.0(supports-color@8.1.1)(typescript@5.8.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.8.3) '@typescript-eslint/types': 8.65.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -2350,13 +2378,13 @@ snapshots: dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) - debug: 4.4.3 - eslint: 9.39.5(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@8.1.1)(typescript@5.8.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) ts-api-utils: 2.5.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -2364,13 +2392,13 @@ snapshots: '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@8.65.0(supports-color@8.1.1)(typescript@5.8.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@5.8.3) + '@typescript-eslint/project-service': 8.65.0(supports-color@8.1.1)(typescript@5.8.3) '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.8.3) '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -2379,13 +2407,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)': + '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.8.3) - eslint: 9.39.5(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@8.1.1)(typescript@5.8.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -2510,23 +2538,23 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/eslint-config-prettier@10.2.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.5.3)': + '@vue/eslint-config-prettier@10.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.5.3)': dependencies: - eslint: 9.39.5(jiti@2.7.0) - eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.5.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) + eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)) + eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.5.3) prettier: 3.5.3 transitivePeerDependencies: - '@types/eslint' - '@vue/eslint-config-typescript@14.9.0(eslint-plugin-vue@10.2.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)': + '@vue/eslint-config-typescript@14.9.0(eslint-plugin-vue@10.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3)': dependencies: - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) - eslint: 9.39.5(jiti@2.7.0) - eslint-plugin-vue: 10.2.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) + eslint-plugin-vue: 10.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)) fast-glob: 3.3.3 - typescript-eslint: 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) - vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0)) + typescript-eslint: 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) + vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -2610,11 +2638,11 @@ snapshots: birpc@2.9.0: {} - body-parser@2.3.0: + body-parser@2.3.0(supports-color@8.1.1): dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -2728,9 +2756,11 @@ snapshots: de-indent@1.0.2: {} - debug@4.4.3: + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 deep-eql@5.0.2: {} @@ -2748,6 +2778,8 @@ snapshots: ee-first@1.1.1: {} + emoji-picker-element-data@1.8.0: {} + emoji-regex@8.0.0: {} encodeurl@2.0.0: {} @@ -2804,28 +2836,28 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)): dependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) - eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.5.3): + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.5.3): dependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) prettier: 3.5.3 prettier-linter-helpers: 1.0.1 synckit: 0.11.13 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0)) + eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)) - eslint-plugin-vue@10.2.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))): + eslint-plugin-vue@10.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) - eslint: 9.39.5(jiti@2.7.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 6.1.4 semver: 7.8.5 - vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0)) + vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) xml-name-validator: 4.0.0 eslint-scope@8.4.0: @@ -2846,14 +2878,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.5(jiti@2.7.0): + eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@8.1.1) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.6 + '@eslint/eslintrc': 3.3.6(supports-color@8.1.1) '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -2863,7 +2895,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -2921,20 +2953,20 @@ snapshots: expect-type@1.4.0: {} - express@5.2.1: + express@5.2.1(supports-color@8.1.1): dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.3.0(supports-color@8.1.1) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@8.1.1) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -2945,9 +2977,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.3.0 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@8.1.1) + send: 1.2.1(supports-color@8.1.1) + serve-static: 2.2.1(supports-color@8.1.1) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -2986,9 +3018,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -3390,9 +3422,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 - router@2.2.0: + router@2.2.0(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -3412,9 +3444,9 @@ snapshots: semver@7.8.5: {} - send@1.2.1: + send@1.2.1(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -3428,12 +3460,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1: + serve-static@2.2.1(supports-color@8.1.1): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -3564,13 +3596,13 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3): + typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3) - eslint: 9.39.5(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@8.1.1)(typescript@5.8.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -3589,10 +3621,10 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0): + vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@8.1.1): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0) @@ -3624,7 +3656,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 - vitest@3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0): + vitest@3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@8.1.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -3635,7 +3667,7 @@ snapshots: '@vitest/spy': 3.2.7 '@vitest/utils': 3.2.7 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.4.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -3647,7 +3679,7 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0) - vite-node: 3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0) + vite-node: 3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@8.1.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 @@ -3667,10 +3699,10 @@ snapshots: vscode-uri@3.1.0: {} - vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)): + vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - debug: 4.4.3 - eslint: 9.39.5(jiti@2.7.0) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 espree: 11.2.0 From 5abd8c89dcdcbf44719513e4b26d8167d7c38529 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:52 +0200 Subject: [PATCH 03/62] CLN - trim pnpm workspace config --- frontend/pnpm-workspace.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index c6f4a1e..efc037a 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,3 +1,2 @@ onlyBuiltDependencies: - esbuild - From 903a1faf48590878aa846173027a280021d09cd2 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:53 +0200 Subject: [PATCH 04/62] ADD - define message domain types --- frontend/src/types/messages.ts | 64 ++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 frontend/src/types/messages.ts diff --git a/frontend/src/types/messages.ts b/frontend/src/types/messages.ts new file mode 100644 index 0000000..12962b2 --- /dev/null +++ b/frontend/src/types/messages.ts @@ -0,0 +1,64 @@ +export type SmsDirection = 'sent' | 'received' +export type SmsAttachmentType = 'image' | 'gif' | 'video' +export type SmsMessageType = 'text' | 'voice' | SmsAttachmentType +export type SmsDeliveryStatus = 'sending' | 'delivered' | 'failed' + +export type SmsConversation = { + lastMessage: string + lastMessageAt: string + lastMessageType: SmsMessageType + phoneNumber: string + unread: number +} + +export type SmsMessage = { + body: string + client_id?: string + created_at: string + delivery_status?: SmsDeliveryStatus + direction: SmsDirection + id: string + media_asset_id: string | null + media_duration_ms: number | null + media_mime: string | null + media_waveform: number[] | null + message_type: SmsMessageType + read_at: string | null + recipient_number: string + sender_number: string +} + +export type SmsOutgoingMessage = + | { body: string; messageType: 'text' } + | { + mediaDurationMs: number + mediaMime: string + mediaPayload: string + mediaWaveform: number[] + messageType: 'voice' + } + | { + mediaAssetId: string + mediaDurationMs?: number + messageType: SmsAttachmentType + } + +export type SmsMedia = { + mime: string + payload: string +} + +export type GifSearchResult = { + height: number + id: string + previewUrl: string + title: string + url: string + width: number +} + +export type GifSearchPage = { + hasMore: boolean + nextOffset: number + results: GifSearchResult[] +} From c9290707a641341c8519243996bbeca73957c819 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:54 +0200 Subject: [PATCH 05/62] ADD - migrate message persistence schema --- sky_phone/source/server/db_migrate.lua | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 536fb54..e267669 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -318,9 +318,41 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_sms_messages", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "sender_sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "recipient_sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "sender_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "recipient_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "message_type", type = "ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'" }, + { name = "body", type = "VARCHAR(2000) NOT NULL" }, + { name = "media_payload", type = "MEDIUMTEXT NULL" }, + { name = "media_mime", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "media_duration_ms", type = "INT UNSIGNED NULL" }, + { name = "media_waveform", type = "TEXT NULL" }, + { name = "read_at", type = "DATETIME NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_sms_sender", columns = "(`sender_sim_id`, `created_at`)" }, + { name = "idx_sky_phone_sms_recipient", columns = "(`recipient_sim_id`, `created_at`)" }, + }, + foreignKeys = { + { column = "sender_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE SET NULL" }, + { column = "recipient_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema) +Bridge.Database.Query([[ + ALTER TABLE `sky_phone_sms_messages` + MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text' +]], {}) Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "(`sim_id`)", { unique = true }) Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {}) Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true }) From 97f81d398cbc6b5ea1c698608dbb9fe6802eb737 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:55 +0200 Subject: [PATCH 06/62] ADD - install message persistence tables --- sky_phone/sql/install.sql | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 773ca24..8001b1f 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -161,3 +161,24 @@ CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` ( FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE, FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_sms_messages` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `sender_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `recipient_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `sender_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `recipient_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text', + `body` VARCHAR(2000) NOT NULL, + `media_payload` MEDIUMTEXT NULL, + `media_mime` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NULL, + `media_duration_ms` INT UNSIGNED NULL, + `media_waveform` TEXT NULL, + `read_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_sms_sender` (`sender_sim_id`, `created_at`), + KEY `idx_sky_phone_sms_recipient` (`recipient_sim_id`, `created_at`), + FOREIGN KEY (`sender_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL, + FOREIGN KEY (`recipient_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; From a38198eadcef23a82c968f4e3c9db1dfe38b624e Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:56 +0200 Subject: [PATCH 07/62] ADD - configure messages and GIF media --- sky_phone/config/config.lua | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 5f0b0b2..c0e0d75 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -34,6 +34,26 @@ Config.Calls = { RecentPageSize = 100, } +Config.Messages = { + BodyMaxLength = 2000, + ConversationScanLimit = 1000, + ThreadPageSize = 200, + SendsPerMinute = 30, + MediaLoadsPerMinute = 120, + VoiceMaxDurationMs = 30000, + VoiceMaxBase64Length = 180000, + VoiceWaveformSamples = 48, + VideoMaxDurationMs = 30000, + DeleteBatchSize = 20, +} + +Config.Media = { + GifPageSize = 24, + GifRating = "pg-13", + UrlMaxLength = 2048, + AllowedGifHosts = { "giphy.com" }, +} + Config.Mail = { Domain = "ifruit.com", LocalPartMinLength = 3, From 904746a4123d1f0a4ebc0ee63c02f79ff148f327 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:56 +0200 Subject: [PATCH 08/62] ADD - localize messages experience --- sky_phone/config/locales/en.lua | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 55f86e2..aa43b11 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -36,6 +36,39 @@ Locales["en"] = { }, }, Apps = { + messages = { + name = "Messages", newMessage = "New message from {sender}", compose = "New Message", + search = "Search", to = "To:", message = "Message", send = "Send", details = "Details", + filterUnread = "Show Unread Messages", smsLabel = "Text Message · SMS", + photo = "Photo", gif = "GIF", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo", + attachGif = "Attach GIF", attachVideo = "Attach Video", photos = "Photos", gifs = "GIFs", videos = "Videos", + noPhotos = "Take a photo first to attach it here.", noVideos = "Record a video in Camera first.", searchGifs = "Search GIPHY", loadMore = "Load More", + moreActions = "More Actions", contactDetails = "Contact Details", contactName = "Name", phoneNumber = "Phone Number", + call = "Call", messageAction = "Message", addContact = "Add Contact", deleteContact = "Delete Contact", + selectedCount = "{count} Selected", deleteSelected = "Delete", + contactSaveFailed = "The contact could not be saved.", contactDeleteFailed = "The contact could not be deleted.", + callFailed = "The call could not be started.", + emoji = "Emoji", voiceMessage = "Audio Message", recordVoice = "Record Audio", + playAudio = "Play Audio", pauseAudio = "Pause Audio", + recording = "Recording", stopAndSend = "Stop and Send", cancelRecording = "Cancel Recording", + sending = "Sending...", delivered = "Delivered", notDelivered = "Not Delivered", + microphoneUnavailable = "The microphone is unavailable.", recordingTooLarge = "The recording is too large.", + noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to send and receive messages.", + noMessages = "No Messages", noMessagesBody = "Start a conversation with a phone number.", + noResults = "No Results", today = "Today", yesterday = "Yesterday", + errors = { + invalid_number = "Enter a valid phone number.", invalid_message = "Enter a message.", invalid_voice = "The audio message is invalid.", + invalid_attachment = "The attachment is invalid.", + media_provider_unconfigured = "Photo uploads are not configured.", + capture_provider_unavailable = "The screenshot resource is unavailable.", capture_failed = "The photo could not be captured.", + video_provider_unavailable = "Video capture requires the screencapture resource.", video_capture_failed = "The video could not be recorded.", + recording_in_progress = "A video is already being recorded.", recording_not_found = "No active video recording was found.", + gif_provider_unconfigured = "GIF search is not configured.", gif_provider_failed = "GIF search is temporarily unavailable.", + self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", + no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.", + request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.", + }, + }, phone = { name = "Phone", recents = "Recents", contacts = "Contacts", keypad = "Keypad", noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to make calls.", @@ -63,6 +96,12 @@ Locales["en"] = { calculator = { name = "Calculator" }, camera = { name = "Camera", shutter = "Take photo", flip = "Flip camera", flash = "Toggle flash", controls = "Camera controls", + errors = { + media_provider_unconfigured = "Photo uploads are not configured.", + capture_provider_unavailable = "The screenshot resource is unavailable.", capture_failed = "The photo could not be captured.", + video_provider_unavailable = "Video capture requires the screencapture resource.", video_capture_failed = "The video could not be recorded.", + recording_in_progress = "A video is already being recorded.", recording_not_found = "No active video recording was found.", + }, modes = { timelapse = "Timelapse", slowMo = "Slow-Mo", cinematic = "Cinematic", video = "Video", photo = "Photo", portrait = "Portrait", pano = "Pano" }, }, clock = { From bfb00ec3b56199cb886a5ba0fac3c7344b7f0e5a Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:57 +0200 Subject: [PATCH 09/62] ADD - implement server-authoritative messaging --- sky_phone/source/server/messages.lua | 434 +++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 sky_phone/source/server/messages.lua diff --git a/sky_phone/source/server/messages.lua b/sky_phone/source/server/messages.lua new file mode 100644 index 0000000..1e0a6d5 --- /dev/null +++ b/sky_phone/source/server/messages.lua @@ -0,0 +1,434 @@ +Bridge.Database.AfterMigration("sky_phone", function() + +local allowed_voice_mimes = { + ["audio/webm"] = true, + ["audio/webm;codecs=opus"] = true, +} + +local attachment_assets = { + gif = { + celebrate = true, + hearts = true, + party = true, + thumbs_up = true, + wow = true, + }, + image = { + ["camera-1"] = true, + ["camera-2"] = true, + ["camera-3"] = true, + ["city-lights"] = true, + ["desert-road"] = true, + ["ocean-air"] = true, + ["sunset-drive"] = true, + }, + video = { + ["city-loop"] = true, + ["ocean-loop"] = true, + ["sunset-loop"] = true, + }, +} + +local attachment_mimes = { + gif = "image/gif", + image = "image/jpeg", + video = "video/mp4", +} + +local function allowed_media_url(value) + if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then + return false + end + local host = value:lower():match("^https://([^/:?#]+)") + if not host then + return false + end + for _, allowed_host in ipairs(Config.Media.AllowedGifHosts) do + local suffix = "." .. allowed_host + if host == allowed_host or host:sub(-#suffix) == suffix then + return true + end + end + return false +end + +local function valid_attachment_asset(message_type, value) + return attachment_assets[message_type][value] + or message_type == "gif" and allowed_media_url(value) +end + +local function uuid() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + if not rows[1] or type(rows[1].id) ~= "string" then + error("[sky_phone] Database did not generate an SMS UUID.") + end + return rows[1].id +end + +local function trim(value) + if type(value) ~= "string" then + return nil + end + return value:match("^%s*(.-)%s*$") +end + +local function current_device(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + local device = SkyPhone.LoadDevice(session.imei) + if not device then + return nil, { success = false, error = "device_not_found" } + end + if not device.sim_id then + return nil, { success = false, error = "no_sim" } + end + return device +end + +local function format_message(row) + row.media_duration_ms = tonumber(row.media_duration_ms) + row.media_asset_id = nil + if row.message_type == "voice" then + local waveform = row.media_waveform and json.decode(row.media_waveform) or nil + if type(waveform) ~= "table" then + error(("[sky_phone] Voice message %s has an invalid waveform payload."):format(tostring(row.id))) + end + row.media_waveform = waveform + row.media_payload = nil + elseif attachment_assets[row.message_type] then + if not valid_attachment_asset(row.message_type, row.media_payload) then + error(("[sky_phone] Message %s has an invalid attachment asset."):format(tostring(row.id))) + end + row.media_asset_id = row.media_payload + row.media_payload = nil + row.media_waveform = nil + if row.message_type ~= "video" then + row.media_duration_ms = nil + end + else + row.media_payload = nil + row.media_duration_ms = nil + row.media_mime = nil + row.media_waveform = nil + end + return row +end + +local function validate_attachment(message_type, data) + if type(data.mediaAssetId) ~= "string" or not valid_attachment_asset(message_type, data.mediaAssetId) then + return nil + end + local duration = nil + if message_type == "video" then + duration = tonumber(data.mediaDurationMs) + if not duration or duration < 1000 or duration > Config.Messages.VideoMaxDurationMs then + return nil + end + duration = math.floor(duration) + end + return { + duration = duration, + mime = attachment_mimes[message_type], + payload = data.mediaAssetId, + } +end + +local function validate_voice(data) + if type(data.mediaPayload) ~= "string" + or #data.mediaPayload == 0 + or #data.mediaPayload > Config.Messages.VoiceMaxBase64Length + or data.mediaPayload:find("[^A-Za-z0-9+/=]") then + return nil + end + if type(data.mediaMime) ~= "string" or not allowed_voice_mimes[data.mediaMime] then + return nil + end + local duration = tonumber(data.mediaDurationMs) + if not duration or duration < 300 or duration > Config.Messages.VoiceMaxDurationMs then + return nil + end + if type(data.mediaWaveform) ~= "table" + or #data.mediaWaveform < 8 + or #data.mediaWaveform > Config.Messages.VoiceWaveformSamples then + return nil + end + local waveform = {} + for index = 1, #data.mediaWaveform do + local sample = tonumber(data.mediaWaveform[index]) + if not sample or sample < 0 or sample > 1 then + return nil + end + waveform[index] = math.floor(sample * 1000 + 0.5) / 1000 + end + return { + duration = math.floor(duration), + mime = data.mediaMime, + payload = data.mediaPayload, + waveform = json.encode(waveform), + } +end + +local function notify_sim(sim_id, event_name, data) + local devices = Bridge.Database.Query([[ + SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings` + FROM `sky_phone_devices` d + LEFT JOIN `sky_phone_device_data` settings + ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings' + WHERE d.`sim_id` = ? + ]], { sim_id }) + for _, device in ipairs(devices) do + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + local source = tonumber(player_source) or player_source + if SkyPhone.FindDeviceSlots(source, device.imei)[1] then + local payload = {} + for key, value in pairs(data) do + payload[key] = value + end + payload.device = { + imei = device.imei, + name = device.device_name, + settings = device.settings, + } + TriggerClientEvent(event_name, source, payload) + end + end + end +end + +Bridge.Callbacks.Register("sky_phone:messages:conversations", function(source) + local device, error_response = current_device(source) + if not device then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT `id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, + `message_type`, `body`, `read_at`, `created_at` + FROM `sky_phone_sms_messages` + WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ? + ORDER BY `created_at` DESC, `id` DESC LIMIT ? + ]], { device.sim_id, device.sim_id, Config.Messages.ConversationScanLimit }) + local conversations = {} + local ordered = {} + for _, row in ipairs(rows) do + local received = row.recipient_sim_id == device.sim_id + local number = received and row.sender_number or row.recipient_number + local conversation = conversations[number] + if not conversation then + conversation = { + phoneNumber = number, + lastMessage = row.body, + lastMessageAt = row.created_at, + lastMessageType = row.message_type, + unread = 0, + } + conversations[number] = conversation + ordered[#ordered + 1] = conversation + end + if received and not row.read_at then + conversation.unread = conversation.unread + 1 + end + end + return { success = true, data = ordered } +end) + +Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data) + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local device, error_response = current_device(source) + if not device then + return error_response + end + local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + if not number then + return { success = false, error = "invalid_number" } + end + local rows = Bridge.Database.Query([[ + SELECT * FROM ( + SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`, `media_payload`, `media_mime`, + `media_duration_ms`, `media_waveform`, `read_at`, `created_at`, + CASE WHEN `sender_sim_id` = ? THEN 'sent' ELSE 'received' END AS `direction` + FROM `sky_phone_sms_messages` + WHERE (`sender_sim_id` = ? AND `recipient_number` = ?) + OR (`recipient_sim_id` = ? AND `sender_number` = ?) + ORDER BY `created_at` DESC, `id` DESC LIMIT ? + ) recent_messages + ORDER BY `created_at` ASC, `id` ASC + ]], { device.sim_id, device.sim_id, number, device.sim_id, number, Config.Messages.ThreadPageSize }) + for index = 1, #rows do + rows[index] = format_message(rows[index]) + end + Bridge.Database.Query([[ + UPDATE `sky_phone_sms_messages` SET `read_at` = CURRENT_TIMESTAMP + WHERE `recipient_sim_id` = ? AND `sender_number` = ? AND `read_at` IS NULL + ]], { device.sim_id, number }) + return { success = true, data = rows } +end) + +Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data) + if not SkyPhone.AllowOperation(source, "message_delete", 10, 60) then + return { success = false, error = "rate_limited" } + end + if type(data) ~= "table" or type(data.phoneNumbers) ~= "table" + or #data.phoneNumbers == 0 or #data.phoneNumbers > Config.Messages.DeleteBatchSize then + return { success = false, error = "invalid_request" } + end + local device, error_response = current_device(source) + if not device then + return error_response + end + local numbers = {} + local seen = {} + for index = 1, #data.phoneNumbers do + local number = SkyPhoneSimNumber.Normalize( + data.phoneNumbers[index], + Config.Sim.NumberLength, + Config.Sim.NumberPrefix + ) + if not number then + return { success = false, error = "invalid_number" } + end + if not seen[number] then + seen[number] = true + numbers[#numbers + 1] = number + end + end + local placeholders = {} + for index = 1, #numbers do + placeholders[index] = "?" + end + local values = { device.sim_id } + for _, number in ipairs(numbers) do + values[#values + 1] = number + end + values[#values + 1] = device.sim_id + for _, number in ipairs(numbers) do + values[#values + 1] = number + end + local list = table.concat(placeholders, ", ") + Bridge.Database.Query(([[ + DELETE FROM `sky_phone_sms_messages` + WHERE (`sender_sim_id` = ? AND `recipient_number` IN (%s)) + OR (`recipient_sim_id` = ? AND `sender_number` IN (%s)) + ]]):format(list, list), values) + TriggerClientEvent("sky_phone:messages:changed", source, {}) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:messages:media", function(source, data) + if not SkyPhone.AllowOperation(source, "message_media", Config.Messages.MediaLoadsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + if type(data) ~= "table" or type(data.id) ~= "string" then + return { success = false, error = "invalid_request" } + end + local device, error_response = current_device(source) + if not device then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT `media_payload`, `media_mime` + FROM `sky_phone_sms_messages` + WHERE `id` = ? AND `message_type` = 'voice' + AND (`sender_sim_id` = ? OR `recipient_sim_id` = ?) + LIMIT 1 + ]], { data.id, device.sim_id, device.sim_id }) + if not rows[1] or type(rows[1].media_payload) ~= "string" then + return { success = false, error = "message_not_found" } + end + return { + success = true, + data = { + mime = rows[1].media_mime, + payload = rows[1].media_payload, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data) + if not SkyPhone.AllowOperation(source, "message_send", Config.Messages.SendsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local device, error_response = current_device(source) + if not device then + return error_response + end + local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + if not number then + return { success = false, error = "invalid_number" } + end + if number == device.phone_number then + return { success = false, error = "self_message" } + end + local message_type = data.messageType or "text" + local body = trim(data.body) or "" + local voice = nil + local attachment = nil + if message_type == "text" then + if body == "" or #body > Config.Messages.BodyMaxLength then + return { success = false, error = "invalid_message" } + end + elseif message_type == "voice" then + voice = validate_voice(data) + if not voice then + return { success = false, error = "invalid_voice" } + end + body = "" + elseif attachment_assets[message_type] then + attachment = validate_attachment(message_type, data) + if not attachment then + return { success = false, error = "invalid_attachment" } + end + body = "" + else + return { success = false, error = "invalid_request" } + end + local recipients = Bridge.Database.Query( + "SELECT `id`, `phone_number` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1", + { number } + ) + local recipient = recipients[1] + if not recipient then + return { success = false, error = "recipient_not_found" } + end + local id = uuid() + Bridge.Database.Query([[ + INSERT INTO `sky_phone_sms_messages` + (`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, `message_type`, + `body`, `media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + id, + device.sim_id, + recipient.id, + device.phone_number, + number, + message_type, + body, + voice and voice.payload or attachment and attachment.payload or nil, + voice and voice.mime or attachment and attachment.mime or nil, + voice and voice.duration or attachment and attachment.duration or nil, + voice and voice.waveform or nil, + }) + local rows = Bridge.Database.Query([[ + SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`, `media_payload`, `media_mime`, + `media_duration_ms`, `media_waveform`, `read_at`, `created_at`, 'sent' AS `direction` + FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1 + ]], { id }) + local message = format_message(rows[1]) + TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number }) + notify_sim(recipient.id, "sky_phone:messages:new", { + message = message, + phoneNumber = device.phone_number, + sender = device.phone_number, + voice = message_type == "voice", + }) + return { success = true, data = message } +end) + +end) From e087d76e2a425689e248a06b7bbfd876ddd57efd Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:58 +0200 Subject: [PATCH 10/62] ADD - proxy paginated GIPHY results --- sky_phone/source/server/media.lua | 112 ++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 sky_phone/source/server/media.lua diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua new file mode 100644 index 0000000..40417f3 --- /dev/null +++ b/sky_phone/source/server/media.lua @@ -0,0 +1,112 @@ +local function current_device(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + local device = SkyPhone.LoadDevice(session.imei) + if not device then + return nil, { success = false, error = "device_not_found" } + end + if not device.sim_id then + return nil, { success = false, error = "no_sim" } + end + return device +end + +local function await_http(url, method, body, headers) + local request = promise.new() + PerformHttpRequest(url, function(status, response_body) + request:resolve({ + body = response_body, + status = status, + }) + end, method, body or "", headers or {}) + return Citizen.Await(request) +end + +local function parse_json(value) + if type(value) == "table" then + return value + end + if type(value) ~= "string" or value == "" then + return nil + end + return json.decode(value) +end + +local function url_encode(value) + return tostring(value):gsub("\n", "\r\n"):gsub("([^%w%-_%.~])", function(character) + return ("%%%02X"):format(character:byte()) + end) +end + +Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data) + if not SkyPhone.AllowOperation(source, "gif_search", 30, 60) then + return { success = false, error = "rate_limited" } + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local device, error_response = current_device(source) + if not device then + return error_response + end + local query = type(data.query) == "string" and data.query:match("^%s*(.-)%s*$") or "" + local offset = math.floor(tonumber(data.offset) or 0) + if #query > 60 or offset < 0 or offset > 500 then + return { success = false, error = "invalid_request" } + end + local api_key = GetConvar("sky_phone_giphy_api_key", "") + if api_key == "" then + return { success = false, error = "gif_provider_unconfigured" } + end + local endpoint = query == "" and "trending" or "search" + local url = ("https://api.giphy.com/v1/gifs/%s?api_key=%s&limit=%s&offset=%s&rating=%s"):format( + endpoint, + url_encode(api_key), + Config.Media.GifPageSize, + offset, + url_encode(Config.Media.GifRating) + ) + if query ~= "" then + url = url .. "&q=" .. url_encode(query) + end + local response = await_http(url, "GET") + if response.status < 200 or response.status >= 300 then + Bridge.Debug("error", "[sky_phone] GIPHY request failed with HTTP %s.", tostring(response.status)) + return { success = false, error = "gif_provider_failed" } + end + local payload = parse_json(response.body) + if type(payload) ~= "table" or type(payload.data) ~= "table" then + return { success = false, error = "gif_provider_failed" } + end + local results = {} + for _, item in ipairs(payload.data) do + local preview = item.images and (item.images.fixed_width or item.images.downsized) + local original = item.images and item.images.original + if type(item.id) == "string" and type(preview) == "table" and type(preview.url) == "string" + and type(original) == "table" and type(original.url) == "string" then + results[#results + 1] = { + height = tonumber(preview.height) or 200, + id = item.id, + previewUrl = preview.url, + title = type(item.title) == "string" and item.title or "GIF", + url = original.url, + width = tonumber(preview.width) or 200, + } + end + end + local pagination = type(payload.pagination) == "table" and payload.pagination or {} + local page_offset = math.floor(tonumber(pagination.offset) or offset) + local page_count = math.floor(tonumber(pagination.count) or #payload.data) + local total_count = math.floor(tonumber(pagination.total_count) or (page_offset + page_count)) + local next_offset = page_offset + page_count + return { + success = true, + data = { + hasMore = page_count > 0 and next_offset < total_count, + nextOffset = next_offset, + results = results, + }, + } +end) From 03daee5c7b98de15c169459cc4b57c489ef99a8e Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:59 +0200 Subject: [PATCH 11/62] ADD - load messaging server modules --- sky_phone/fxmanifest.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index a414025..fd1b7d0 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -41,6 +41,8 @@ server_scripts { 'source/server/phone.lua', 'source/server/sim.lua', 'source/server/calls.lua', + 'source/server/media.lua', + 'source/server/messages.lua', 'source/server/notes.lua', 'source/server/mail.lua', } From 7be7a3104bb89e606ea7d61a548419aef9c48e4f Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:57:59 +0200 Subject: [PATCH 12/62] ADD - bridge message NUI events --- sky_phone/source/client/main.lua | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 801757a..ab45564 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -43,6 +43,12 @@ local server_callbacks = { "calls:answer", "calls:decline", "calls:hangup", + "messages:conversations", + "messages:thread", + "messages:send", + "messages:media", + "messages:delete", + "messages:gifs", } local function get_locale() @@ -283,6 +289,17 @@ RegisterNetEvent("sky_phone:calls:changed", function() SendNUIMessage({ type = "calls:changed" }) end) +RegisterNetEvent("sky_phone:messages:changed", function(data) + SendNUIMessage({ type = "messages:changed", data = data }) +end) + +RegisterNetEvent("sky_phone:messages:new", function(data) + local messages_locale = get_locale().Nui.Apps.messages + data.title = messages_locale.name + data.text = messages_locale.newMessage:gsub("{sender}", tostring(data.sender)) + SendNUIMessage({ type = "messages:new", data = data }) +end) + RegisterNetEvent("sky_phone:call:incoming", function(data) notification_focus = true SetNuiFocus(true, true) From 0dd75f2e0c95b3541eb6b24f845a3ad43e8a88fc Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:58:00 +0200 Subject: [PATCH 13/62] ADD - manage message conversations --- frontend/src/stores/messages.ts | 158 ++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 frontend/src/stores/messages.ts diff --git a/frontend/src/stores/messages.ts b/frontend/src/stores/messages.ts new file mode 100644 index 0000000..d8fc2cf --- /dev/null +++ b/frontend/src/stores/messages.ts @@ -0,0 +1,158 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import type { + GifSearchPage, + SmsConversation, + SmsMedia, + SmsMessage, + SmsOutgoingMessage, +} from '@/types/messages' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const useMessagesStore = defineStore('messages', () => { + const conversations = ref([]) + const messages = ref([]) + const mediaSources = ref>({}) + const activeNumber = ref(null) + const loading = ref(false) + const unreadCount = computed(() => + conversations.value.reduce((total, item) => total + item.unread, 0), + ) + + async function loadConversations(): Promise { + const response = await nuiCall('messages:conversations') + if (response.success && response.data) conversations.value = response.data + else if (!response.success) conversations.value = [] + return response.success + } + + async function openThread(phoneNumber: string): Promise { + loading.value = true + const response = await nuiCall('messages:thread', { + phoneNumber, + }) + loading.value = false + if (!response.success || !response.data) return false + activeNumber.value = phoneNumber + messages.value = response.data.map((message) => ({ + ...message, + delivery_status: + message.direction === 'sent' ? 'delivered' : undefined, + })) + await loadConversations() + return true + } + + async function send( + outgoing: SmsOutgoingMessage, + ): Promise> { + if (!activeNumber.value) return { success: false, error: 'invalid_number' } + const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const optimistic: SmsMessage = { + body: outgoing.messageType === 'text' ? outgoing.body.trim() : '', + client_id: clientId, + created_at: new Date().toISOString().slice(0, 19).replace('T', ' '), + delivery_status: 'sending', + direction: 'sent', + id: clientId, + media_asset_id: + outgoing.messageType === 'image' || + outgoing.messageType === 'gif' || + outgoing.messageType === 'video' + ? outgoing.mediaAssetId + : null, + media_duration_ms: + outgoing.messageType === 'voice' || outgoing.messageType === 'video' + ? outgoing.mediaDurationMs ?? null + : null, + media_mime: + outgoing.messageType === 'voice' ? outgoing.mediaMime : null, + media_waveform: + outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null, + message_type: outgoing.messageType, + read_at: null, + recipient_number: activeNumber.value, + sender_number: '', + } + messages.value.push(optimistic) + if (outgoing.messageType === 'voice') { + mediaSources.value[clientId] = + `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}` + } + + const response = await nuiCall('messages:send', { + ...outgoing, + phoneNumber: activeNumber.value, + }) + const index = messages.value.findIndex( + (message) => message.client_id === clientId, + ) + if (!response.success || !response.data) { + if (index >= 0) messages.value[index].delivery_status = 'failed' + return response + } + + const source = mediaSources.value[clientId] + delete mediaSources.value[clientId] + if (source) mediaSources.value[response.data.id] = source + if (index >= 0) { + messages.value[index] = { + ...response.data, + client_id: clientId, + delivery_status: 'delivered', + } + } + await loadConversations() + return response + } + + async function loadMedia(id: string): Promise { + if (mediaSources.value[id]) return true + const response = await nuiCall('messages:media', { id }) + if (!response.success || !response.data) return false + mediaSources.value[id] = + `data:${response.data.mime};base64,${response.data.payload}` + return true + } + + async function deleteConversations(phoneNumbers: string[]): Promise { + if (!phoneNumbers.length) return false + const response = await nuiCall('messages:delete', { phoneNumbers }) + if (!response.success) return false + if (activeNumber.value && phoneNumbers.includes(activeNumber.value)) { + closeThread() + } + await loadConversations() + return true + } + + async function searchGifs( + query: string, + offset = 0, + ): Promise> { + return nuiCall('messages:gifs', { offset, query }) + } + + function closeThread(): void { + activeNumber.value = null + messages.value = [] + mediaSources.value = {} + } + + return { + activeNumber, + closeThread, + conversations, + deleteConversations, + loadConversations, + loadMedia, + loading, + mediaSources, + messages, + openThread, + searchGifs, + send, + unreadCount, + } +}) From 8d7c5282d78420f7787cbb1a56b1c6c56a572531 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:58:01 +0200 Subject: [PATCH 14/62] ADD - test message store behavior --- frontend/src/stores/messages.test.ts | 96 ++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 frontend/src/stores/messages.test.ts diff --git a/frontend/src/stores/messages.test.ts b/frontend/src/stores/messages.test.ts new file mode 100644 index 0000000..00c36a6 --- /dev/null +++ b/frontend/src/stores/messages.test.ts @@ -0,0 +1,96 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useMessagesStore } from '@/stores/messages' +import type { SmsMessage } from '@/types/messages' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const mockNuiCall = vi.mocked(nuiCall) + +function sentMessage(id: string): SmsMessage { + return { + body: 'Hello', + created_at: '2026-08-06 15:00:00', + direction: 'sent', + id, + media_duration_ms: null, + media_asset_id: null, + media_mime: null, + media_waveform: null, + message_type: 'text', + read_at: null, + recipient_number: '4205550196', + sender_number: '4205550100', + } +} + +describe('messages store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('shows an outgoing message immediately and marks it delivered', async () => { + let resolveSend: ((value: { data: SmsMessage; success: true }) => void) | undefined + const response = new Promise<{ data: SmsMessage; success: true }>((resolve) => { + resolveSend = resolve + }) + mockNuiCall + .mockResolvedValueOnce({ data: [], success: true }) + .mockResolvedValueOnce({ data: [], success: true }) + .mockImplementationOnce(() => response) + .mockResolvedValueOnce({ data: [], success: true }) + + const messages = useMessagesStore() + await messages.openThread('4205550196') + const sending = messages.send({ body: 'Hello', messageType: 'text' }) + + expect(messages.messages).toHaveLength(1) + expect(messages.messages[0]).toMatchObject({ + body: 'Hello', + delivery_status: 'sending', + message_type: 'text', + }) + + resolveSend?.({ data: sentMessage('server-id'), success: true }) + await sending + + expect(messages.messages[0]).toMatchObject({ + delivery_status: 'delivered', + id: 'server-id', + }) + }) + + it('keeps a failed optimistic message visible', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: [], success: true }) + .mockResolvedValueOnce({ data: [], success: true }) + .mockResolvedValueOnce({ error: 'recipient_unavailable', success: false }) + + const messages = useMessagesStore() + await messages.openThread('4205550196') + await messages.send({ body: 'Hello', messageType: 'text' }) + + expect(messages.messages[0].delivery_status).toBe('failed') + }) + + it('loads protected voice media once and caches the data URI', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { mime: 'audio/webm;codecs=opus', payload: 'ZmFrZQ==' }, + success: true, + }) + + const messages = useMessagesStore() + expect(await messages.loadMedia('voice-id')).toBe(true) + expect(await messages.loadMedia('voice-id')).toBe(true) + + expect(messages.mediaSources['voice-id']).toBe( + 'data:audio/webm;codecs=opus;base64,ZmFrZQ==', + ) + expect(mockNuiCall).toHaveBeenCalledTimes(1) + }) +}) From 26d55e51504149044223396b0419f35da098ff53 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:58:02 +0200 Subject: [PATCH 15/62] ADD - render advanced voice messages --- .../src/components/VoiceMessageBubble.vue | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 frontend/src/components/VoiceMessageBubble.vue diff --git a/frontend/src/components/VoiceMessageBubble.vue b/frontend/src/components/VoiceMessageBubble.vue new file mode 100644 index 0000000..11fff5c --- /dev/null +++ b/frontend/src/components/VoiceMessageBubble.vue @@ -0,0 +1,103 @@ + + + From 5347c3802c184b99ca57cbdcd752a272523a8ebb Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:58:03 +0200 Subject: [PATCH 16/62] ADD - render message attachments --- .../components/MessageAttachmentBubble.vue | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 frontend/src/components/MessageAttachmentBubble.vue diff --git a/frontend/src/components/MessageAttachmentBubble.vue b/frontend/src/components/MessageAttachmentBubble.vue new file mode 100644 index 0000000..a8372f1 --- /dev/null +++ b/frontend/src/components/MessageAttachmentBubble.vue @@ -0,0 +1,98 @@ + + + From 3db1ca7d4a303fd905981d2c5fff92177af4e733 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:58:04 +0200 Subject: [PATCH 17/62] ADD - provide complete emoji picker --- frontend/src/components/FullEmojiPicker.vue | 100 ++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 frontend/src/components/FullEmojiPicker.vue diff --git a/frontend/src/components/FullEmojiPicker.vue b/frontend/src/components/FullEmojiPicker.vue new file mode 100644 index 0000000..725130a --- /dev/null +++ b/frontend/src/components/FullEmojiPicker.vue @@ -0,0 +1,100 @@ + + + From 18c38aa9428a621f7d0f91ffd75e2a274f6b6689 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:58:05 +0200 Subject: [PATCH 18/62] ADD - build iOS messages app --- frontend/src/views/apps/MessagesApp.vue | 1157 +++++++++++++++++++++++ 1 file changed, 1157 insertions(+) create mode 100644 frontend/src/views/apps/MessagesApp.vue diff --git a/frontend/src/views/apps/MessagesApp.vue b/frontend/src/views/apps/MessagesApp.vue new file mode 100644 index 0000000..99366d6 --- /dev/null +++ b/frontend/src/views/apps/MessagesApp.vue @@ -0,0 +1,1157 @@ + + + From 7f63e99482ac2796d71891b3e4bff3ed0c1f6a32 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:58:06 +0200 Subject: [PATCH 19/62] ADD - provide messages locale fallbacks --- frontend/src/stores/phone.ts | 89 ++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0d0f8b5..ee32d0f 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -27,6 +27,86 @@ const namespaceQueues = new Map>() const defaultLocales: LocaleTree = { Apps: { + messages: { + name: 'Messages', + newMessage: 'New message from {sender}', + compose: 'New Message', + search: 'Search', + to: 'To:', + message: 'Message', + send: 'Send', + details: 'Details', + filterUnread: 'Show Unread Messages', + smsLabel: 'Text Message · SMS', + photo: 'Photo', + gif: 'GIF', + video: 'Video', + attachPhoto: 'Attach Photo', + takePhoto: 'Take Photo', + attachGif: 'Attach GIF', + attachVideo: 'Attach Video', + photos: 'Photos', + gifs: 'GIFs', + videos: 'Videos', + noPhotos: 'Take a photo first to attach it here.', + noVideos: 'Record a video in Camera first.', + searchGifs: 'Search GIPHY', + loadMore: 'Load More', + moreActions: 'More Actions', + contactDetails: 'Contact Details', + contactName: 'Name', + phoneNumber: 'Phone Number', + call: 'Call', + messageAction: 'Message', + addContact: 'Add Contact', + deleteContact: 'Delete Contact', + selectedCount: '{count} Selected', + deleteSelected: 'Delete', + contactSaveFailed: 'The contact could not be saved.', + contactDeleteFailed: 'The contact could not be deleted.', + callFailed: 'The call could not be started.', + emoji: 'Emoji', + voiceMessage: 'Audio Message', + recordVoice: 'Record Audio', + playAudio: 'Play Audio', + pauseAudio: 'Pause Audio', + recording: 'Recording', + stopAndSend: 'Stop and Send', + cancelRecording: 'Cancel Recording', + sending: 'Sending...', + delivered: 'Delivered', + notDelivered: 'Not Delivered', + microphoneUnavailable: 'The microphone is unavailable.', + recordingTooLarge: 'The recording is too large.', + noSim: 'No SIM', + noSimBody: 'Insert a SIM card in Settings to send and receive messages.', + noMessages: 'No Messages', + noMessagesBody: 'Start a conversation with a phone number.', + noResults: 'No Results', + today: 'Today', + yesterday: 'Yesterday', + errors: { + invalid_number: 'Enter a valid phone number.', + invalid_message: 'Enter a message.', + invalid_voice: 'The audio message is invalid.', + invalid_attachment: 'The attachment is invalid.', + media_provider_unconfigured: 'Photo uploads are not configured.', + capture_provider_unavailable: 'The screenshot resource is unavailable.', + capture_failed: 'The photo could not be captured.', + video_provider_unavailable: 'Video capture requires the screencapture resource.', + video_capture_failed: 'The video could not be recorded.', + recording_in_progress: 'A video is already being recorded.', + recording_not_found: 'No active video recording was found.', + gif_provider_unconfigured: 'GIF search is not configured.', + gif_provider_failed: 'GIF search is temporarily unavailable.', + self_message: 'You cannot message your own number.', + recipient_not_found: 'That number is unavailable.', + no_sim: 'This phone has no SIM card.', + rate_limited: 'Too many messages. Try again in a minute.', + request_failed: 'Messages are temporarily unavailable.', + default: 'The message could not be sent.', + }, + }, appStore: { name: 'App Store', eyebrow: 'Discover', @@ -188,6 +268,15 @@ const defaultLocales: LocaleTree = { flip: 'Flip camera', flash: 'Toggle flash', controls: 'Camera controls', + errors: { + media_provider_unconfigured: 'Photo uploads are not configured.', + capture_provider_unavailable: 'The screenshot resource is unavailable.', + capture_failed: 'The photo could not be captured.', + video_provider_unavailable: 'Video capture requires the screencapture resource.', + video_capture_failed: 'The video could not be recorded.', + recording_in_progress: 'A video is already being recorded.', + recording_not_found: 'No active video recording was found.', + }, modes: { timelapse: 'Timelapse', slowMo: 'Slow-Mo', From e5c50ac0e29479a6bea792245a2a7a8326c8b775 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:58:07 +0200 Subject: [PATCH 20/62] CLN - remove local camera capture state --- frontend/src/stores/media.ts | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/frontend/src/stores/media.ts b/frontend/src/stores/media.ts index a3512d4..87c4594 100644 --- a/frontend/src/stores/media.ts +++ b/frontend/src/stores/media.ts @@ -3,32 +3,38 @@ import { defineStore } from 'pinia' import { usePhoneStore } from '@/stores/phone' export type PhonePhoto = { + attachmentId: string capturedAt: number gradient: string id: string titleKey: string + url?: string } const samplePhotos: PhonePhoto[] = [ { + attachmentId: 'sunset-drive', capturedAt: Date.now() - 86_400_000, gradient: 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)', id: 'sunset-drive', titleKey: 'Apps.photos.samples.sunset', }, { + attachmentId: 'ocean-air', capturedAt: Date.now() - 172_800_000, gradient: 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', id: 'ocean-air', titleKey: 'Apps.photos.samples.ocean', }, { + attachmentId: 'city-lights', capturedAt: Date.now() - 259_200_000, gradient: 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)', id: 'city-lights', titleKey: 'Apps.photos.samples.city', }, { + attachmentId: 'desert-road', capturedAt: Date.now() - 345_600_000, gradient: 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)', id: 'desert-road', @@ -48,21 +54,6 @@ export const useMediaStore = defineStore('media', { photos: (state): PhonePhoto[] => [...state.captures, ...samplePhotos], }, actions: { - capture(): void { - const gradients = [ - 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)', - 'linear-gradient(150deg, #00c9a7, #4d8076 46%, #1f3a5f)', - 'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)', - ] - const captureNumber = this.captures.length - this.captures.unshift({ - capturedAt: Date.now(), - gradient: gradients[captureNumber % gradients.length], - id: `capture-${Date.now()}`, - titleKey: 'Apps.photos.samples.capture', - }) - this.persist() - }, claimApp(id: string): void { if (!this.claimedApps.includes(id)) { this.claimedApps.push(id) @@ -74,7 +65,14 @@ export const useMediaStore = defineStore('media', { captures: PhonePhoto[] claimedApps: string[] }> | null - this.captures = Array.isArray(data?.captures) ? data.captures : [] + this.captures = Array.isArray(data?.captures) + ? data.captures.filter( + (photo) => + typeof photo.id === 'string' && + typeof photo.url === 'string' && + photo.url.startsWith('https://'), + ) + : [] this.claimedApps = Array.isArray(data?.claimedApps) ? data.claimedApps.filter((id): id is string => typeof id === 'string') : [] From 89b7abf471a96dad65dea8676ffd48e87fbae278 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 16:58:07 +0200 Subject: [PATCH 21/62] ENH - render hosted photo assets --- frontend/src/views/apps/PhotosApp.vue | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/apps/PhotosApp.vue b/frontend/src/views/apps/PhotosApp.vue index 2b015de..6ac2a53 100644 --- a/frontend/src/views/apps/PhotosApp.vue +++ b/frontend/src/views/apps/PhotosApp.vue @@ -4,6 +4,7 @@ import { computed, ref } from 'vue' import { useMediaStore } from '@/stores/media' import { usePhoneStore } from '@/stores/phone' +import type { PhonePhoto } from '@/stores/media' const media = useMediaStore() const phone = usePhoneStore() @@ -26,6 +27,20 @@ const filtered = computed(() => phone.t(photo.titleKey).toLowerCase().includes(query.value.toLowerCase()), ), ) + +function photoStyle(photo?: PhonePhoto): Record { + if (!photo) return {} + return { + background: photo.gradient, + ...(photo.url + ? { + backgroundImage: `url(${photo.url})`, + backgroundPosition: 'center', + backgroundSize: 'cover', + } + : {}), + } +}