Compare commits
601 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45df132dc6 | |||
| c42a285f0b | |||
| 1e3211ae74 | |||
| ec507b05d6 | |||
| 339bb1afac | |||
| c441012e02 | |||
| 0d61278c56 | |||
| ffd06ab561 | |||
| eb8dd330b6 | |||
| 6267e52bdf | |||
| ab21008f34 | |||
| 0998639d59 | |||
| eccde07d06 | |||
| 770cdc5f13 | |||
| 6bafb62414 | |||
| 6ce0fbbbe6 | |||
| 8fe42e6f22 | |||
| 47a6209730 | |||
| 24d3f867f8 | |||
| 9db60374e4 | |||
| 8ef4b21dab | |||
| 8f56812dd1 | |||
| 3833cb093d | |||
| 94db65b85e | |||
| 6f731e48d2 | |||
| 99fe0e543c | |||
| c6b0799b2a | |||
| 861f2286db | |||
| 9af3e3b2e9 | |||
| 341c1b45b2 | |||
| 89f5d8cdf5 | |||
| ca3270437d | |||
| bbbc6f7363 | |||
| 8a0abacf6f | |||
| 976ccdabd4 | |||
| 99b26680b6 | |||
| c5be477855 | |||
| 32c1501e9c | |||
| 463837e7d4 | |||
| d74f142cdd | |||
| 53954aae89 | |||
| 24aa62a503 | |||
| 2618bb9c63 | |||
| 32a31045ef | |||
| 56edad77a8 | |||
| fdb604e350 | |||
| 3c66dfd83c | |||
| 81633b0a1e | |||
| 4a967de184 | |||
| 59961cbdb5 | |||
| 95d9d8bf23 | |||
| 2fd9741a2b | |||
| fe9c325580 | |||
| 61e93d4071 | |||
| 1e4a4e43dc | |||
| e1a7b051bd | |||
| 7a7af58f5c | |||
| 016ae86d50 | |||
| 2bff060a5e | |||
| 68231504d0 | |||
| 0658a8ee44 | |||
| 43fb3d35e6 | |||
| 4cc1cc95ca | |||
| 964ef441ec | |||
| 796f37d320 | |||
| b46fd94578 | |||
| bdc8e75640 | |||
| ef08821796 | |||
| 9f386f6968 | |||
| ec0b6a99e2 | |||
| f6d9e52c6e | |||
| 90f86b833d | |||
| 29bb33c26c | |||
| c740bd21d4 | |||
| 1d92709c76 | |||
| a42e1df1a7 | |||
| e33beee17d | |||
| b10ea04cb3 | |||
| e8c94177ca | |||
| f1f2083c88 | |||
| f42889c3c2 | |||
| a75e1f96eb | |||
| 85c5293082 | |||
| 37efa6a62c | |||
| 1d5f91fb6c | |||
| ef18655776 | |||
| b786e858d9 | |||
| f4ebc4e99e | |||
| 65ca8a7fd8 | |||
| 7f1e98dcb2 | |||
| 4c19ee823b | |||
| 8e2dd8b278 | |||
| 8d35b3aad2 | |||
| 613cad31c0 | |||
| 3779a90f26 | |||
| 7470f28f31 | |||
| 17fb4e780b | |||
| 30c2a6ef79 | |||
| 0547e9513f | |||
| 70e5172f1b | |||
| 61c568a112 | |||
| ae2ba6f44d | |||
| f84006fbe4 | |||
| fed34a2747 | |||
| 80df16f97b | |||
| 18cb245599 | |||
| fd6cc84be6 | |||
| 9311cab3b2 | |||
| fceccf47be | |||
| fe20fbfd28 | |||
| 4f3a2a1660 | |||
| 1c8457a4bf | |||
| 8710043a02 | |||
| dc46b42cb6 | |||
| 2f1972e70a | |||
| c5fcf12165 | |||
| 61ed632579 | |||
| 86f4b75c52 | |||
| b26ab916d5 | |||
| c882198206 | |||
| 4aef27ffd5 | |||
| cf4e3f5fc6 | |||
| 57eb919c83 | |||
| 85cfaf2bc9 | |||
| 25a69a8191 | |||
| 6feeb23b1f | |||
| 4b92ffe3c5 | |||
| 823a9c3271 | |||
| fe89df2aa3 | |||
| 97ff8ff802 | |||
| a10a9e7043 | |||
| 4f42abc2ff | |||
| fe042c88b8 | |||
| 55e3a31b61 | |||
| 5760be4313 | |||
| 2fd7556a52 | |||
| e8ed9cd379 | |||
| eeeb3c96d2 | |||
| 2da5dee6bd | |||
| a66193ff45 | |||
| 55131ba7ce | |||
| df6282d2ba | |||
| 6ebe792ce5 | |||
| 6c9bdb2ccd | |||
| bc94c705f3 | |||
| 2b9b4da2cc | |||
| 090070d1f9 | |||
| 16a73f27c9 | |||
| 82245d895c | |||
| 610b8dd171 | |||
| f5b1e91378 | |||
| 1de6d7a874 | |||
| b716f3f792 | |||
| 75053bbbb1 | |||
| f9c7ed4936 | |||
| 1f5be54cb1 | |||
| 0761cdd28f | |||
| 7e2a0e9d5f | |||
| 7ae887561d | |||
| baa1d49b3a | |||
| 58a6be911a | |||
| 368f0b048b | |||
| 10894e17a5 | |||
| ec8a7a40e2 | |||
| ce30820108 | |||
| 147c756cc1 | |||
| c7fb404404 | |||
| 2546146ca8 | |||
| ffa776fd42 | |||
| a59ffb8758 | |||
| 9e74c94871 | |||
| 77294e7f1c | |||
| 57f2f4a619 | |||
| 1df2284ea3 | |||
| 189af077e7 | |||
| 10e4d83bce | |||
| 5d6661f964 | |||
| e6e11bb8f2 | |||
| 0e65e834da | |||
| 19f532c12e | |||
| bfc997ba37 | |||
| 99966a976e | |||
| cd54bc2880 | |||
| ffdd689331 | |||
| af41d81981 | |||
| 10d28ed364 | |||
| b02f4bd53a | |||
| 7ce8e3dbe6 | |||
| 2446d5cdb8 | |||
| d015018a16 | |||
| 6231c75e34 | |||
| 2f3bc6cc6f | |||
| 16c6015919 | |||
| e6b291cc68 | |||
| ae523c1ca6 | |||
| 7c86c1477f | |||
| 71f162f20d | |||
| eeacaca725 | |||
| af52ee25eb | |||
| eef32ca11e | |||
| 1ae821bff8 | |||
| 65483a6ef0 | |||
| 606a9343d9 | |||
| 7dfa6538be | |||
| 476d010ebe | |||
| 96d2efebc8 | |||
| f60f5af424 | |||
| 3da0334083 | |||
| c970038943 | |||
| 4000477bdb | |||
| ba11d53922 | |||
| beef606024 | |||
| 2adf64da55 | |||
| fd3fb8573c | |||
| e0d94d9794 | |||
| 7d049150a0 | |||
| 527ef59adc | |||
| b39775daef | |||
| 4bdb21560a | |||
| 797a9c32aa | |||
| bc864b29f8 | |||
| 482121db5c | |||
| 0fa26c8d0a | |||
| f5c768d6a7 | |||
| c43544734a | |||
| 86d99916f7 | |||
| 135dbc8789 | |||
| fc14de9b0f | |||
| c77197d959 | |||
| 56dddbdd86 | |||
| cbafcf6939 | |||
| 4b156ee699 | |||
| a4e883b09a | |||
| b114a724e2 | |||
| 621c0d839c | |||
| 021c1fc7c4 | |||
| bda91080ab | |||
| a9828be25c | |||
| dde9dbfbfe | |||
| ca7d126a3c | |||
| 7f6450375b | |||
| c9954db3fe | |||
| 3d268f1f9d | |||
| 66a7a2a7af | |||
| 7823e1d803 | |||
| d5e91ce874 | |||
| 6f32c1932f | |||
| cb06c4e954 | |||
| 9188c0a8bc | |||
| 30653fe344 | |||
| 5bb55c453f | |||
| 3024e08ca5 | |||
| aaf1f25167 | |||
| aabbb758a4 | |||
| d824f928b5 | |||
| 445ed27eb8 | |||
| 21f3970ca8 | |||
| 919fe29ffb | |||
| 3b2870a318 | |||
| 1076e8531c | |||
| 72f2effda4 | |||
| 7566f56858 | |||
| c1f9c9e25e | |||
| 380a52efb3 | |||
| 028c3ba92b | |||
| f80aba33f1 | |||
| bb15c9e2d0 | |||
| 518c80bb1d | |||
| 0067d049e6 | |||
| bf60dd24aa | |||
| 38d9cc4892 | |||
| c4f2f55617 | |||
| 8f73fb85e9 | |||
| 3bd126c11d | |||
| 7e7aba06a6 | |||
| 2bf00f7ddc | |||
| 24b88e4ac0 | |||
| 3df3130395 | |||
| c0c388d1b9 | |||
| 9f27cc61da | |||
| 8fa1987ec0 | |||
| 39eae42b05 | |||
| 4dfbb437f9 | |||
| f132d22308 | |||
| b7dd2ff8b4 | |||
| b6b78591bc | |||
| ec54a0dbce | |||
| 8793912b65 | |||
| 70c430ddc2 | |||
| 815dbba497 | |||
| dc5bac67aa | |||
| 5427fd7860 | |||
| 119c09d730 | |||
| 1da6833c71 | |||
| 4b8cf53731 | |||
| d646ddd91d | |||
| 764719afde | |||
| 75ec7688b1 | |||
| 7fc508603f | |||
| fb2d78fd57 | |||
| 4480132c74 | |||
| 38c0a9d403 | |||
| 4169db33e6 | |||
| ee48072137 | |||
| a3c1a5c731 | |||
| e74f922e8d | |||
| 16cd90f7b7 | |||
| e2ba10d224 | |||
| 459e9359db | |||
| d2a044f958 | |||
| 2fbcd644d0 | |||
| cf8e736f46 | |||
| d4378731ae | |||
| 000344a942 | |||
| bf6abd301c | |||
| 143a1dd39b | |||
| 9b3a8258ce | |||
| 646b8f8736 | |||
| 2528e4acad | |||
| 286d939097 | |||
| ca3d661830 | |||
| 63fee653e8 | |||
| 9da2473976 | |||
| 6d1eeacc49 | |||
| f85748fef9 | |||
| 9f34b33b7e | |||
| 1510f39a8a | |||
| bbbe011482 | |||
| 82ab7a043f | |||
| 798253a50e | |||
| 52432ca068 | |||
| b3f1d8464b | |||
| 87bb62b359 | |||
| 3f914d02cc | |||
| d1db77d0f5 | |||
| 6aa297c1a4 | |||
| f3647e9bc1 | |||
| 5b43c62f2d | |||
| 23ffb15a8d | |||
| adb2ce4846 | |||
| cdee6ca743 | |||
| fe30aa4af2 | |||
| 9943728eab | |||
| 8ae7cf05cc | |||
| a7c944e8ef | |||
| 102339d7e8 | |||
| 9a0ad0c663 | |||
| f86afc08fa | |||
| cd1b328b1b | |||
| 48f2bb1c75 | |||
| d416fe913e | |||
| 7f8744725c | |||
| e5d1b82a9d | |||
| 619cf2e134 | |||
| 28b522f015 | |||
| 39233f261e | |||
| 00f0127caf | |||
| f69b575381 | |||
| 986973a605 | |||
| 0d6b4591f1 | |||
| 2c62749d9b | |||
| 4be4288ef0 | |||
| c7eec167cf | |||
| 7bae956ffa | |||
| a2f59a5b1b | |||
| df56af9b0e | |||
| 83f7f9584f | |||
| a2d440e54f | |||
| 4132e8449b | |||
| ee444416e4 | |||
| 10c12c3c48 | |||
| db3775ae99 | |||
| 393acce884 | |||
| 68fe663730 | |||
| f65a4b0db0 | |||
| cdfb502e6e | |||
| 1a2c83e49b | |||
| e6c7a675a9 | |||
| 69c04f29f4 | |||
| 04c6f9b4fe | |||
| 86ec12a9db | |||
| 72b3111c64 | |||
| 6709c91779 | |||
| bb6e7495f5 | |||
| df17929681 | |||
| e083719ceb | |||
| bfdc69f18c | |||
| e7ae20afb7 | |||
| 229d92055f | |||
| 64c77cfd13 | |||
| 3a63894562 | |||
| 1d272f8b37 | |||
| bac433b640 | |||
| 62f573eac0 | |||
| b3ea62c53c | |||
| b0731503a8 | |||
| 2421c02c24 | |||
| 25e868118d | |||
| 2880044e0e | |||
| 5300404b46 | |||
| d949d58076 | |||
| 997b223e95 | |||
| ba52a97e26 | |||
| cc4c7b5fe9 | |||
| 8e2ebd11fc | |||
| 9cae4da9f4 | |||
| c05d7e99e2 | |||
| 2390599e8f | |||
| 1a4d45fa9c | |||
| 57447e5bf4 | |||
| 8e411daaef | |||
| 183aebf841 | |||
| e3e500ccc2 | |||
| e7a2535ece | |||
| 761e369313 | |||
| 5248275d73 | |||
| cb033279dd | |||
| 41d50d8c28 | |||
| a52c2b4c3c | |||
| b5917cb184 | |||
| 57348472f8 | |||
| 4b6223dc00 | |||
| 5525e45a15 | |||
| 80a2ae60b0 | |||
| d7e95f5d2f | |||
| ca4e5ae5ee | |||
| b673658c0c | |||
| 5c5c130700 | |||
| 2d89ca6c0e | |||
| 806a7c2609 | |||
| 501ce8067d | |||
| 6429f82829 | |||
| fe626218ea | |||
| b62b1bc063 | |||
| d980f36246 | |||
| b469addd29 | |||
| 6923c2a8b7 | |||
| 1d3f32fb99 | |||
| 42a550788a | |||
| b1c68972c9 | |||
| 3978e32d5f | |||
| ba2b5d182e | |||
| bef04fa899 | |||
| 4f8609421c | |||
| 07660c9d44 | |||
| a324dad2ba | |||
| dbaa0f5d49 | |||
| 478721d349 | |||
| a669a23dbc | |||
| cfeb6478cc | |||
| 64539c49c1 | |||
| 0399ae37ec | |||
| 173a411a36 | |||
| 62013a2ea2 | |||
| c82cf4a4c2 | |||
| df42085be6 | |||
| b09d3065ae | |||
| c050f5a9e3 | |||
| 78e6c0eca0 | |||
| da4da45348 | |||
| dc2af86db8 | |||
| 7502004aba | |||
| 2e8678e4c6 | |||
| 97569016fc | |||
| fe72798592 | |||
| 4583c4e028 | |||
| 0b98197a86 | |||
| 0e94a9c33f | |||
| 3dff1fcb4d | |||
| e163286dd4 | |||
| a99e12f12e | |||
| c3dd997e57 | |||
| a730384baf | |||
| 43cf91e877 | |||
| 75bee027e1 | |||
| 5cbf69a8bd | |||
| ecbb3086d8 | |||
| 7476767aa7 | |||
| e5b8987a9d | |||
| 6ca74c21bf | |||
| e0099141aa | |||
| d0491ed202 | |||
| cbc2137ced | |||
| f9ac13ba11 | |||
| b3533c285f | |||
| a636ae6592 | |||
| 69e3ee0aff | |||
| a39a87ba6d | |||
| 5b22d6ac01 | |||
| 7334cd26f8 | |||
| 44555215cf | |||
| 0cc25913c0 | |||
| 004b30b737 | |||
| 632f330b4c | |||
| 666433912f | |||
| db98ce8db7 | |||
| 71dcfae5ff | |||
| 04155f5b23 | |||
| b4058389ec | |||
| 483fa81b74 | |||
| a8d1c4bbbc | |||
| 0a8c2faa74 | |||
| dd3231e70f | |||
| 7ff9c00032 | |||
| 9ed483abf7 | |||
| b9aeaf29a4 | |||
| 65e3f81f36 | |||
| c6641dba31 | |||
| e48d1e0e59 | |||
| d1e5aee84e | |||
| 5cb22d0bed | |||
| d1c6f53d7c | |||
| 6e238f98c0 | |||
| 290274d6c8 | |||
| e1de0239c9 | |||
| bec77d59e8 | |||
| 84f8794d7c | |||
| 4cddf41bf3 | |||
| 125a7e238e | |||
| 468200b717 | |||
| bdfcb99781 | |||
| 38da650861 | |||
| dd006badfc | |||
| 87e4e3fe5b | |||
| af3e38254f | |||
| 70843f54d3 | |||
| bda75b29b4 | |||
| 750830d593 | |||
| 3c0f1a1d2f | |||
| 4253b0ed29 | |||
| 3c9b3f23df | |||
| e0d83608be | |||
| a0301d599b | |||
| 7dcaa0e8d7 | |||
| 129f49bcc7 | |||
| fc3b68c390 | |||
| 52c7df8a15 | |||
| ce1c4dd488 | |||
| fc6a1a3819 | |||
| 69bd6d0e70 | |||
| 6d383d54e8 | |||
| 998c48b1d3 | |||
| 7217d122b5 | |||
| 1c37c5bb3d | |||
| e8f785b558 | |||
| c94d314f6d | |||
| 2672a8f922 | |||
| 8a8d80d692 | |||
| 95698813c6 | |||
| 4001e877b4 | |||
| 99defc6d79 | |||
| a94883089e | |||
| 5ea4aeb75c | |||
| 456d111925 | |||
| 837ae4b38e | |||
| ffbcbf86c3 | |||
| bcda637192 | |||
| 72c7dd6126 | |||
| a2a4b3599f | |||
| 4955a4f16c | |||
| bb1ff4fb11 | |||
| b81f7c9ed3 | |||
| 689cfb6d45 | |||
| 9da3141650 | |||
| e4fe18df2f | |||
| ba80ebac63 | |||
| d4943daa82 | |||
| cde03ec0fe | |||
| 4f6c08f8a2 | |||
| 38e0fc53ad | |||
| 2a30ca5306 | |||
| 4a4ea13bef | |||
| 239bd3f31a | |||
| 831ec05012 | |||
| 0cc0598287 | |||
| 0a5bc618c2 | |||
| 069904f07a | |||
| 03b42c8276 | |||
| 8697cc23be | |||
| 69e1f97e72 | |||
| 3e832af3e4 | |||
| 84b8650fa4 | |||
| 83abb5aa94 | |||
| a12eddb47b | |||
| c87166247c | |||
| 037c8cb41b | |||
| 79de2e1176 | |||
| d4b026a3ad | |||
| 00f383ff2e | |||
| 6f6bb508db | |||
| e2a0672ca5 | |||
| e2a5fe7a79 | |||
| 5d02ae75dc | |||
| 2460bbbc83 | |||
| 084d8d931b | |||
| 6ee4ac1a89 | |||
| 1d07097350 | |||
| 63d6b362c7 | |||
| bfed277ea9 | |||
| 9e8aa2ef3a | |||
| 4bbc0878f7 |
@@ -1,8 +1,10 @@
|
|||||||
--ignore-dir=.svelte-kit
|
--ignore-dir=.svelte-kit
|
||||||
--ignore-dir=android
|
--ignore-dir=android
|
||||||
|
--ignore-dir=target
|
||||||
--ignore-dir=build
|
--ignore-dir=build
|
||||||
--ignore-dir=ios/DerivedData
|
--ignore-dir=ios/DerivedData
|
||||||
--ignore-dir=ios/App/App/public
|
--ignore-dir=ios/App/App/public
|
||||||
|
--ignore-dir=ios/App/Pods
|
||||||
--ignore-file=match:.svg
|
--ignore-file=match:.svg
|
||||||
--ignore-file=match:package-lock.json
|
--ignore-file=match:package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,10 @@ node_modules
|
|||||||
android
|
android
|
||||||
ios
|
ios
|
||||||
build
|
build
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Env files (keep .env for build; exclude local overrides)
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
||||||
VITE_BURROW_URL=
|
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
|
||||||
VITE_PLATFORM_URL=https://flotilla.social
|
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
|
||||||
|
VITE_PLATFORM_URL=https://app.flotilla.social
|
||||||
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||||
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||||
VITE_PLATFORM_NAME=Flotilla
|
VITE_PLATFORM_NAME=Flotilla
|
||||||
VITE_PLATFORM_LOGO=static/flotilla.png
|
VITE_PLATFORM_LOGO=static/logo.png
|
||||||
VITE_PLATFORM_RELAYS=
|
VITE_PLATFORM_RELAYS=
|
||||||
VITE_PLATFORM_ACCENT="#7161FF"
|
VITE_PLATFORM_ACCENT="#7161FF"
|
||||||
VITE_PLATFORM_SECONDARY="#EB5E28"
|
VITE_PLATFORM_SECONDARY="#EB5E28"
|
||||||
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
||||||
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
|
VITE_PUSH_SERVER=https://nps.flotilla.social/
|
||||||
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
|
VITE_PUSH_BRIDGE=wss://npb.coracle.social/
|
||||||
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
|
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
|
||||||
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
|
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
|
||||||
VITE_VAPID_PUBLIC_KEY=
|
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
|
||||||
|
VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
|
||||||
|
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
||||||
|
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
||||||
|
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||||
|
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
|
||||||
VITE_GLITCHTIP_API_KEY=
|
VITE_GLITCHTIP_API_KEY=
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
GLITCHTIP_AUTH_TOKEN=
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
src/assets
|
src/assets
|
||||||
|
.claude
|
||||||
|
target
|
||||||
build
|
build
|
||||||
.idea
|
.idea
|
||||||
.gradle
|
.gradle
|
||||||
@@ -12,4 +14,4 @@ ios/App/Pods/
|
|||||||
android/capacitor-cordova-android-plugins
|
android/capacitor-cordova-android-plugins
|
||||||
android/app/src/androidTest
|
android/app/src/androidTest
|
||||||
android/app/src/test
|
android/app/src/test
|
||||||
|
node_modules
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
name: Docker
|
name: Container Image Build and Publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['master']
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: gitea.coracle.social
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: coracle/flotilla
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
@@ -14,8 +19,6 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -25,8 +28,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: hodlbod
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
@@ -34,6 +37,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
|
type=sha
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -47,13 +51,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
target: production
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
|
||||||
uses: actions/attest-build-provenance@v2
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# Env
|
# Env
|
||||||
.env
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
@@ -24,8 +25,11 @@ android/app/src/main/assets/public/
|
|||||||
|
|
||||||
# Web/JavaScript
|
# Web/JavaScript
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
build/
|
build/
|
||||||
|
build-server/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
|
.next/
|
||||||
|
|
||||||
# iOS
|
# iOS
|
||||||
ios/App/App/public
|
ios/App/App/public
|
||||||
@@ -63,7 +67,9 @@ GoogleService-Info.plist
|
|||||||
.roo
|
.roo
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# OS generated
|
# OS generated
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
package-lock.json
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
"svelteSortOrder": "options-styles-scripts-markup",
|
"svelteSortOrder": "options-styles-scripts-markup",
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
"bracketSpacing": false,
|
"bracketSpacing": false,
|
||||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
"plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-svelte"],
|
||||||
"overrides": [{"files": "*.svelte", "options": {"parser": "svelte"}}]
|
"overrides": [{"files": "*.svelte", "options": {"parser": "svelte"}}]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
|
||||||
|
|
||||||
|
**Tech Stack:**
|
||||||
|
|
||||||
|
- SvelteKit 5.48+ with TypeScript 5.9+
|
||||||
|
- Capacitor for cross-platform (Web/PWA, Android, iOS)
|
||||||
|
- TailwindCSS + DaisyUI for styling
|
||||||
|
- Welshman library suite for Nostr protocol
|
||||||
|
- IndexedDB for local storage
|
||||||
|
- Vite for building
|
||||||
|
|
||||||
|
**Key Concepts:**
|
||||||
|
|
||||||
|
- **Spaces** - Relays used as community groups (like Discord servers)
|
||||||
|
- **Rooms** - NIP-29 groups within spaces (like Discord channels), identified by `h`
|
||||||
|
- **Chats** - Direct message conversations (NIP-04/NIP-44 encrypted)
|
||||||
|
|
||||||
|
## Architecture & Dependency Graph
|
||||||
|
|
||||||
|
The project follows a **strict acyclic dependency hierarchy**:
|
||||||
|
|
||||||
|
```
|
||||||
|
routes/ (top layer - can depend on anything)
|
||||||
|
↓
|
||||||
|
app/components/ (can depend on app/* and lib/*)
|
||||||
|
↓
|
||||||
|
app/core/ & app/util/ (can only depend on lib/*)
|
||||||
|
↓
|
||||||
|
lib/ (can only depend on external libraries)
|
||||||
|
↓
|
||||||
|
external libraries (bottom layer)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import Ordering Convention (CRITICAL):**
|
||||||
|
Always sort imports by dependency level:
|
||||||
|
|
||||||
|
1. Third-party libraries first
|
||||||
|
2. Then `lib/` imports
|
||||||
|
3. Then `app/` imports
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {throttle} from "throttle-debounce"
|
||||||
|
import {Dialog} from "$lib/components"
|
||||||
|
import {repository} from "$app/core/state"
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/ # Generic reusable code
|
||||||
|
│ ├── components/ # 38 UI components (Button, Dialog, etc.)
|
||||||
|
│ ├── html.ts # DOM utilities
|
||||||
|
│ ├── indexeddb.ts # IndexedDB helpers
|
||||||
|
│ └── util.ts # Generic utilities
|
||||||
|
│
|
||||||
|
├── app/
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── state.ts # State management, stores, constants (687 lines)
|
||||||
|
│ │ ├── commands.ts # Publishing events and other write operations (440+ lines)
|
||||||
|
│ │ ├── requests.ts # Loading data from network (191 lines)
|
||||||
|
│ │ ├── sync.ts # Data synchronization (296 lines)
|
||||||
|
│ │ └── storage.ts # IndexedDB setup
|
||||||
|
│ │
|
||||||
|
│ ├── util/
|
||||||
|
│ │ ├── notifications.ts # Push notifications (731 lines)
|
||||||
|
│ │ ├── policies.ts # Relay policies
|
||||||
|
│ │ ├── routes.ts # Routing helpers
|
||||||
|
│ │ ├── modal.ts # Modal management
|
||||||
|
│ │ ├── toast.ts # Toast notifications
|
||||||
|
│ │ ├── theme.ts # Theme switching
|
||||||
|
│ │ └── keyboard.ts # Keyboard handling
|
||||||
|
│ │
|
||||||
|
│ ├── editor/ # Rich text editor config
|
||||||
|
│ │ ├── index.ts # TipTap setup with Nostr integration
|
||||||
|
│ │ ├── EditorContent.svelte
|
||||||
|
│ │ └── MentionNodeView.ts
|
||||||
|
│ │
|
||||||
|
│ └── components/ # 188 app-specific components
|
||||||
|
│ ├── Space*.svelte # Space/relay management
|
||||||
|
│ ├── Room*.svelte # Room/channel management
|
||||||
|
│ ├── Chat*.svelte # Direct messaging
|
||||||
|
│ ├── Profile*.svelte # User profiles
|
||||||
|
│ ├── Thread*.svelte # Threaded posts
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── routes/ # SvelteKit file-based routing
|
||||||
|
│ ├── +layout.svelte # Root layout (sync logic here)
|
||||||
|
│ ├── spaces/ # Space management
|
||||||
|
│ │ └── [relay]/ # Specific space
|
||||||
|
│ │ ├── chat/ # Space chat
|
||||||
|
│ │ ├── threads/ # Thread posts
|
||||||
|
│ │ ├── calendar/ # Events
|
||||||
|
│ │ └── [h]/ # Specific room (h = room id)
|
||||||
|
│ ├── chat/ # Direct messages
|
||||||
|
│ ├── settings/ # User settings
|
||||||
|
│ └── [bech32]/ # Bech32 entity viewer
|
||||||
|
│
|
||||||
|
├── assets/icons/ # ~1,277 SVG icons
|
||||||
|
├── app.html # HTML template
|
||||||
|
├── app.css # Global styles
|
||||||
|
└── types.d.ts # Type definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
**Core Principles:**
|
||||||
|
|
||||||
|
- Use Svelte 4 **stores** for all state (NOT runes outside UI components)
|
||||||
|
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||||
|
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||||
|
- Update state by publishing events via `publishThunk`
|
||||||
|
|
||||||
|
**Thunks:**
|
||||||
|
|
||||||
|
- Reduce UI latency by handling signatures and sending in background
|
||||||
|
- Return status that should be displayed to user
|
||||||
|
- Allow cancellation and error handling
|
||||||
|
- Immediately publish to local repository for optimistic updates
|
||||||
|
|
||||||
|
## Nostr Integration
|
||||||
|
|
||||||
|
**Welshman Library Suite:**
|
||||||
|
|
||||||
|
- `@welshman/app` - High-level state (pubkey, signer, repository, tracker)
|
||||||
|
- `@welshman/net` - Network layer (Pool, Socket, load, pull, request)
|
||||||
|
- `@welshman/store` - Svelte integration (deriveEventsMapped, etc.)
|
||||||
|
- `@welshman/util` - Event utilities (kinds, tags, validation)
|
||||||
|
- `@welshman/signer` - Signing abstraction (NIP-01, NIP-07, NIP-46)
|
||||||
|
- `@welshman/router` - Relay routing (inbox/outbox model)
|
||||||
|
- `@welshman/editor` - Rich text editor with Nostr
|
||||||
|
- `@welshman/content` - Content parsing
|
||||||
|
- `@welshman/feeds` - Feed management
|
||||||
|
|
||||||
|
**Key NIPs Implemented:**
|
||||||
|
|
||||||
|
- NIP-01: Basic protocol
|
||||||
|
- NIP-44/59/17: Encrypted DMs
|
||||||
|
- NIP-07: Browser extension signing
|
||||||
|
- NIP-19: Bech32 encoding
|
||||||
|
- NIP-29: Relay-based Groups
|
||||||
|
- NIP-42: Relay authentication
|
||||||
|
- NIP-43: Relay membership
|
||||||
|
- NIP-46: Nostr Connect (remote signing)
|
||||||
|
- NIP-57: Lightning Zaps
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
**Component Parameterization:**
|
||||||
|
|
||||||
|
- Only pass entity identifiers (`url` for spaces, `h` for rooms)
|
||||||
|
- Derive all other data inside the component from identifiers
|
||||||
|
- Example: Don't pass `members` prop, derive it from `h` inside component
|
||||||
|
|
||||||
|
**CRITICAL Code Style Guidelines:**
|
||||||
|
|
||||||
|
- **No `null`** - only use `undefined`
|
||||||
|
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
|
||||||
|
- TailwindCSS and DaisyUI styling
|
||||||
|
- Only add comments for really weird stuff
|
||||||
|
- Do not call functions in components unless a parameter is reactive. Instead, use a svelte store or rune to make it reactive.
|
||||||
|
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||||
|
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
|
||||||
|
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
|
||||||
|
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
||||||
|
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||||
|
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
|
||||||
|
|
||||||
|
**Human-First Simplicity (Jon Staab Style):**
|
||||||
|
|
||||||
|
- Prefer direct, readable code over layered abstractions.
|
||||||
|
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
|
||||||
|
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies.
|
||||||
|
- Favor linear control flow and explicit naming over clever patterns.
|
||||||
|
- Remove defensive checks that do not apply in this runtime model.
|
||||||
|
- When two approaches work, pick the one that feels more human and easier to maintain.
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New Component
|
||||||
|
|
||||||
|
1. Determine if it's generic (`lib/components/`) or app-specific (`app/components/`)
|
||||||
|
2. Follow naming convention: `PascalCase.svelte`
|
||||||
|
3. Import in dependency order (3rd party → lib → app)
|
||||||
|
4. Use stores for state, runes only for UI reactivity
|
||||||
|
|
||||||
|
### Creating a New Route
|
||||||
|
|
||||||
|
1. Add to `src/routes/` following SvelteKit conventions
|
||||||
|
2. Use `+page.svelte` for page component
|
||||||
|
3. Use `+layout.svelte` for shared layouts
|
||||||
|
4. Top-level sync logic goes in root `+layout.svelte`
|
||||||
|
|
||||||
|
### Loading Data from Network
|
||||||
|
|
||||||
|
1. Use utilities from `app/core/requests.ts`
|
||||||
|
2. Or create derived stores in `app/core/state.ts`
|
||||||
|
3. Use `load`, `pull`, or `request` from `@welshman/net`
|
||||||
|
|
||||||
|
### Publishing Events
|
||||||
|
|
||||||
|
1. Create `make*` function to build event template
|
||||||
|
2. Create `publish*` function using `publishThunk`
|
||||||
|
3. Display thunk status to user (for cancel/error handling)
|
||||||
|
4. These go in in `app/core/commands.ts`
|
||||||
|
|
||||||
|
### Managing Modals/Toasts
|
||||||
|
|
||||||
|
- Import from `app/util/modal.ts` or `app/util/toast.ts`
|
||||||
|
- Pass component objects with parameters
|
||||||
|
- Use `$state.snapshot` if calling component might unmount
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
Agents should not run the dev server or build the app. Instead, use the following commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run format # Format changed files
|
||||||
|
pnpm run lint # Check formatting and linting
|
||||||
|
pnpm run check # Type check
|
||||||
|
```
|
||||||
|
|
||||||
|
**Welshman Development:**
|
||||||
|
|
||||||
|
- Clone welshman to parent directory
|
||||||
|
- Use `./link_deps` script to link local welshman packages
|
||||||
|
- Avoid committing `pnpm.overrides` changes
|
||||||
|
|
||||||
|
**Git Workflow:**
|
||||||
|
|
||||||
|
- `master` branch auto-deploys to production
|
||||||
|
- Work on feature branches based on `dev` branch
|
||||||
|
- Pre-commit hooks run lint/typecheck automatically
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.template` for all options.
|
||||||
|
|
||||||
|
## Important Files to Reference
|
||||||
|
|
||||||
|
- **src/app/core/state.ts** - All stores and constants
|
||||||
|
- **src/app/core/sync.ts** - Data synchronization
|
||||||
|
- **src/app/core/requests.ts** - Utilities for requesting data
|
||||||
|
- **src/app/core/commands.ts** - Publishing patterns
|
||||||
|
- **src/app/util/notifications.ts** - Notification badges and push notifications
|
||||||
|
- **src/routes/+layout.svelte** - Top-level sync logic
|
||||||
|
|
||||||
|
## Mobile Development
|
||||||
|
|
||||||
|
**Capacitor Integration:**
|
||||||
|
|
||||||
|
- Android: Full support, APK builds via `pnpm run release:android`
|
||||||
|
- iOS: Full support (zaps disabled due to App Store policy)
|
||||||
|
- PWA: Progressive Web App with service worker
|
||||||
|
|
||||||
|
**Native Features:**
|
||||||
|
|
||||||
|
- Push notifications (FCM/APNs)
|
||||||
|
- Deep linking (nostr: and https: URLs)
|
||||||
|
- Native signing plugin
|
||||||
|
- Keyboard management
|
||||||
|
- Safe area handling
|
||||||
|
- Badge management
|
||||||
@@ -1,5 +1,308 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
# 1.8.0
|
||||||
|
|
||||||
|
* Fix relay badge overflow
|
||||||
|
* Suppress programmatic scroll when user is scrolling
|
||||||
|
* Fix vertical alignment of emoji and overflow buttons in shared event action row
|
||||||
|
* Use type=email for signup/login email inputs, validate password
|
||||||
|
* Improve toggle switch placement on settings screens
|
||||||
|
* Fix relay auth privacy toggle
|
||||||
|
* Improve field layout
|
||||||
|
* Add progress bar to signup flow
|
||||||
|
* Bundle emojis properly
|
||||||
|
* Rework hosting page
|
||||||
|
* Fix padding on pages on small screens
|
||||||
|
* Add richer link preview support
|
||||||
|
* Fix pasting into event summary
|
||||||
|
* Publish fewer join/claim requests
|
||||||
|
* Fix new messages not rendering in safari
|
||||||
|
* Avoid capturing stale cleanup function in chat
|
||||||
|
* Hide keyboard on app resume
|
||||||
|
* Add email rendering support
|
||||||
|
* Fix bunker login
|
||||||
|
* Fix undefined chat draft key
|
||||||
|
* Allow sharing to chat without a message
|
||||||
|
* Make sure to show date on calendar events when embedded
|
||||||
|
* Improve space search
|
||||||
|
|
||||||
|
# 1.7.4
|
||||||
|
|
||||||
|
* Fix safe area inset for FAB
|
||||||
|
|
||||||
|
# 1.7.3
|
||||||
|
|
||||||
|
* Add native share support for space invites
|
||||||
|
* Stop sending duplicate requests per room
|
||||||
|
* Add more robust thumbnail url generation
|
||||||
|
* Make space reordering discoverable with smoother drag animation
|
||||||
|
* Improve relay member list
|
||||||
|
* Add room mentions and clickable room/relay refs
|
||||||
|
* Support native clipboard image paste on mobile
|
||||||
|
* publish kind 9 quote after room content creation for cross-client interoperability
|
||||||
|
* Improve feed pagination logic and performance
|
||||||
|
* Support Aegis URL scheme for NIP-46 login
|
||||||
|
* Various UI and bug fixes
|
||||||
|
* Raise message size limit in chat
|
||||||
|
* Fix realtime updates for room members and admins
|
||||||
|
* Add video to calls
|
||||||
|
* Remove follow graph building
|
||||||
|
* Add start chat FAB
|
||||||
|
* Add drafts
|
||||||
|
* Redesign toast notifications
|
||||||
|
* Remove room/space leave indications
|
||||||
|
* Hide report badge for non-admin users
|
||||||
|
* Add polls
|
||||||
|
* Add search to recent activity page
|
||||||
|
* Fix notification badge on mobile nav
|
||||||
|
* Change audio devices in call
|
||||||
|
|
||||||
|
# 1.7.2
|
||||||
|
|
||||||
|
* Fix race condition in nip 46
|
||||||
|
* Remove duplicate spaces button
|
||||||
|
* Combine discover and space list pages
|
||||||
|
* Fix some chat related bugs
|
||||||
|
* Fix bug with joining spaces
|
||||||
|
|
||||||
|
# 1.7.1
|
||||||
|
|
||||||
|
* Fix pomade registration fallback in case of offline signer
|
||||||
|
|
||||||
|
# 1.7.0
|
||||||
|
|
||||||
|
* Enable email/password login
|
||||||
|
* Add up/edit to direct messages
|
||||||
|
* Fix a number of UI bugs
|
||||||
|
* Improve navigation on mobile
|
||||||
|
* Improve performance and syncing reliability
|
||||||
|
* Add proof of work to DMs
|
||||||
|
* Detect blossom support using supported_nips
|
||||||
|
* Improve notification badges
|
||||||
|
* Add voice rooms (@mplorentz)
|
||||||
|
* Re-design relay onboarding and settings
|
||||||
|
* Add android fallback for push notifications
|
||||||
|
* Fix file uploads on android
|
||||||
|
|
||||||
|
# 1.6.5
|
||||||
|
|
||||||
|
* Attempt to fix permission grant for notifications
|
||||||
|
* Make sync logic more robust
|
||||||
|
* Add unban/unallow support
|
||||||
|
* Improve support for downloading/opening protected images
|
||||||
|
* Add manual send/receive to wallet
|
||||||
|
* Show wallet status when wallet is unreachable
|
||||||
|
* Update nostr signer capacitor plugin
|
||||||
|
* Fix some safe area insets
|
||||||
|
* Update NIP 55 signer plugin (fixes Primal login)
|
||||||
|
* Refine space join dialogs and discover page
|
||||||
|
* Reopen the last DM that was open when navigating back to chat
|
||||||
|
* Get rid of ChatEnable interstitial
|
||||||
|
* Enable auth for relays we're publishing to
|
||||||
|
* Drag and drop space icons
|
||||||
|
* Add better muting support
|
||||||
|
* Add back button to settings menu
|
||||||
|
* Add page titles
|
||||||
|
* Improve scroll to event behavior
|
||||||
|
* Add in-memory search to rooms
|
||||||
|
* Fix editing messages with html tags
|
||||||
|
* Fix DM media detection
|
||||||
|
* Clean up reporting dialogs
|
||||||
|
* Improve room detail
|
||||||
|
|
||||||
|
# 1.6.4
|
||||||
|
|
||||||
|
* Clean up modal design
|
||||||
|
* Fix overflowing popovers
|
||||||
|
* Use space urls for relay hints
|
||||||
|
* Re-work notification badges
|
||||||
|
* Add push notification support via NIP 9a
|
||||||
|
* Optimistically load messaging relays to avoid unnecessary warning
|
||||||
|
* Recover from indexeddb not being available
|
||||||
|
* Fix safe area inset support
|
||||||
|
* Show space URL in top bar on mobile
|
||||||
|
* Fix calendar detail page
|
||||||
|
* Improve relay synchronization, especially for pyramid and relay29
|
||||||
|
* Improve invite code error handling
|
||||||
|
* Add wallet receive flow
|
||||||
|
* Fix safari image uploads
|
||||||
|
* Re-work recent activity page
|
||||||
|
* Add classified listing content type
|
||||||
|
* Use address for page param for replaceable events
|
||||||
|
* Refine discover page to avoid slowness
|
||||||
|
* Upgrade som dependencies
|
||||||
|
* Tag event author when tagging parent event
|
||||||
|
* Disable macos build
|
||||||
|
* Add room muting
|
||||||
|
|
||||||
|
# 1.6.3
|
||||||
|
|
||||||
|
* Fix scroll down button z index
|
||||||
|
* Hide tooltips on mobile
|
||||||
|
* Sort comments ascending
|
||||||
|
* Make video embeds rounded
|
||||||
|
* Fix ProfileMultiSelect styling
|
||||||
|
* Accept hex pubkeys/npubs/nprofiles in ProfileMultiSelect
|
||||||
|
* Tweak room edit form design
|
||||||
|
* Report pending signer to user
|
||||||
|
* Update default relays
|
||||||
|
* Fix chat list responsiveness
|
||||||
|
* Fix memory leak, notification badge not showing
|
||||||
|
* Improve space join flow
|
||||||
|
* Fix opening images in fullscreen dialog
|
||||||
|
* Add support for blocked relays
|
||||||
|
* Add authentication policy setting
|
||||||
|
* Add login with key if no signer is detected
|
||||||
|
* Publish default relay selections on signup
|
||||||
|
|
||||||
|
# 1.6.2
|
||||||
|
|
||||||
|
* Fix modal scrolling and style
|
||||||
|
|
||||||
|
# 1.6.1
|
||||||
|
|
||||||
|
* Fix skinny profile images
|
||||||
|
* Custom handler for relay urls
|
||||||
|
* Improve time based chat partitioning
|
||||||
|
* Improve authenticated image access interop
|
||||||
|
* Fix image detail dialog
|
||||||
|
* Fix zapper loading
|
||||||
|
* Fix recent events missing in feeds
|
||||||
|
|
||||||
|
# 1.6.0
|
||||||
|
|
||||||
|
* Switch back to indexeddb to fix memory and performance
|
||||||
|
* Add pay invoice functionality
|
||||||
|
* Add space membership management and bans
|
||||||
|
* Add event info to profile dialog
|
||||||
|
* Add better room membership management
|
||||||
|
* Refactor stores for performance
|
||||||
|
* Hide nav when keyboard is open
|
||||||
|
* Handle flotilla links in-app
|
||||||
|
* Fix new messages indicator z-index
|
||||||
|
* Fix some display bugs
|
||||||
|
* Add date to chat items
|
||||||
|
* Refine data synchronization
|
||||||
|
* Hide nav when keyboard is open on mobile
|
||||||
|
|
||||||
|
# 1.5.3
|
||||||
|
|
||||||
|
* Add space edit form
|
||||||
|
* Improve room syncing
|
||||||
|
* Return better blossom errors
|
||||||
|
* Fix access restricted bugs
|
||||||
|
* Add room detail dialog
|
||||||
|
* Fix broken link to self hosting
|
||||||
|
* Tweak shadows
|
||||||
|
* Always join spaces when visiting them
|
||||||
|
|
||||||
|
# 1.5.2
|
||||||
|
|
||||||
|
* Fix negentropy room syncing
|
||||||
|
|
||||||
|
# 1.5.1
|
||||||
|
|
||||||
|
* Fix chat path link
|
||||||
|
|
||||||
|
# 1.5.0
|
||||||
|
|
||||||
|
* Restyle mobile dialogs
|
||||||
|
* Add room membership lists
|
||||||
|
* Add space membership lists
|
||||||
|
* Add edit room form
|
||||||
|
* Support closed/private/restricted/hidden rooms
|
||||||
|
* Add hosting services page
|
||||||
|
* Improve performance and UI
|
||||||
|
* Fix push notifications
|
||||||
|
* Improve error detection and handling
|
||||||
|
* Support invite links on discover page
|
||||||
|
* Add link to landlubber if user is admin
|
||||||
|
* Clear reply/share/edit on escape
|
||||||
|
|
||||||
|
# 1.4.1
|
||||||
|
|
||||||
|
* Improve data synchronization
|
||||||
|
* Fix app url on capacitor deployments
|
||||||
|
|
||||||
|
# 1.4.0
|
||||||
|
|
||||||
|
* Allow "editing" chat messages
|
||||||
|
* Check for room create permission
|
||||||
|
* Re-work space navigation
|
||||||
|
* Show all messages in non-nip29 chat
|
||||||
|
* Improve synchronization logic
|
||||||
|
* Add connection status to space menu
|
||||||
|
* Add icon picker to room create component
|
||||||
|
* Improve mention suggestions
|
||||||
|
* Improve storage adapter and relay list performance
|
||||||
|
* Fix modals
|
||||||
|
* Add room deletion
|
||||||
|
* Fix zapper loading
|
||||||
|
* Add support for relay/group member lists and join/leave events
|
||||||
|
|
||||||
|
# 1.3.1
|
||||||
|
|
||||||
|
* Fix memory leak in storage adapter
|
||||||
|
* Show fewer annoying toast messages
|
||||||
|
|
||||||
|
# 1.3.0
|
||||||
|
|
||||||
|
* Add optional badge and sound for notifications
|
||||||
|
* Improve link rendering
|
||||||
|
* Remove imgproxy
|
||||||
|
* Bring back blossom feature detection for spaces
|
||||||
|
* Improve light theme
|
||||||
|
* Add more info to signer status
|
||||||
|
* Simplify navigation for adding a space
|
||||||
|
* Add ability to scan QR code for invite links
|
||||||
|
* Streamline wallet setup and move receive address setting
|
||||||
|
* Remove indexeddb on mobile, use capacitor file storage API
|
||||||
|
* Fix duplicate DMs showing up
|
||||||
|
|
||||||
|
# 1.2.5
|
||||||
|
|
||||||
|
* Fix icons in build
|
||||||
|
|
||||||
|
# 1.2.4
|
||||||
|
|
||||||
|
* Add direct message alerts
|
||||||
|
* Add alert settings page
|
||||||
|
* Add instructions to key download
|
||||||
|
* Add option that allows relays to strip signatures
|
||||||
|
* Detect relays that mostly refuse to serve requests
|
||||||
|
* Compress and upload profile images
|
||||||
|
* Use system theme by default
|
||||||
|
* Switch icon set, refactor how they're included
|
||||||
|
* Use capacitor's preferences for storage instead of localStorage
|
||||||
|
|
||||||
|
# 1.2.3
|
||||||
|
|
||||||
|
* Add `created_at` to event info dialog
|
||||||
|
* Add signer status to profile page
|
||||||
|
* Re-work bunker login flow
|
||||||
|
* Add in-app onboarding flow
|
||||||
|
* Only protect events if relay authenticates
|
||||||
|
* Filter out non-global chats from global chat
|
||||||
|
* Improve publish status indicator
|
||||||
|
* Fix encrypted upload content type
|
||||||
|
* Add relays to event details dialog
|
||||||
|
* Add universal link handler for apps
|
||||||
|
|
||||||
|
# 1.2.2
|
||||||
|
|
||||||
|
* Fix phantom chat notifications
|
||||||
|
* Fix zaps on mobile
|
||||||
|
|
||||||
|
# 1.2.1
|
||||||
|
|
||||||
|
* Add zaps to chat, threads, and events
|
||||||
|
* Add funding goals
|
||||||
|
* Add NWC support
|
||||||
|
* Add wallet settings page
|
||||||
|
* Handle invalid bunker url
|
||||||
|
* Fix sidebar overflow
|
||||||
|
* Fix profile npub display
|
||||||
|
|
||||||
# 1.2.0
|
# 1.2.0
|
||||||
|
|
||||||
* Fix sort order of thread comments
|
* Fix sort order of thread comments
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
## Project Overview
|
|
||||||
|
|
||||||
Flotilla is a Discord-like Nostr client that operates on the concept of "relays as groups/spaces." Built with SvelteKit 2.5 and Svelte 5, it provides messaging, threads, calendar events, and social features across Nostr relays.
|
|
||||||
|
|
||||||
## Important Patterns
|
|
||||||
|
|
||||||
### Finding Code
|
|
||||||
- Prefer navigating from one file to the next following imports when possible
|
|
||||||
- If search is necessary, use `ack`, not `grep` or `rg`.
|
|
||||||
|
|
||||||
### Nostr Event Handling
|
|
||||||
- Prefer seconds to milliseconds when handling nostr events.
|
|
||||||
|
|
||||||
### Styling Conventions
|
|
||||||
- When styling html, prefer flex/gap classes over margin or space-y classes.
|
|
||||||
|
|
||||||
### Room/space memberships
|
|
||||||
|
|
||||||
Memberships are surfaced as "bookmarks" to the user.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {membershipsByPubkey, getMembershipUrls} from '@app/state'
|
|
||||||
|
|
||||||
const spaces = getMembershipUrls($membershipsByPubkey.get(pubkey))
|
|
||||||
const rooms = getMembershipRooms($membershipsByPubkey.get(pubkey))
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
|
||||||
|
|
||||||
|
Please visit our [issue tracker](https://gitea.coracle.social/coracle/flotilla/issues) to contribute. Any new issues should be opened without a milestone, label, or project and the project owners will triage them.
|
||||||
|
|
||||||
|
### Milestones
|
||||||
|
|
||||||
|
Milestones indicate how soon a given task should be tackled.
|
||||||
|
|
||||||
|
- [Current](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=1) issues are immediately actionable.
|
||||||
|
- [Next](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=2) means an issue is blocked.
|
||||||
|
- [Future](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=3) means we're deferring work until a later date.
|
||||||
|
|
||||||
|
### Labels
|
||||||
|
|
||||||
|
- [Design](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=15) issues need design work before being implemented. This might take the form of a high-quality mockup, wireframes, user flows, or just a couple notes about where things go, depending on the nature of the task.
|
||||||
|
- [Dev](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=16) issues are ready to be implemented. Most of the work will be related to architecting and writing code.
|
||||||
|
- [Easy](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=14) issues have no dependencies, and are scoped quite narrowly.
|
||||||
|
- [Priority](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=6) issues include bugs and urgent feature requests. These should get attention first if possible, although sometimes long-standing performance issues or subtle bugs might end up here for a while.
|
||||||
|
- [Ideas](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=13) are for things that aren't scoped out yet, or which need protocol work before getting designed or implemented.
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
Issues may or may not have a project. Projects are used to group issues thematically just for organization.
|
||||||
|
|
||||||
|
## Coding conventions
|
||||||
|
|
||||||
|
There are a few conventions that are helpful to know right out of the gate.
|
||||||
|
|
||||||
|
- Most nostr protocol functionality is implemented via the [welshman library](https://welshman.coracle.social/)
|
||||||
|
- Use Svelte 4 **stores** rather than runes for all state outside UI components
|
||||||
|
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||||
|
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||||
|
- Events are published via `publishThunk`, which allows for optimistic UI updates during signing/pow generation.
|
||||||
|
- Components should have minimal props - e.g. instead of passing a whole `relay` through, pass its `url`.
|
||||||
|
- Use `AbortController` when possible instead of request ids
|
||||||
|
- Use `undefined` or optional properties instead of `null`
|
||||||
|
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||||
|
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||||
|
- When dynamically building classes, use `cx` from `classnames`.
|
||||||
|
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates.
|
||||||
|
|
||||||
|
## Contributing Workflow
|
||||||
|
|
||||||
|
To contribute, do the following:
|
||||||
|
|
||||||
|
- Find or create an issue and assign yourself (comment instead if you're not able to self-assign)
|
||||||
|
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
|
||||||
|
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
|
||||||
|
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
|
||||||
|
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
|
||||||
|
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
|
||||||
|
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.
|
||||||
|
- PRs are rebased, squashed, and merged to keep commit history simple.
|
||||||
|
- An issue may have multiple PRs. Once complete, it can be closed.
|
||||||
@@ -1,24 +1,31 @@
|
|||||||
FROM node:20-slim
|
# Build and run the Flotilla web server.
|
||||||
|
#
|
||||||
|
# docker build -t flotilla .
|
||||||
|
# docker run -p 3000:3000 flotilla
|
||||||
|
#
|
||||||
|
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
|
||||||
|
# A .env in the build context is picked up by build.sh for branding config.
|
||||||
|
|
||||||
# Install pnpm
|
# https://pnpm.io/docker#example-3-build-on-cicd
|
||||||
RUN npm install -g pnpm@latest
|
FROM node:24-slim AS builder
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN pnpm i
|
|
||||||
|
|
||||||
# Copy the rest of the application
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm i --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_BUILD_HASH
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
RUN pnpm run build:server
|
||||||
|
|
||||||
# Default to serving the build directory
|
FROM node:24-slim AS production
|
||||||
CMD ["npx", "serve", "build"]
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/build /app/build
|
||||||
|
COPY --from=builder /app/build-server/server.js /app/server.js
|
||||||
|
EXPOSE 3000
|
||||||
|
USER node
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -6,23 +6,23 @@ If you would like to be interoperable with Flotilla, please check out this guide
|
|||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
|
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples):
|
||||||
|
|
||||||
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust.
|
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
|
||||||
- `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
|
- `VITE_PLATFORM_URL` - The url where the app will be hosted
|
||||||
- `VITE_PLATFORM_NAME` - The name of the app
|
- `VITE_PLATFORM_NAME` - The name of the app
|
||||||
- `VITE_PLATFORM_LOGO` - A logo url for the app
|
- `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file.
|
||||||
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
||||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||||
- `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting)
|
|
||||||
- `GLITCHTIP_AUTH_TOKEN` - A glitchtip auth token for error reporting
|
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
|
||||||
|
|
||||||
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, a pre-commit hook will run to lint and typecheck your work.
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -31,18 +31,18 @@ To run your own Flotilla, it's as simple as:
|
|||||||
```sh
|
```sh
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run build
|
pnpm run build
|
||||||
npx serve build
|
pnpm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
Or, if you prefer to use a container:
|
Or, if you prefer to use a container:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
|
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir ./mount
|
mkdir ./mount
|
||||||
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
|
docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "social.flotilla"
|
namespace = "social.flotilla"
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "social.flotilla"
|
applicationId "social.flotilla"
|
||||||
minSdk rootProject.ext.minSdkVersion
|
minSdk rootProject.ext.minSdkVersion
|
||||||
targetSdk rootProject.ext.targetSdkVersion
|
targetSdk rootProject.ext.targetSdkVersion
|
||||||
versionCode 21
|
versionCode 47
|
||||||
versionName "1.2.0"
|
versionName "1.8.0"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -35,6 +36,10 @@ dependencies {
|
|||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation "androidx.work:work-runtime:2.10.3"
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||||
|
implementation "fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':aparajita-capacitor-secure-storage')
|
||||||
implementation project(':capacitor-community-safe-area')
|
implementation project(':capacitor-community-safe-area')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-clipboard')
|
||||||
|
implementation project(':capacitor-filesystem')
|
||||||
implementation project(':capacitor-keyboard')
|
implementation project(':capacitor-keyboard')
|
||||||
|
implementation project(':capacitor-preferences')
|
||||||
implementation project(':capacitor-push-notifications')
|
implementation project(':capacitor-push-notifications')
|
||||||
|
implementation project(':capacitor-share')
|
||||||
|
implementation project(':capawesome-capacitor-android-dark-mode-support')
|
||||||
implementation project(':capawesome-capacitor-badge')
|
implementation project(':capawesome-capacitor-badge')
|
||||||
implementation project(':nostr-signer-capacitor-plugin')
|
implementation project(':nostr-signer-capacitor-plugin')
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:label="@string/title_activity_main"
|
android:label="@string/title_activity_main"
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
@@ -20,6 +20,13 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="app.flotilla.social" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
@@ -35,4 +42,9 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
package social.flotilla;
|
package social.flotilla;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {}
|
import social.flotilla.notifications.AndroidPushFallbackPlugin;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
registerPlugin(AndroidPushFallbackPlugin.class);
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package social.flotilla.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
|
import androidx.work.OutOfQuotaPolicy
|
||||||
|
import androidx.work.PeriodicWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
@CapacitorPlugin(name = "AndroidPushFallback")
|
||||||
|
class AndroidPushFallbackPlugin : Plugin() {
|
||||||
|
companion object {
|
||||||
|
const val PREFS_NAME = "CapacitorStorage"
|
||||||
|
const val KEY_STATE = "androidPushFallback.state"
|
||||||
|
const val UNIQUE_PERIODIC_WORK = "androidPushFallback.periodic"
|
||||||
|
const val UNIQUE_IMMEDIATE_WORK = "androidPushFallback.immediate"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPrefs(): SharedPreferences {
|
||||||
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun syncState(call: PluginCall) {
|
||||||
|
val state: JSObject? = call.getObject("state")
|
||||||
|
|
||||||
|
if (state != null) {
|
||||||
|
getPrefs().edit().putString(KEY_STATE, state.toString()).apply()
|
||||||
|
|
||||||
|
if (isEnabled(state.toString())) {
|
||||||
|
scheduleWork()
|
||||||
|
} else {
|
||||||
|
cancelWork()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEnabled(rawState: String?): Boolean {
|
||||||
|
if (rawState == null || rawState.isEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val state = JSONObject(rawState)
|
||||||
|
val subscriptions: JSONArray? = state.optJSONArray("subscriptions")
|
||||||
|
subscriptions != null && subscriptions.length() > 0
|
||||||
|
} catch (_: JSONException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleWork() {
|
||||||
|
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
|
||||||
|
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
val periodic = PeriodicWorkRequest.Builder(
|
||||||
|
AndroidPushFallbackWorker::class.java,
|
||||||
|
15,
|
||||||
|
TimeUnit.MINUTES,
|
||||||
|
).setConstraints(constraints).build()
|
||||||
|
|
||||||
|
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
UNIQUE_PERIODIC_WORK,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
periodic,
|
||||||
|
)
|
||||||
|
|
||||||
|
workManager.enqueueUniqueWork(
|
||||||
|
UNIQUE_IMMEDIATE_WORK,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
immediate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelWork() {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
workManager.cancelUniqueWork(UNIQUE_IMMEDIATE_WORK)
|
||||||
|
workManager.cancelUniqueWork(UNIQUE_PERIODIC_WORK)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,861 @@
|
|||||||
|
package social.flotilla.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.util.Log
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import fr.acinq.secp256k1.Secp256k1
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.KEY_STATE
|
||||||
|
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.PREFS_NAME
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.Arrays
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import android.util.Base64
|
||||||
|
|
||||||
|
class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PushFallback"
|
||||||
|
private const val CHANNEL_ID = "flotilla_fallback"
|
||||||
|
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
|
||||||
|
private const val SOCKET_TIMEOUT_SECONDS = 30L
|
||||||
|
private const val REJECTED = "__REJECTED__"
|
||||||
|
private const val KIND_RELAY_AUTH = 22242
|
||||||
|
private const val KIND_NIP46_RPC = 24133
|
||||||
|
private val SECP = Secp256k1.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val client: OkHttpClient = OkHttpClient.Builder().build()
|
||||||
|
|
||||||
|
// ---- Socket pool ----
|
||||||
|
|
||||||
|
// Opens each relay URL at most once; caller must invoke closeAll() when done.
|
||||||
|
private inner class SocketPool {
|
||||||
|
private val sockets = ConcurrentHashMap<String, WebSocket>()
|
||||||
|
|
||||||
|
fun open(url: String, listener: WebSocketListener): WebSocket =
|
||||||
|
sockets.getOrPut(url) {
|
||||||
|
client.newWebSocket(Request.Builder().url(url).build(), listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeAll() {
|
||||||
|
for ((_, ws) in sockets) ws.close(1000, "done")
|
||||||
|
sockets.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doWork(): Result {
|
||||||
|
Log.i(TAG, "doWork() started")
|
||||||
|
|
||||||
|
if (isAppInForeground()) {
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
val pool = SocketPool()
|
||||||
|
try {
|
||||||
|
val rawState = prefs.getString(KEY_STATE, "") ?: ""
|
||||||
|
if (rawState.isEmpty()) return Result.success()
|
||||||
|
|
||||||
|
val state = JSONObject(rawState)
|
||||||
|
val sessionInfo = getSessionInfo(state)
|
||||||
|
val subscriptions = parseSubscriptions(state)
|
||||||
|
if (subscriptions.isEmpty()) return Result.success()
|
||||||
|
|
||||||
|
val activeSince = state.optLong("activeSince", 0L)
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
val newEvents = mutableListOf<Pair<String, JSONObject>>()
|
||||||
|
|
||||||
|
for (sub in subscriptions) {
|
||||||
|
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
||||||
|
val since = maxOf(prefs.getLong(cursorKey, 0L), activeSince)
|
||||||
|
val result = pollRelay(sub, since, sessionInfo, pool)
|
||||||
|
|
||||||
|
if (result.lastCursor > prefs.getLong(cursorKey, 0L)) {
|
||||||
|
prefs.edit().putLong(cursorKey, result.lastCursor).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (event in result.events) {
|
||||||
|
val id = event.optString("id", "")
|
||||||
|
if (id.isNotEmpty() && seen.add(id)) {
|
||||||
|
newEvents.add(Pair(sub.relay, event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((relay, event) in newEvents) {
|
||||||
|
postNotification(relay, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Worker failed", e)
|
||||||
|
return Result.retry()
|
||||||
|
} finally {
|
||||||
|
pool.closeAll()
|
||||||
|
client.dispatcher.executorService.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAppInForeground(): Boolean {
|
||||||
|
val am = applicationContext.getSystemService(ActivityManager::class.java) ?: return false
|
||||||
|
val tasks = am.getRunningAppProcesses() ?: return false
|
||||||
|
val pkg = applicationContext.packageName
|
||||||
|
return tasks.any { it.processName == pkg && it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSessionInfo(state: JSONObject): SessionInfo {
|
||||||
|
val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject())
|
||||||
|
return SessionInfo(
|
||||||
|
session.optString("method", "anonymous"),
|
||||||
|
session.optString("pubkey", ""),
|
||||||
|
session,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubscriptions(state: JSONObject): List<Subscription> {
|
||||||
|
val result = mutableListOf<Subscription>()
|
||||||
|
val arr = state.optJSONArray("subscriptions") ?: return result
|
||||||
|
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val item = arr.optJSONObject(i) ?: continue
|
||||||
|
val relay = item.optString("relay", "").trim()
|
||||||
|
|
||||||
|
if (!relay.startsWith("wss://") && !relay.startsWith("ws://")) continue
|
||||||
|
|
||||||
|
val filters = item.optJSONArray("filters")
|
||||||
|
if (filters == null || filters.length() == 0) continue
|
||||||
|
|
||||||
|
val key = item.optString("key", "").trim()
|
||||||
|
result.add(Subscription(relay, key, filters, item.optJSONArray("ignore")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pollRelay(sub: Subscription, since: Long, sessionInfo: SessionInfo, pool: SocketPool): RelayResult {
|
||||||
|
val result = RelayResult()
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
|
||||||
|
val listener = RelayListener(sub, since, sessionInfo, result, latch, pool)
|
||||||
|
pool.open(sub.relay, listener)
|
||||||
|
|
||||||
|
if (!latch.await(SOCKET_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||||
|
Log.d(TAG, "Relay ${sub.relay} timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postNotification(relay: String, event: JSONObject) {
|
||||||
|
val context = applicationContext
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) return
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
|
if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
val channel = NotificationChannel(CHANNEL_ID, "Fallback Notifications", NotificationManager.IMPORTANCE_DEFAULT)
|
||||||
|
channel.description = "Notifications delivered by Android background fallback"
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val id = event.optString("id", "")
|
||||||
|
val encodedRelay = Uri.encode(relay)
|
||||||
|
val url = "https://app.flotilla.social/?relay=$encodedRelay&id=$id"
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
intent.setPackage(context.packageName)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val body = "New activity"
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||||
|
.setContentTitle("Flotilla")
|
||||||
|
.setContentText(body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
|
||||||
|
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
||||||
|
val kinds = filter.optJSONArray("kinds")
|
||||||
|
if (kinds != null && kinds.length() > 0) {
|
||||||
|
val kind = event.optInt("kind", -1)
|
||||||
|
var found = false
|
||||||
|
for (i in 0 until kinds.length()) {
|
||||||
|
if (kinds.optInt(i, -1) == kind) { found = true; break }
|
||||||
|
}
|
||||||
|
if (!found) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val tags = event.optJSONArray("tags")
|
||||||
|
val iter = filter.keys()
|
||||||
|
while (iter.hasNext()) {
|
||||||
|
val key = iter.next()
|
||||||
|
if (!key.startsWith("#")) continue
|
||||||
|
val tagName = key.substring(1)
|
||||||
|
val allowed = filter.optJSONArray(key) ?: continue
|
||||||
|
if (allowed.length() == 0) continue
|
||||||
|
|
||||||
|
val allowedValues = mutableSetOf<String>()
|
||||||
|
for (i in 0 until allowed.length()) {
|
||||||
|
val v = allowed.optString(i, "")
|
||||||
|
if (v.isNotEmpty()) allowedValues.add(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched = false
|
||||||
|
if (tags != null) {
|
||||||
|
for (i in 0 until tags.length()) {
|
||||||
|
val tag = tags.optJSONArray(i) ?: continue
|
||||||
|
if (tag.optString(0, "") == tagName && allowedValues.contains(tag.optString(1, ""))) {
|
||||||
|
matched = true; break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Crypto helpers ----
|
||||||
|
|
||||||
|
private fun computeEventId(event: JSONObject): String {
|
||||||
|
return try {
|
||||||
|
val serialized = JSONArray()
|
||||||
|
serialized.put(0)
|
||||||
|
serialized.put(event.optString("pubkey", ""))
|
||||||
|
serialized.put(event.optLong("created_at", 0))
|
||||||
|
serialized.put(event.optInt("kind", 0))
|
||||||
|
serialized.put(event.optJSONArray("tags") ?: JSONArray())
|
||||||
|
serialized.put(event.optString("content", ""))
|
||||||
|
// JSONObject escapes forward slashes (/ -> \/), but NIP-01 event ID hashing
|
||||||
|
// requires unescaped slashes. Replace them before hashing.
|
||||||
|
val serializedStr = serialized.toString().replace("\\/", "/")
|
||||||
|
bytesToHex(sha256(serializedStr.toByteArray(StandardCharsets.UTF_8)))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deriveXOnlyPubkey(secretHex: String): String {
|
||||||
|
val secret = hexToBytes(secretHex)
|
||||||
|
if (secret.size != 32 || !SECP.secKeyVerify(secret)) return ""
|
||||||
|
val pubkey65 = try { SECP.pubkeyCreate(secret) } catch (_: Exception) { return "" }
|
||||||
|
if (pubkey65.size != 65) return ""
|
||||||
|
return bytesToHex(Arrays.copyOfRange(pubkey65, 1, 33))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schnorrSign(secretHex: String, messageHex: String): String {
|
||||||
|
val sk = hexToBytes(secretHex)
|
||||||
|
val msg = hexToBytes(messageHex)
|
||||||
|
if (sk.size != 32 || msg.size != 32 || !SECP.secKeyVerify(sk)) return ""
|
||||||
|
val aux = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||||
|
val sig = try { SECP.signSchnorr(msg, sk, aux) } catch (_: Exception) { return "" }
|
||||||
|
if (sig.size != 64) return ""
|
||||||
|
return bytesToHex(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256(input: ByteArray): ByteArray =
|
||||||
|
try { MessageDigest.getInstance("SHA-256").digest(input) } catch (_: Exception) { ByteArray(32) }
|
||||||
|
|
||||||
|
private fun hexToBytes(hex: String?): ByteArray {
|
||||||
|
var s = hex?.trim()?.lowercase() ?: ""
|
||||||
|
if (s.startsWith("0x")) s = s.substring(2)
|
||||||
|
if (s.length % 2 == 1) s = "0$s"
|
||||||
|
val bytes = ByteArray(s.length / 2)
|
||||||
|
var i = 0
|
||||||
|
while (i < s.length) {
|
||||||
|
val hi = Character.digit(s[i], 16)
|
||||||
|
val lo = Character.digit(s[i + 1], 16)
|
||||||
|
if (hi < 0 || lo < 0) return ByteArray(0)
|
||||||
|
bytes[i / 2] = ((hi shl 4) + lo).toByte()
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bytesToHex(bytes: ByteArray): String {
|
||||||
|
val hex = "0123456789abcdef".toCharArray()
|
||||||
|
val chars = CharArray(bytes.size * 2)
|
||||||
|
for (i in bytes.indices) {
|
||||||
|
val v = bytes[i].toInt() and 0xFF
|
||||||
|
chars[i * 2] = hex[v ushr 4]
|
||||||
|
chars[i * 2 + 1] = hex[v and 0x0F]
|
||||||
|
}
|
||||||
|
return String(chars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NIP-44 encryption (v2: ECDH + HKDF + ChaCha20 + HMAC-SHA256) ----
|
||||||
|
|
||||||
|
private fun nip44ConversationKey(clientSecret: String, theirPubkey: String): ByteArray {
|
||||||
|
val sk = hexToBytes(clientSecret)
|
||||||
|
val pk = hexToBytes("02$theirPubkey")
|
||||||
|
if (sk.size != 32 || pk.size != 33) return ByteArray(0)
|
||||||
|
val shared = try { SECP.pubKeyTweakMul(pk, sk) } catch (_: Exception) { return ByteArray(0) }
|
||||||
|
if (shared.size != 65) return ByteArray(0)
|
||||||
|
val sharedX = Arrays.copyOfRange(shared, 1, 33)
|
||||||
|
return hkdfExtract(sharedX, "nip44-v2".toByteArray(StandardCharsets.UTF_8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hkdfExtract(ikm: ByteArray, salt: ByteArray): ByteArray {
|
||||||
|
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(javax.crypto.spec.SecretKeySpec(salt, "HmacSHA256"))
|
||||||
|
return mac.doFinal(ikm)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
|
||||||
|
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||||
|
val result = ByteArray(length)
|
||||||
|
var prev = ByteArray(0)
|
||||||
|
var offset = 0
|
||||||
|
var counter = 1
|
||||||
|
while (offset < length) {
|
||||||
|
mac.init(javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"))
|
||||||
|
mac.update(prev)
|
||||||
|
mac.update(info)
|
||||||
|
mac.update(counter.toByte())
|
||||||
|
prev = mac.doFinal()
|
||||||
|
val toCopy = minOf(prev.size, length - offset)
|
||||||
|
System.arraycopy(prev, 0, result, offset, toCopy)
|
||||||
|
offset += toCopy
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hmacSha256(key: ByteArray, vararg parts: ByteArray): ByteArray {
|
||||||
|
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA256"))
|
||||||
|
for (part in parts) mac.update(part)
|
||||||
|
return mac.doFinal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChaCha20 block function per RFC 8439
|
||||||
|
private fun chacha20Block(key: ByteArray, counter: Int, nonce: ByteArray): ByteArray {
|
||||||
|
fun Int.rotl(n: Int) = (this shl n) or (this ushr (32 - n))
|
||||||
|
val state = IntArray(16)
|
||||||
|
state[0] = 0x61707865; state[1] = 0x3320646e; state[2] = 0x79622d32; state[3] = 0x6b206574
|
||||||
|
for (i in 0..7) state[4 + i] = (key[i*4].toInt() and 0xFF) or
|
||||||
|
((key[i*4+1].toInt() and 0xFF) shl 8) or
|
||||||
|
((key[i*4+2].toInt() and 0xFF) shl 16) or
|
||||||
|
((key[i*4+3].toInt() and 0xFF) shl 24)
|
||||||
|
state[12] = counter
|
||||||
|
for (i in 0..2) state[13 + i] = (nonce[i*4].toInt() and 0xFF) or
|
||||||
|
((nonce[i*4+1].toInt() and 0xFF) shl 8) or
|
||||||
|
((nonce[i*4+2].toInt() and 0xFF) shl 16) or
|
||||||
|
((nonce[i*4+3].toInt() and 0xFF) shl 24)
|
||||||
|
val working = state.copyOf()
|
||||||
|
repeat(10) {
|
||||||
|
fun quarterRound(a: Int, b: Int, c: Int, d: Int) {
|
||||||
|
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(16)
|
||||||
|
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(12)
|
||||||
|
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(8)
|
||||||
|
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(7)
|
||||||
|
}
|
||||||
|
quarterRound(0,4,8,12); quarterRound(1,5,9,13); quarterRound(2,6,10,14); quarterRound(3,7,11,15)
|
||||||
|
quarterRound(0,5,10,15); quarterRound(1,6,11,12); quarterRound(2,7,8,13); quarterRound(3,4,9,14)
|
||||||
|
}
|
||||||
|
val out = ByteArray(64)
|
||||||
|
for (i in 0..15) {
|
||||||
|
val v = working[i] + state[i]
|
||||||
|
out[i*4] = v.toByte()
|
||||||
|
out[i*4+1] = (v ushr 8).toByte()
|
||||||
|
out[i*4+2] = (v ushr 16).toByte()
|
||||||
|
out[i*4+3] = (v ushr 24).toByte()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chacha20(key: ByteArray, nonce: ByteArray, data: ByteArray): ByteArray {
|
||||||
|
val out = ByteArray(data.size)
|
||||||
|
var counter = 0
|
||||||
|
var offset = 0
|
||||||
|
while (offset < data.size) {
|
||||||
|
val block = chacha20Block(key, counter, nonce)
|
||||||
|
val len = minOf(64, data.size - offset)
|
||||||
|
for (i in 0 until len) out[offset + i] = (data[offset + i].toInt() xor block[i].toInt()).toByte()
|
||||||
|
offset += len
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nip44CalcPaddedLen(len: Int): Int {
|
||||||
|
if (len <= 32) return 32
|
||||||
|
val nextPower = 1 shl (Math.floor(Math.log((len - 1).toDouble()) / Math.log(2.0)).toInt() + 1)
|
||||||
|
val chunk = if (nextPower <= 256) 32 else nextPower / 8
|
||||||
|
return chunk * ((len - 1) / chunk + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nip44Pad(plaintext: String): ByteArray {
|
||||||
|
val unpadded = plaintext.toByteArray(StandardCharsets.UTF_8)
|
||||||
|
val len = unpadded.size
|
||||||
|
val padded = ByteArray(2 + nip44CalcPaddedLen(len))
|
||||||
|
padded[0] = (len ushr 8).toByte()
|
||||||
|
padded[1] = len.toByte()
|
||||||
|
System.arraycopy(unpadded, 0, padded, 2, len)
|
||||||
|
return padded
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nip44Unpad(padded: ByteArray): String {
|
||||||
|
val len = ((padded[0].toInt() and 0xFF) shl 8) or (padded[1].toInt() and 0xFF)
|
||||||
|
if (len == 0 || len > padded.size - 2) return ""
|
||||||
|
return String(padded, 2, len, StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encryptNip44(plaintext: String, conversationKey: ByteArray): String {
|
||||||
|
return try {
|
||||||
|
val nonce = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||||
|
val keys = hkdfExpand(conversationKey, nonce, 76)
|
||||||
|
val chachaKey = keys.sliceArray(0 until 32)
|
||||||
|
val chachaNonce = keys.sliceArray(32 until 44)
|
||||||
|
val hmacKey = keys.sliceArray(44 until 76)
|
||||||
|
val padded = nip44Pad(plaintext)
|
||||||
|
val ciphertext = chacha20(chachaKey, chachaNonce, padded)
|
||||||
|
val mac = hmacSha256(hmacKey, nonce, ciphertext)
|
||||||
|
val payload = ByteArray(1 + 32 + ciphertext.size + 32)
|
||||||
|
payload[0] = 2
|
||||||
|
System.arraycopy(nonce, 0, payload, 1, 32)
|
||||||
|
System.arraycopy(ciphertext, 0, payload, 33, ciphertext.size)
|
||||||
|
System.arraycopy(mac, 0, payload, 33 + ciphertext.size, 32)
|
||||||
|
Base64.encodeToString(payload, Base64.NO_WRAP)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptNip44(payload: String, conversationKey: ByteArray): String {
|
||||||
|
return try {
|
||||||
|
if (payload.isEmpty() || payload[0] == '#') return ""
|
||||||
|
val data = Base64.decode(payload, Base64.NO_WRAP)
|
||||||
|
if (data.size < 99 || data[0] != 2.toByte()) return ""
|
||||||
|
val nonce = data.sliceArray(1 until 33)
|
||||||
|
val ciphertext = data.sliceArray(33 until data.size - 32)
|
||||||
|
val mac = data.sliceArray(data.size - 32 until data.size)
|
||||||
|
val keys = hkdfExpand(conversationKey, nonce, 76)
|
||||||
|
val chachaKey = keys.sliceArray(0 until 32)
|
||||||
|
val chachaNonce = keys.sliceArray(32 until 44)
|
||||||
|
val hmacKey = keys.sliceArray(44 until 76)
|
||||||
|
val expectedMac = hmacSha256(hmacKey, nonce, ciphertext)
|
||||||
|
if (!expectedMac.contentEquals(mac)) return ""
|
||||||
|
val padded = chacha20(chachaKey, chachaNonce, ciphertext)
|
||||||
|
nip44Unpad(padded)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Signing ----
|
||||||
|
|
||||||
|
private fun signWithNip01Secret(secretHex: String, eventJson: String, expectedPubkey: String): String {
|
||||||
|
return try {
|
||||||
|
val secret = hexToBytes(secretHex)
|
||||||
|
if (secret.size != 32) return ""
|
||||||
|
|
||||||
|
val event = JSONObject(eventJson)
|
||||||
|
var pubkey = event.optString("pubkey", expectedPubkey)
|
||||||
|
if (pubkey.isEmpty()) pubkey = deriveXOnlyPubkey(secretHex)
|
||||||
|
if (pubkey.isEmpty()) return ""
|
||||||
|
|
||||||
|
event.put("pubkey", pubkey)
|
||||||
|
val id = computeEventId(event)
|
||||||
|
if (id.isEmpty()) return ""
|
||||||
|
|
||||||
|
val sig = schnorrSign(secretHex, id)
|
||||||
|
if (sig.isEmpty()) return ""
|
||||||
|
|
||||||
|
event.put("id", id)
|
||||||
|
event.put("sig", sig)
|
||||||
|
event.toString()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signWithNip55ContentResolver(packageName: String, eventJson: String, pubkey: String): String {
|
||||||
|
val uri = Uri.parse("content://$packageName.SIGN_EVENT")
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
return try {
|
||||||
|
cursor = applicationContext.contentResolver.query(uri, arrayOf(eventJson, "", pubkey), "1", null, null)
|
||||||
|
if (cursor == null || !cursor.moveToFirst()) return ""
|
||||||
|
val rejIdx = cursor.getColumnIndex("rejected")
|
||||||
|
if (rejIdx >= 0) {
|
||||||
|
val v = cursor.getString(rejIdx)
|
||||||
|
if (v == "1" || v.equals("true", ignoreCase = true)) return REJECTED
|
||||||
|
}
|
||||||
|
val eventIdx = cursor.getColumnIndex("event")
|
||||||
|
if (eventIdx >= 0) cursor.getString(eventIdx) ?: "" else ""
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
} finally {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Data types ----
|
||||||
|
|
||||||
|
private data class SessionInfo(
|
||||||
|
val method: String,
|
||||||
|
val pubkey: String,
|
||||||
|
val session: JSONObject,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class Subscription(
|
||||||
|
val relay: String,
|
||||||
|
val key: String,
|
||||||
|
val filters: JSONArray,
|
||||||
|
val ignore: JSONArray?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class RelayResult {
|
||||||
|
val events = mutableListOf<JSONObject>()
|
||||||
|
var lastCursor = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Relay listener ----
|
||||||
|
|
||||||
|
private inner class RelayListener(
|
||||||
|
private val sub: Subscription,
|
||||||
|
private val since: Long,
|
||||||
|
private val sessionInfo: SessionInfo,
|
||||||
|
private val result: RelayResult,
|
||||||
|
private val latch: CountDownLatch,
|
||||||
|
private val pool: SocketPool,
|
||||||
|
) : WebSocketListener() {
|
||||||
|
private val subId = UUID.randomUUID().toString().replace("-", "")
|
||||||
|
private var done = false
|
||||||
|
private var authed = false
|
||||||
|
private var authEventId = ""
|
||||||
|
private var nip46InFlight = false
|
||||||
|
private var pendingDone = false
|
||||||
|
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
sendReq(webSocket)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendReq(webSocket: WebSocket) {
|
||||||
|
val req = JSONArray()
|
||||||
|
req.put("REQ")
|
||||||
|
req.put(subId)
|
||||||
|
|
||||||
|
for (i in 0 until sub.filters.length()) {
|
||||||
|
val filter = sub.filters.optJSONObject(i) ?: continue
|
||||||
|
val shaped = JSONObject(filter.toString())
|
||||||
|
if (since > 0) shaped.put("since", since + 1)
|
||||||
|
shaped.put("limit", 1)
|
||||||
|
req.put(shaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.length() <= 2) { finish(); return }
|
||||||
|
|
||||||
|
send(webSocket, req.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
try {
|
||||||
|
val message = JSONArray(text)
|
||||||
|
Log.d(TAG, "Received message from ${sub.relay}: $text")
|
||||||
|
when (message.optString(0, "")) {
|
||||||
|
"EVENT" -> {
|
||||||
|
val event = message.optJSONObject(2) ?: return
|
||||||
|
if (!matchesAnyFilter(sub.filters, event)) return
|
||||||
|
if (isIgnored(event)) return
|
||||||
|
result.events.add(event)
|
||||||
|
val createdAt = event.optLong("created_at", 0L)
|
||||||
|
if (createdAt > result.lastCursor) result.lastCursor = createdAt
|
||||||
|
}
|
||||||
|
"AUTH" -> {
|
||||||
|
// Only auth once per connection
|
||||||
|
if (!authed) {
|
||||||
|
authed = true
|
||||||
|
tryAuth(webSocket, message.optString(1, ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"OK" -> {
|
||||||
|
val okId = message.optString(1, "")
|
||||||
|
val accepted = message.optBoolean(2, false)
|
||||||
|
if (accepted && okId == authEventId) sendReq(webSocket)
|
||||||
|
}
|
||||||
|
"EOSE" -> {
|
||||||
|
send(webSocket, JSONArray().apply { put("CLOSE"); put(subId) }.toString())
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
|
||||||
|
|
||||||
|
private fun finish() {
|
||||||
|
if (done) return
|
||||||
|
if (nip46InFlight) { pendingDone = true; return }
|
||||||
|
done = true
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isIgnored(event: JSONObject): Boolean {
|
||||||
|
val ignore = sub.ignore ?: return false
|
||||||
|
for (i in 0 until ignore.length()) {
|
||||||
|
val filter = ignore.optJSONObject(i) ?: continue
|
||||||
|
if (matchesFilter(filter, event)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesAnyFilter(filters: JSONArray, event: JSONObject): Boolean {
|
||||||
|
for (i in 0 until filters.length()) {
|
||||||
|
val filter = filters.optJSONObject(i) ?: continue
|
||||||
|
if (matchesFilter(filter, event)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NIP-42 auth ----
|
||||||
|
|
||||||
|
private fun tryAuth(webSocket: WebSocket, challenge: String) {
|
||||||
|
if (challenge.isEmpty()) return
|
||||||
|
when (sessionInfo.method) {
|
||||||
|
"nip01" -> tryNip01Auth(webSocket, challenge)
|
||||||
|
"nip55" -> tryNip55Auth(webSocket, challenge)
|
||||||
|
"nip46" -> tryNip46Auth(webSocket, challenge)
|
||||||
|
// Pomade background auth is not supported: properly delegating to the Pomade signer
|
||||||
|
// from a background worker is complex, usage is rare, and relays that require auth
|
||||||
|
// may still be readable without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildAuthEvent(challenge: String): JSONObject {
|
||||||
|
return JSONObject().apply {
|
||||||
|
put("kind", KIND_RELAY_AUTH)
|
||||||
|
put("pubkey", sessionInfo.pubkey)
|
||||||
|
put("created_at", System.currentTimeMillis() / 1000L)
|
||||||
|
put("content", "")
|
||||||
|
put("id", "")
|
||||||
|
put("sig", "")
|
||||||
|
put("tags", JSONArray().apply {
|
||||||
|
put(JSONArray().apply { put("relay"); put(sub.relay) })
|
||||||
|
put(JSONArray().apply { put("challenge"); put(challenge) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendAuthMessage(webSocket: WebSocket, signedEventJson: String): Boolean {
|
||||||
|
if (signedEventJson.isEmpty() || signedEventJson == REJECTED) return false
|
||||||
|
return try {
|
||||||
|
val event = JSONObject(signedEventJson)
|
||||||
|
authEventId = event.optString("id", "")
|
||||||
|
send(webSocket, JSONArray().apply { put("AUTH"); put(event) }.toString())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send(webSocket: WebSocket, message: String): Boolean {
|
||||||
|
Log.d(TAG, "Sending message to ${webSocket.request().url}: $message")
|
||||||
|
return webSocket.send(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||||
|
val secret = sessionInfo.session.optString("secret", "")
|
||||||
|
if (secret.isEmpty()) return false
|
||||||
|
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||||
|
return sendAuthMessage(webSocket, signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||||
|
val signerPackage = sessionInfo.session.optString("signer", "")
|
||||||
|
if (signerPackage.isEmpty()) return false
|
||||||
|
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||||
|
return sendAuthMessage(webSocket, signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||||
|
val handler = sessionInfo.session.optJSONObject("handler") ?: return false
|
||||||
|
val clientSecret = sessionInfo.session.optString("secret", "")
|
||||||
|
val signerPubkey = handler.optString("pubkey", "")
|
||||||
|
val relays = handler.optJSONArray("relays")
|
||||||
|
|
||||||
|
if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false
|
||||||
|
|
||||||
|
val clientPubkey = deriveXOnlyPubkey(clientSecret)
|
||||||
|
if (clientPubkey.isEmpty()) return false
|
||||||
|
|
||||||
|
val authEventJson = buildAuthEvent(challenge).toString()
|
||||||
|
|
||||||
|
nip46InFlight = true
|
||||||
|
var success = false
|
||||||
|
try {
|
||||||
|
for (i in 0 until relays.length()) {
|
||||||
|
val signerRelay = relays.optString(i, "").trim()
|
||||||
|
if (!signerRelay.startsWith("wss://") && !signerRelay.startsWith("ws://")) continue
|
||||||
|
if (tryNip46ViaRelay(webSocket, signerRelay, clientSecret, clientPubkey, signerPubkey, authEventJson)) { success = true; break }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
nip46InFlight = false
|
||||||
|
if (pendingDone) finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryNip46ViaRelay(
|
||||||
|
relaySocket: WebSocket,
|
||||||
|
signerRelay: String,
|
||||||
|
clientSecret: String,
|
||||||
|
clientPubkey: String,
|
||||||
|
signerPubkey: String,
|
||||||
|
authEventJson: String,
|
||||||
|
): Boolean {
|
||||||
|
val localLatch = CountDownLatch(1)
|
||||||
|
val signedEvent = StringBuilder()
|
||||||
|
val requestId = UUID.randomUUID().toString().replace("-", "")
|
||||||
|
|
||||||
|
val signerSocket = pool.open(signerRelay, object : WebSocketListener() {
|
||||||
|
private var done = false
|
||||||
|
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
try {
|
||||||
|
val rpcEnvelope = JSONObject().apply {
|
||||||
|
put("kind", KIND_NIP46_RPC)
|
||||||
|
put("pubkey", clientPubkey)
|
||||||
|
put("created_at", System.currentTimeMillis() / 1000L)
|
||||||
|
put("content", encryptNip44(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", requestId)
|
||||||
|
put("method", "sign_event")
|
||||||
|
put("params", JSONArray().apply { put(authEventJson) })
|
||||||
|
}.toString(),
|
||||||
|
nip44ConversationKey(clientSecret, signerPubkey),
|
||||||
|
))
|
||||||
|
put("id", "")
|
||||||
|
put("sig", "")
|
||||||
|
put("tags", JSONArray().apply { put(JSONArray().apply { put("p"); put(signerPubkey) }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
val signedEnvelope = signWithNip01Secret(clientSecret, rpcEnvelope.toString(), clientPubkey)
|
||||||
|
if (signedEnvelope.isEmpty()) { finish(); return }
|
||||||
|
|
||||||
|
val sentAt = System.currentTimeMillis() / 1000L
|
||||||
|
send(webSocket, JSONArray().apply { put("EVENT"); put(JSONObject(signedEnvelope)) }.toString())
|
||||||
|
send(webSocket, JSONArray().apply {
|
||||||
|
put("REQ")
|
||||||
|
put(requestId)
|
||||||
|
put(JSONObject().apply {
|
||||||
|
put("#p", JSONArray().apply { put(clientPubkey) })
|
||||||
|
put("kinds", JSONArray().apply { put(KIND_NIP46_RPC) })
|
||||||
|
put("since", sentAt)
|
||||||
|
put("limit", 10)
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
try {
|
||||||
|
val message = JSONArray(text)
|
||||||
|
val msgType = message.optString(0, "")
|
||||||
|
Log.d(TAG, "NIP-46 signer message: $msgType from ${webSocket.request().url}")
|
||||||
|
if (msgType != "EVENT") return
|
||||||
|
val event = message.optJSONObject(2) ?: return
|
||||||
|
|
||||||
|
val tags = event.optJSONArray("tags")
|
||||||
|
var hasP = false
|
||||||
|
if (tags != null) {
|
||||||
|
for (i in 0 until tags.length()) {
|
||||||
|
val tag = tags.optJSONArray(i) ?: continue
|
||||||
|
if (tag.optString(0, "") == "p" && tag.optString(1, "") == clientPubkey) { hasP = true; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasP) { Log.d(TAG, "NIP-46 event missing p tag for client"); return }
|
||||||
|
|
||||||
|
val decryptedContent = decryptNip44(event.optString("content", ""), nip44ConversationKey(clientSecret, signerPubkey))
|
||||||
|
Log.d(TAG, "NIP-46 decrypted response: $decryptedContent")
|
||||||
|
if (decryptedContent.isEmpty()) return
|
||||||
|
val payload = JSONObject(decryptedContent)
|
||||||
|
if (requestId == payload.optString("id", "")) {
|
||||||
|
val result = payload.optString("result", "")
|
||||||
|
if (result.isNotEmpty()) {
|
||||||
|
signedEvent.setLength(0)
|
||||||
|
signedEvent.append(result)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "NIP-46 signer message error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
|
||||||
|
|
||||||
|
private fun finish() {
|
||||||
|
if (!done) { done = true; localLatch.countDown() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
localLatch.await(5, TimeUnit.SECONDS)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signedEvent.isEmpty()) return false
|
||||||
|
|
||||||
|
val authEvent = JSONObject(signedEvent.toString())
|
||||||
|
authEventId = authEvent.optString("id", "")
|
||||||
|
val authMessage = JSONArray().apply { put("AUTH"); put(authEvent) }.toString()
|
||||||
|
Log.d(TAG, "NIP-46 sending AUTH to relay ${relaySocket.request().url}: $authMessage")
|
||||||
|
return try {
|
||||||
|
relaySocket.send(authMessage)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "NIP-46 failed to send AUTH", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 705 B After Width: | Height: | Size: 711 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:ignore="NewApi">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
|
ext.kotlin_version = '2.2.20'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.8.0'
|
classpath 'com.android.tools.build:gradle:8.13.2'
|
||||||
classpath 'com.google.gms:google-services:4.4.2'
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':aparajita-capacitor-secure-storage'
|
||||||
|
project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android')
|
||||||
|
|
||||||
include ':capacitor-community-safe-area'
|
include ':capacitor-community-safe-area'
|
||||||
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area/android')
|
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area/android')
|
||||||
|
|
||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-clipboard'
|
||||||
|
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard/android')
|
||||||
|
|
||||||
|
include ':capacitor-filesystem'
|
||||||
|
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
|
||||||
|
|
||||||
include ':capacitor-keyboard'
|
include ':capacitor-keyboard'
|
||||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
|
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard/android')
|
||||||
|
|
||||||
|
include ':capacitor-preferences'
|
||||||
|
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences/android')
|
||||||
|
|
||||||
include ':capacitor-push-notifications'
|
include ':capacitor-push-notifications'
|
||||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications/android')
|
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
|
||||||
|
|
||||||
|
include ':capacitor-share'
|
||||||
|
project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share/android')
|
||||||
|
|
||||||
|
include ':capawesome-capacitor-android-dark-mode-support'
|
||||||
|
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
|
||||||
|
|
||||||
include ':capawesome-capacitor-badge'
|
include ':capawesome-capacitor-badge'
|
||||||
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge/android')
|
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
|
||||||
|
|
||||||
include ':nostr-signer-capacitor-plugin'
|
include ':nostr-signer-capacitor-plugin'
|
||||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
|
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin/android')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -86,8 +86,7 @@ done
|
|||||||
# shellcheck disable=SC2034
|
# shellcheck disable=SC2034
|
||||||
APP_BASE_NAME=${0##*/}
|
APP_BASE_NAME=${0##*/}
|
||||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
' "$PWD" ) || exit
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD=maximum
|
MAX_FD=maximum
|
||||||
@@ -115,7 +114,7 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
@@ -206,7 +205,7 @@ fi
|
|||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
# Collect all arguments for the java command:
|
# Collect all arguments for the java command:
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
# and any embedded shellness will be escaped.
|
# and any embedded shellness will be escaped.
|
||||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
# treated as '${Hostname}' itself on the command line.
|
# treated as '${Hostname}' itself on the command line.
|
||||||
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-classpath "$CLASSPATH" \
|
||||||
org.gradle.wrapper.GradleWrapperMain \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ goto fail
|
|||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
ext {
|
ext {
|
||||||
minSdkVersion = 23
|
minSdkVersion = 24
|
||||||
compileSdkVersion = 35
|
compileSdkVersion = 36
|
||||||
targetSdkVersion = 35
|
targetSdkVersion = 36
|
||||||
androidxActivityVersion = '1.9.2'
|
androidxActivityVersion = '1.11.0'
|
||||||
//https://github.com/ionic-team/capacitor/issues/7866
|
//https://github.com/ionic-team/capacitor/issues/7866
|
||||||
// androidxAppCompatVersion = '1.7.0'
|
// androidxAppCompatVersion = '1.7.1'
|
||||||
androidxAppCompatVersion = '1.6.1'
|
androidxAppCompatVersion = '1.7.1'
|
||||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||||
androidxCoreVersion = '1.15.0'
|
androidxCoreVersion = '1.17.0'
|
||||||
androidxFragmentVersion = '1.8.4'
|
androidxFragmentVersion = '1.8.9'
|
||||||
coreSplashScreenVersion = '1.0.1'
|
coreSplashScreenVersion = '1.2.0'
|
||||||
androidxWebkitVersion = '1.12.1'
|
androidxWebkitVersion = '1.14.0'
|
||||||
junitVersion = '4.13.2'
|
junitVersion = '4.13.2'
|
||||||
androidxJunitVersion = '1.2.1'
|
androidxJunitVersion = '1.3.0'
|
||||||
androidxEspressoCoreVersion = '3.6.1'
|
androidxEspressoCoreVersion = '3.7.0'
|
||||||
cordovaAndroidVersion = '10.1.1'
|
cordovaAndroidVersion = '14.0.1'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
# Fetch tags and set to env vars
|
# Fetch tags and set to env vars
|
||||||
git fetch --prune --unshallow --tags
|
git fetch --prune --unshallow --tags || true
|
||||||
git describe --tags --abbrev=0
|
git describe --tags --abbrev=0 || true
|
||||||
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
|
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
|
||||||
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
|
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
temp_env=$(declare -p -x)
|
temp_env=$(declare -p -x)
|
||||||
|
|
||||||
if [ -f .env.template ]; then
|
|
||||||
source .env.template
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
source .env
|
source .env
|
||||||
fi
|
fi
|
||||||
@@ -18,12 +14,13 @@ if [[ -z $VITE_BUILD_HASH ]]; then
|
|||||||
export VITE_BUILD_HASH=$(git rev-parse --short HEAD)
|
export VITE_BUILD_HASH=$(git rev-parse --short HEAD)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then
|
if [[ $VITE_PLATFORM_LOGO =~ ^https:// ]]; then
|
||||||
curl $VITE_PLATFORM_LOGO > static/logo.png
|
curl -fSL "$VITE_PLATFORM_LOGO" -o static/logo.png
|
||||||
export VITE_PLATFORM_LOGO=static/logo.png
|
export VITE_PLATFORM_LOGO=static/logo.png
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npx pwa-assets-generator
|
# Ensure generator uses local path (dotenv may have loaded URL from .env)
|
||||||
|
VITE_PLATFORM_LOGO="${VITE_PLATFORM_LOGO}" npx pwa-assets-generator
|
||||||
npx vite build
|
npx vite build
|
||||||
|
|
||||||
# Replace index.html variables with stuff from our env
|
# Replace index.html variables with stuff from our env
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import type { CapacitorConfig } from '@capacitor/cli';
|
import type {CapacitorConfig} from "@capacitor/cli"
|
||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'social.flotilla',
|
appId: "social.flotilla",
|
||||||
appName: 'Flotilla',
|
appName: "Flotilla",
|
||||||
webDir: 'build'
|
webDir: "build",
|
||||||
server: {
|
ios: {
|
||||||
androidScheme: "https"
|
scheme: "Flotilla Chat",
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
adjustMarginsForEdgeToEdge: true,
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
CapacitorHttp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
SystemBars: {
|
||||||
|
insetsHandling: "enable",
|
||||||
|
},
|
||||||
SplashScreen: {
|
SplashScreen: {
|
||||||
androidSplashResourceName: "splash"
|
androidSplashResourceName: "splash",
|
||||||
},
|
},
|
||||||
Keyboard: {
|
Keyboard: {
|
||||||
style: "DARK",
|
style: "DARK",
|
||||||
@@ -17,14 +26,14 @@ const config: CapacitorConfig = {
|
|||||||
},
|
},
|
||||||
Badge: {
|
Badge: {
|
||||||
persist: true,
|
persist: true,
|
||||||
autoClear: true
|
autoClear: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
|
server: {
|
||||||
// server: {
|
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
|
||||||
// url: "http://192.168.1.115:1847",
|
// url: "http://192.168.1.17:1847",
|
||||||
// cleartext: true
|
// cleartext: true,
|
||||||
// },
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
export default config;
|
export default config
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 48;
|
objectVersion = 54;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||||
|
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; };
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; };
|
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; };
|
||||||
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
|
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||||
|
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||||
504EC3041FED79650016851F /* Flotilla Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Flotilla Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
504EC3041FED79650016851F /* Flotilla Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Flotilla Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
504EC2FB1FED79650016851F = {
|
504EC2FB1FED79650016851F = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */,
|
||||||
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
|
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
|
||||||
504EC3061FED79650016851F /* App */,
|
504EC3061FED79650016851F /* App */,
|
||||||
504EC3051FED79650016851F /* Products */,
|
504EC3051FED79650016851F /* Products */,
|
||||||
@@ -128,8 +131,9 @@
|
|||||||
504EC2FC1FED79650016851F /* Project object */ = {
|
504EC2FC1FED79650016851F /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastSwiftUpdateCheck = 920;
|
LastSwiftUpdateCheck = 920;
|
||||||
LastUpgradeCheck = 920;
|
LastUpgradeCheck = 2630;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
CreatedOnToolsVersion = 9.2;
|
CreatedOnToolsVersion = 9.2;
|
||||||
@@ -162,6 +166,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||||
|
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||||
@@ -253,6 +258,7 @@
|
|||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
CLANG_WARN_COMMA = YES;
|
CLANG_WARN_COMMA = YES;
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
@@ -260,8 +266,10 @@
|
|||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -271,8 +279,10 @@
|
|||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
@@ -287,10 +297,11 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@@ -310,6 +321,7 @@
|
|||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
CLANG_WARN_COMMA = YES;
|
CLANG_WARN_COMMA = YES;
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
@@ -317,8 +329,10 @@
|
|||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -328,8 +342,10 @@
|
|||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
@@ -338,10 +354,12 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -354,18 +372,21 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 13;
|
CURRENT_PROJECT_VERSION = 38;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 1.2.0;
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.8.0;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -380,17 +401,20 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 13;
|
CURRENT_PROJECT_VERSION = 38;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 1.2.0;
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.8.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
@@ -20,8 +20,18 @@
|
|||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Flotilla uses the camera when you enable it in a voice room.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@@ -47,11 +57,5 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<false/>
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -4,5 +4,9 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:app.flotilla.social</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
require_relative '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios/scripts/pods_helpers'
|
require_relative '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||||
|
|
||||||
platform :ios, '14.0'
|
platform :ios, '15.0'
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
|
|
||||||
# workaround to avoid Xcode caching of Pods that requires
|
# workaround to avoid Xcode caching of Pods that requires
|
||||||
@@ -9,14 +9,19 @@ use_frameworks!
|
|||||||
install! 'cocoapods', :disable_input_output_paths => true
|
install! 'cocoapods', :disable_input_output_paths => true
|
||||||
|
|
||||||
def capacitor_pods
|
def capacitor_pods
|
||||||
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area'
|
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
|
||||||
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
|
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
|
||||||
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
|
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
|
||||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications'
|
pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard'
|
||||||
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge'
|
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
|
||||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
|
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
|
||||||
|
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
|
||||||
|
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
|
||||||
|
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
|
||||||
|
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
|
||||||
|
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'Flotilla Chat' do
|
target 'Flotilla Chat' do
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
|
||||||
|
const force = process.argv.includes('--force')
|
||||||
|
|
||||||
|
if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) {
|
||||||
|
console.error('Error: Git working tree is dirty. Please commit or stash your changes first, or re-run with --force.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
|
||||||
|
|
||||||
|
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
|
||||||
|
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
|
||||||
|
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
|
||||||
|
pkg.pnpm.overrides["@welshman/editor"] = "link:../welshman/packages/editor"
|
||||||
|
pkg.pnpm.overrides["@welshman/feeds"] = "link:../welshman/packages/feeds"
|
||||||
|
pkg.pnpm.overrides["@welshman/lib"] = "link:../welshman/packages/lib"
|
||||||
|
pkg.pnpm.overrides["@welshman/net"] = "link:../welshman/packages/net"
|
||||||
|
pkg.pnpm.overrides["@welshman/router"] = "link:../welshman/packages/router"
|
||||||
|
pkg.pnpm.overrides["@welshman/signer"] = "link:../welshman/packages/signer"
|
||||||
|
pkg.pnpm.overrides["@welshman/store"] = "link:../welshman/packages/store"
|
||||||
|
pkg.pnpm.overrides["@welshman/util"] = "link:../welshman/packages/util"
|
||||||
|
// pkg.pnpm.overrides["nostr-editor"] = "link:../nostr-editor"
|
||||||
|
// pkg.pnpm.overrides["@pomade/core"] = "link:../pomade/packages/core"
|
||||||
|
// pkg.pnpm.overrides["nostr-signer-capacitor-plugin"] = "link:../nostr-signer-capacitor-plugin"
|
||||||
|
|
||||||
|
fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
|
||||||
|
execSync('pnpm i', { stdio: 'inherit' })
|
||||||
|
|
||||||
|
execSync('git checkout -f pnpm-lock.yaml', { stdio: 'inherit' })
|
||||||
|
execSync('git checkout -f package.json', { stdio: 'inherit' })
|
||||||
@@ -1,91 +1,114 @@
|
|||||||
{
|
{
|
||||||
"name": "flotilla",
|
"name": "flotilla",
|
||||||
"version": "1.2.0",
|
"version": "1.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "./build.sh",
|
"build": "./build.sh",
|
||||||
"sourcemaps": "./build.sh && ./sourcemaps.sh",
|
"build:server": "vite build --config vite.config.server.ts",
|
||||||
|
"start": "node server.js",
|
||||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check src && eslint src",
|
"lint": "prettier --check src && eslint src",
|
||||||
"format": "prettier --write src",
|
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||||
|
"format:all": "prettier --write src",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@eslint/js": "^9.26.0",
|
"@eslint/js": "^9.39.2",
|
||||||
"@sentry/cli": "^2.40.0",
|
"@sveltejs/kit": "^2.61.1",
|
||||||
"@sveltejs/kit": "^2.5.27",
|
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@types/eslint": "^9.6.0",
|
"@types/eslint": "^9.6.1",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.23",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.2",
|
||||||
"eslint-plugin-svelte": "^2.45.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.15.0",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.55.9",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vite": "^5.4.4"
|
"vite": "^6.4.2"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/safe-area": "7.0.0-alpha.1",
|
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||||
"@capacitor/android": "^7.0.0",
|
"@capacitor-community/safe-area": "^8.0.1",
|
||||||
"@capacitor/app": "^7.0.0",
|
"@capacitor/android": "^8.0.1",
|
||||||
"@capacitor/cli": "^7.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/core": "^7.0.1",
|
"@capacitor/cli": "^8.0.1",
|
||||||
"@capacitor/ios": "^7.0.0",
|
"@capacitor/clipboard": "^8.0.1",
|
||||||
"@capacitor/keyboard": "^7.0.0",
|
"@capacitor/core": "^8.0.1",
|
||||||
"@capacitor/push-notifications": "^7.0.1",
|
"@capacitor/filesystem": "^8.1.0",
|
||||||
"@capawesome/capacitor-badge": "^7.0.1",
|
"@capacitor/ios": "^8.0.1",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@capacitor/keyboard": "^8.0.0",
|
||||||
"@sentry/browser": "^8.35.0",
|
"@capacitor/preferences": "^8.0.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.4",
|
"@capacitor/push-notifications": "^8.0.0",
|
||||||
"@tiptap/core": "^2.12.0",
|
"@capacitor/share": "^8.0.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
"@capawesome/capacitor-badge": "^8.0.0",
|
||||||
"@vite-pwa/sveltekit": "^0.6.6",
|
"@getalby/lightning-tools": "^6.1.0",
|
||||||
"@welshman/app": "^0.3.8",
|
"@getalby/sdk": "^5.1.2",
|
||||||
"@welshman/content": "^0.3.8",
|
"@hono/node-server": "^2.0.0",
|
||||||
"@welshman/editor": "^0.3.8",
|
"@noble/curves": "^1.9.7",
|
||||||
"@welshman/feeds": "^0.3.8",
|
"@pomade/core": "^0.2.3",
|
||||||
"@welshman/lib": "^0.3.8",
|
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||||
"@welshman/net": "^0.3.8",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@welshman/relay": "^0.3.8",
|
"@tiptap/core": "^2.27.2",
|
||||||
"@welshman/router": "^0.3.8",
|
"@tiptap/pm": "^2.27.2",
|
||||||
"@welshman/signer": "^0.3.8",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@welshman/store": "^0.3.8",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"@welshman/util": "^0.3.8",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"compressorjs": "^1.2.1",
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
"daisyui": "^4.12.10",
|
"@welshman/app": "^0.8.15",
|
||||||
"date-picker-svelte": "^2.13.0",
|
"@welshman/content": "^0.8.15",
|
||||||
"dotenv": "^16.4.5",
|
"@welshman/editor": "^0.8.15",
|
||||||
"emoji-picker-element": "^1.22.8",
|
"@welshman/feeds": "^0.8.15",
|
||||||
"fuse.js": "^7.0.0",
|
"@welshman/lib": "^0.8.15",
|
||||||
"husky": "^9.1.6",
|
"@welshman/net": "^0.8.15",
|
||||||
"idb": "^8.0.0",
|
"@welshman/router": "^0.8.15",
|
||||||
"nostr-signer-capacitor-plugin": "^0.0.4",
|
"@welshman/signer": "^0.8.15",
|
||||||
"nostr-tools": "^2.14.2",
|
"@welshman/store": "^0.8.15",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"@welshman/util": "^0.8.15",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"compressorjs-next": "^1.1.2",
|
||||||
|
"daisyui": "^5.5.19",
|
||||||
|
"date-picker-svelte": "^2.17.0",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"emoji-picker-element": "^1.28.1",
|
||||||
|
"emoji-picker-element-data": "^1.8.0",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
|
"hono": "^4.12.23",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"livekit-client": "^2.17.2",
|
||||||
|
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
||||||
|
"nostr-tools": "^2.19.4",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"throttle-debounce": "^5.0.2",
|
||||||
"tippy.js": "^6.3.7"
|
"tippy.js": "^6.3.7"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"ignoredBuiltDependencies": [
|
"ignoredBuiltDependencies": [
|
||||||
"@sentry/cli",
|
|
||||||
"esbuild"
|
"esbuild"
|
||||||
],
|
],
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"sharp"
|
"sharp",
|
||||||
]
|
"nostr-signer-capacitor-plugin"
|
||||||
}
|
],
|
||||||
|
"overrides": {
|
||||||
|
"sharp": "0.35.0-rc.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import dotenv from "dotenv"
|
import dotenv from "dotenv"
|
||||||
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
||||||
|
|
||||||
|
dotenv.config({path: ".env.local"})
|
||||||
dotenv.config({path: ".env"})
|
dotenv.config({path: ".env"})
|
||||||
dotenv.config({path: ".env.template"})
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
preset,
|
preset,
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import {promises as fs} from "node:fs"
|
||||||
|
import {fileURLToPath} from "node:url"
|
||||||
|
|
||||||
|
import "dotenv/config"
|
||||||
|
import {serve} from "@hono/node-server"
|
||||||
|
import {serveStatic} from "@hono/node-server/serve-static"
|
||||||
|
import {loadRelay} from "@welshman/app"
|
||||||
|
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {load} from "cheerio"
|
||||||
|
import {Hono} from "hono"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const BUILD_DIR = path.join(__dirname, "build")
|
||||||
|
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || "", 10) || 3000
|
||||||
|
const HOST = process.env.HOST || "0.0.0.0"
|
||||||
|
|
||||||
|
let TEMPLATE_HTML = ""
|
||||||
|
try {
|
||||||
|
TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
|
||||||
|
const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION
|
||||||
|
|
||||||
|
// Match client-side decode logic
|
||||||
|
const decodeRelay = url => {
|
||||||
|
try {
|
||||||
|
return normalizeRelayUrl(decodeURIComponent(url))
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrlFromContext = context => {
|
||||||
|
const requestUrl = new URL(context.req.url)
|
||||||
|
const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim()
|
||||||
|
const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim()
|
||||||
|
|
||||||
|
if (forwardedProto === "http" || forwardedProto === "https") {
|
||||||
|
requestUrl.protocol = `${forwardedProto}:`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forwardedHost) {
|
||||||
|
requestUrl.host = forwardedHost
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRelayMeta = async relayUrl => {
|
||||||
|
if (!relayUrl) return undefined
|
||||||
|
try {
|
||||||
|
return await loadRelay(normalizeRelayUrl(relayUrl))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDefaultImage = requestUrl => {
|
||||||
|
return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMetadataForInvite = async (url, match) => {
|
||||||
|
const relayParam = url.searchParams.get("r")
|
||||||
|
if (!relayParam) return undefined
|
||||||
|
|
||||||
|
const relayMetadata = await fetchRelayMeta(relayParam)
|
||||||
|
if (!relayMetadata) return undefined
|
||||||
|
|
||||||
|
const relayDisplay = displayRelayUrl(relayParam)
|
||||||
|
const spaceName = relayMetadata.name
|
||||||
|
const relayDescription = relayMetadata.description
|
||||||
|
|
||||||
|
const title = spaceName
|
||||||
|
? `Invite to ${spaceName} on ${PLATFORM_NAME}`
|
||||||
|
: `Invite to a Space on ${PLATFORM_NAME}`
|
||||||
|
|
||||||
|
const parts = []
|
||||||
|
if (spaceName) {
|
||||||
|
parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`)
|
||||||
|
} else {
|
||||||
|
parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`)
|
||||||
|
if (relayDescription) parts.push(relayDescription)
|
||||||
|
else parts.push(PLATFORM_DESCRIPTION)
|
||||||
|
|
||||||
|
const description = parts.join(" ")
|
||||||
|
const image =
|
||||||
|
relayMetadata.icon ||
|
||||||
|
relayMetadata.picture ||
|
||||||
|
relayMetadata.image ||
|
||||||
|
buildDefaultImage(url)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
url: url.toString(),
|
||||||
|
site: url.origin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMetadataForSpace = async (url, match) => {
|
||||||
|
const relayParam = decodeRelay(match[1])
|
||||||
|
if (!relayParam) return undefined
|
||||||
|
|
||||||
|
const relayMetadata = await fetchRelayMeta(relayParam)
|
||||||
|
if (!relayMetadata) return undefined
|
||||||
|
|
||||||
|
const spaceName = relayMetadata.name || displayRelayUrl(relayParam)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${spaceName} on ${PLATFORM_NAME}`,
|
||||||
|
description: relayMetadata.description || PLATFORM_DESCRIPTION,
|
||||||
|
image:
|
||||||
|
relayMetadata.icon ||
|
||||||
|
relayMetadata.picture ||
|
||||||
|
relayMetadata.image ||
|
||||||
|
buildDefaultImage(url),
|
||||||
|
url: url.toString(),
|
||||||
|
site: url.origin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMetadataForSpaceSection = async (url, match) => {
|
||||||
|
const spaceMeta = await getMetadataForSpace(url, match)
|
||||||
|
if (!spaceMeta) return undefined
|
||||||
|
|
||||||
|
const section = match[2]
|
||||||
|
const sectionName = section.charAt(0).toUpperCase() + section.slice(1)
|
||||||
|
spaceMeta.title = `${sectionName} on ${spaceMeta.title}`
|
||||||
|
return spaceMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMetadataForSpaceItem = async (url, match) => {
|
||||||
|
const spaceMeta = await getMetadataForSpace(url, match)
|
||||||
|
if (!spaceMeta) return undefined
|
||||||
|
|
||||||
|
const section = match[2]
|
||||||
|
let itemType = "Item"
|
||||||
|
if (section === "calendar") itemType = "Event"
|
||||||
|
if (section === "threads") itemType = "Thread"
|
||||||
|
if (section === "polls") itemType = "Poll"
|
||||||
|
if (section === "goals") itemType = "Goal"
|
||||||
|
if (section === "classifieds") itemType = "Listing"
|
||||||
|
|
||||||
|
spaceMeta.title = `${itemType} on ${spaceMeta.title}`
|
||||||
|
return spaceMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMetadataForRoom = async (url, match) => {
|
||||||
|
const spaceMeta = await getMetadataForSpace(url, match)
|
||||||
|
if (!spaceMeta) return undefined
|
||||||
|
|
||||||
|
// Room metadata requires fetching from Nostr, which can be added later.
|
||||||
|
spaceMeta.title = `Room on ${spaceMeta.title}`
|
||||||
|
return spaceMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
[/^\/join\/?$/, getMetadataForInvite],
|
||||||
|
[/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection],
|
||||||
|
[/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem],
|
||||||
|
[/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom],
|
||||||
|
[/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace],
|
||||||
|
]
|
||||||
|
|
||||||
|
const getMetadataForRoute = async url => {
|
||||||
|
for (const [regex, getMetadata] of routes) {
|
||||||
|
const match = url.pathname.match(regex)
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
return await getMetadata(url, match)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error generating metadata for route ${url.pathname}:`, err)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const injectMeta = metadata => {
|
||||||
|
const $ = load(TEMPLATE_HTML)
|
||||||
|
|
||||||
|
if (metadata.title) {
|
||||||
|
$("title").text(metadata.title)
|
||||||
|
$('meta[property="og:title"]').attr("content", metadata.title)
|
||||||
|
$('meta[name="twitter:title"]').attr("content", metadata.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.description) {
|
||||||
|
$('meta[name="description"]').attr("content", metadata.description)
|
||||||
|
$('meta[property="og:description"]').attr("content", metadata.description)
|
||||||
|
$('meta[name="twitter:description"]').attr("content", metadata.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.image) {
|
||||||
|
$('meta[property="og:image"]').attr("content", metadata.image)
|
||||||
|
$('meta[name="twitter:image"]').attr("content", metadata.image)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.url) {
|
||||||
|
$('meta[property="og:url"]').attr("content", metadata.url)
|
||||||
|
$('meta[name="twitter:site"]').attr("content", metadata.site)
|
||||||
|
$('meta[name="twitter:url"]').attr("content", metadata.url)
|
||||||
|
$('link[rel="canonical"]').attr("href", metadata.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return $.html()
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
// Only allow GET and HEAD requests
|
||||||
|
app.use("*", async (context, next) => {
|
||||||
|
const method = context.req.method
|
||||||
|
if (method !== "GET" && method !== "HEAD") {
|
||||||
|
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
|
||||||
|
}
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Serve static assets with appropriate caching
|
||||||
|
app.use(
|
||||||
|
"*",
|
||||||
|
serveStatic({
|
||||||
|
root: BUILD_DIR,
|
||||||
|
onFound: (filePath, context) => {
|
||||||
|
const isImmutable = filePath.split(path.sep).join("/").includes("/_app/immutable/")
|
||||||
|
const cacheControl =
|
||||||
|
path.basename(filePath) === "index.html"
|
||||||
|
? "no-cache"
|
||||||
|
: isImmutable
|
||||||
|
? "public, max-age=31536000, immutable"
|
||||||
|
: "public, max-age=3600"
|
||||||
|
|
||||||
|
context.header("Cache-Control", cacheControl)
|
||||||
|
|
||||||
|
// Immutable assets are content-hashed by Vite, so the filename is itself a
|
||||||
|
// stable content identifier. Exposing it as an ETag lets clients that
|
||||||
|
// revalidate explicitly (e.g. emoji-picker-element checks its data source
|
||||||
|
// on every load) skip re-downloading large files when nothing changed.
|
||||||
|
if (isImmutable) {
|
||||||
|
context.header("ETag", `"${path.basename(filePath)}"`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// SPA fallback for routes that don't match static files
|
||||||
|
app.get("*", async context => {
|
||||||
|
const requestUrl = requestUrlFromContext(context)
|
||||||
|
|
||||||
|
// If the path has an extension, it's likely a missing static asset, not an SPA route
|
||||||
|
if (path.extname(requestUrl.pathname)) {
|
||||||
|
return context.text("Not found", 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await getMetadataForRoute(requestUrl)
|
||||||
|
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
|
||||||
|
|
||||||
|
return context.html(html, 200, {
|
||||||
|
"Cache-Control": metadata ? "no-store" : "no-cache",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
serve(
|
||||||
|
{
|
||||||
|
fetch: app.fetch,
|
||||||
|
hostname: HOST,
|
||||||
|
port: PORT,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
hash=$(find build -type f -print0 | sort -z | xargs -0 sha1sum | sha1sum | awk '{print $1}')
|
|
||||||
|
|
||||||
sentry-cli \
|
|
||||||
--url https://glitchtip.coracle.social \
|
|
||||||
--auth-token $GLITCHTIP_AUTH_TOKEN \
|
|
||||||
--api-key $VITE_GLITCHTIP_API_KEY \
|
|
||||||
sourcemaps \
|
|
||||||
--org coracle \
|
|
||||||
--project flotilla \
|
|
||||||
--release $hash \
|
|
||||||
upload \
|
|
||||||
--url-prefix /_app/immutable/ \
|
|
||||||
build/_app/immutable
|
|
||||||
@@ -1,143 +1,256 @@
|
|||||||
@import "@welshman/editor/index.css";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@tailwind base;
|
@config "../tailwind.config.js";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* Fonts */
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Satoshis";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lato";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Lato-Regular.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lato";
|
|
||||||
font-style: bold;
|
|
||||||
font-weight: 600;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Lato-Bold.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Lato";
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
src:
|
|
||||||
local(""),
|
|
||||||
url("/fonts/Italic.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* root */
|
/* root */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: Lato;
|
font-family: Lato;
|
||||||
--sait: env(safe-area-inset-top);
|
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||||
--saib: env(safe-area-inset-bottom);
|
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||||
--sail: env(safe-area-inset-left);
|
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||||
--sair: env(safe-area-inset-right);
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme] {
|
@utility pt-sai {
|
||||||
@apply bg-base-300;
|
padding-top: var(--sait);
|
||||||
--base-100: oklch(var(--b1));
|
|
||||||
--base-200: oklch(var(--b2));
|
|
||||||
--base-300: oklch(var(--b3));
|
|
||||||
--base-content: oklch(var(--bc));
|
|
||||||
--primary: oklch(var(--p));
|
|
||||||
--primary-content: oklch(var(--pc));
|
|
||||||
--secondary: oklch(var(--s));
|
|
||||||
--secondary-content: oklch(var(--sc));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* safe area insets */
|
@utility pr-sai {
|
||||||
|
padding-right: var(--sair);
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@utility pb-sai {
|
||||||
.pt-sai {
|
padding-bottom: var(--saib);
|
||||||
padding-top: var(--sait);
|
}
|
||||||
|
|
||||||
|
@utility pl-sai {
|
||||||
|
padding-left: var(--sail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility px-sai {
|
||||||
|
@apply pl-sai pr-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility py-sai {
|
||||||
|
@apply pt-sai pb-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility p-sai {
|
||||||
|
@apply py-sai px-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mt-sai {
|
||||||
|
margin-top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mr-sai {
|
||||||
|
margin-right: var(--sair);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mb-sai {
|
||||||
|
margin-bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility ml-sai {
|
||||||
|
margin-left: var(--sail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mx-sai {
|
||||||
|
@apply ml-sai mr-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility my-sai {
|
||||||
|
@apply mt-sai mb-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility m-sai {
|
||||||
|
@apply my-sai mx-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility top-sai {
|
||||||
|
top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility right-sai {
|
||||||
|
right: var(--sair);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bottom-sai {
|
||||||
|
bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility left-sai {
|
||||||
|
left: var(--sail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card2 {
|
||||||
|
@apply rounded-box text-base-content p-4 sm:p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility column {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility center {
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-2 {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-3 {
|
||||||
|
@apply flex items-center gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility row-4 {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-2 {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-3 {
|
||||||
|
@apply flex flex-col gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-4 {
|
||||||
|
@apply flex flex-col gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility col-8 {
|
||||||
|
@apply flex flex-col gap-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility ellipsize {
|
||||||
|
@apply overflow-hidden text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-x {
|
||||||
|
@apply px-4 sm:px-8 md:px-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-t {
|
||||||
|
@apply pt-4 sm:pt-8 md:pt-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-b {
|
||||||
|
@apply pb-4 sm:pb-8 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-padding-y {
|
||||||
|
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content-sizing {
|
||||||
|
@apply m-auto w-full max-w-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility content {
|
||||||
|
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility heading {
|
||||||
|
@apply text-center text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility subheading {
|
||||||
|
@apply text-center text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility superheading {
|
||||||
|
@apply text-center text-4xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility link {
|
||||||
|
@apply text-primary cursor-pointer underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* content visibility */
|
||||||
|
|
||||||
|
@utility cv {
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Fonts */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Satoshis";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pr-sai {
|
@font-face {
|
||||||
padding-right: var(--sair);
|
font-family: "Lato";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Lato-Regular.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pb-sai {
|
@font-face {
|
||||||
padding-bottom: var(--saib);
|
font-family: "Lato";
|
||||||
|
font-style: bold;
|
||||||
|
font-weight: 600;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Lato-Bold.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl-sai {
|
@font-face {
|
||||||
padding-left: var(--sail);
|
font-family: "Lato";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src:
|
||||||
|
local(""),
|
||||||
|
url("/fonts/Italic.ttf") format("truetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
.px-sai {
|
/* root */
|
||||||
@apply pl-sai pr-sai;
|
|
||||||
|
:root {
|
||||||
|
font-family: Lato;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||||
|
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||||
|
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||||
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
.py-sai {
|
[data-theme] {
|
||||||
@apply pt-sai pb-sai;
|
@apply bg-base-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-sai {
|
.mobile [data-tip]::before {
|
||||||
@apply py-sai px-sai;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-sai {
|
/* safe area insets */
|
||||||
padding-top: var(--sait);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mr-sai {
|
|
||||||
padding-right: var(--sair);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-sai {
|
|
||||||
padding-bottom: var(--saib);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-sai {
|
|
||||||
padding-left: var(--sail);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx-sai {
|
|
||||||
@apply ml-sai mr-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-sai {
|
|
||||||
@apply mt-sai mb-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sai {
|
|
||||||
@apply my-sai mx-sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-sai {
|
|
||||||
top: var(--sait);
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-sai {
|
|
||||||
right: var(--sair);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-sai {
|
|
||||||
bottom: var(--saib);
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-sai {
|
|
||||||
left: var(--sail);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* utilities */
|
/* utilities */
|
||||||
@@ -159,140 +272,42 @@
|
|||||||
@apply bg-base-300 text-base-content transition-colors;
|
@apply bg-base-300 text-base-content transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card2 {
|
|
||||||
@apply rounded-box p-6 text-base-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card2.card2-sm {
|
.card2.card2-sm {
|
||||||
@apply p-4 text-base-content;
|
@apply text-base-content p-2 sm:p-4;
|
||||||
}
|
|
||||||
|
|
||||||
.column {
|
|
||||||
@apply flex flex-col;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
@apply flex items-center justify-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-2 {
|
|
||||||
@apply flex items-center gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-3 {
|
|
||||||
@apply flex items-center gap-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-4 {
|
|
||||||
@apply flex items-center gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-2 {
|
|
||||||
@apply flex flex-col gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-3 {
|
|
||||||
@apply flex flex-col gap-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-4 {
|
|
||||||
@apply flex flex-col gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-8 {
|
|
||||||
@apply flex flex-col gap-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
@apply justify-start overflow-hidden text-ellipsis whitespace-nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ellipsize {
|
|
||||||
@apply overflow-hidden text-ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tip]::before {
|
[data-tip]::before {
|
||||||
@apply ellipsize;
|
@apply overflow-hidden text-ellipsis;
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
[data-tip]::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-x {
|
|
||||||
@apply px-4 sm:px-8 md:px-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-t {
|
|
||||||
@apply pt-4 sm:pt-8 md:pt-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-b {
|
|
||||||
@apply pb-4 sm:pb-8 md:pb-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-y {
|
|
||||||
@apply content-padding-t content-padding-b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-sizing {
|
|
||||||
@apply m-auto w-full max-w-3xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
@apply content-sizing content-padding-x content-padding-y;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
@apply text-center text-2xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subheading {
|
|
||||||
@apply text-center text-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.superheading {
|
|
||||||
@apply text-center text-4xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
@apply cursor-pointer text-primary underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input input::placeholder {
|
.input input::placeholder {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-top-xl {
|
|
||||||
@apply shadow-[0_20px_25px_-5px_rgb(0,0,0,0.1)_0_8px_10px_-6px_rgb(0,0,0,0.1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tiptap */
|
/* tiptap */
|
||||||
|
|
||||||
.input-editor,
|
.input-editor,
|
||||||
.chat-editor,
|
.chat-editor,
|
||||||
.note-editor {
|
.note-editor {
|
||||||
@apply -m-1 min-h-12 p-1;
|
@apply -m-1 p-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
--tiptap-object-bg: var(--base-100);
|
--tiptap-object-bg: var(--color-neutral);
|
||||||
--tiptap-object-fg: var(--base-content);
|
--tiptap-object-fg: var(--color-neutral-content);
|
||||||
--tiptap-active-bg: var(--primary);
|
--tiptap-active-bg: var(--color-primary);
|
||||||
--tiptap-active-fg: var(--primary-content);
|
--tiptap-active-fg: var(--color-primary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions {
|
.tiptap-suggestions {
|
||||||
--tiptap-object-bg: var(--base-100);
|
--tiptap-object-bg: var(--color-base-100);
|
||||||
--tiptap-object-fg: var(--base-content);
|
--tiptap-object-fg: var(--color-base-content);
|
||||||
--tiptap-active-bg: var(--base-300);
|
--tiptap-active-bg: var(--color-base-300);
|
||||||
--tiptap-active-fg: var(--base-content);
|
--tiptap-active-fg: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions__item {
|
.tiptap-suggestions__item {
|
||||||
@apply border-l-2 border-solid border-base-100;
|
@apply border-base-100 border-l-2 border-solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-suggestions__selected {
|
.tiptap-suggestions__selected {
|
||||||
@@ -300,7 +315,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
@apply max-h-[350px] overflow-y-auto p-2 px-4;
|
@apply max-h-[350px] min-h-10 overflow-y-auto p-2 px-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap p.is-editor-empty:first-child::before {
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
@@ -312,13 +327,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-editor .tiptap {
|
.note-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
|
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-editor .tiptap {
|
.input-editor .tiptap {
|
||||||
--tiptap-object-bg: var(--base-200);
|
--tiptap-object-bg: var(--color-base-200);
|
||||||
@apply input input-bordered h-auto p-[.65rem];
|
@apply input block h-auto p-[.65rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* link-content, based on tiptap */
|
/* link-content, based on tiptap */
|
||||||
@@ -330,8 +345,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
background-color: var(--base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--base-content);
|
color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* content rendered by welshman/content */
|
/* content rendered by welshman/content */
|
||||||
@@ -347,33 +362,47 @@
|
|||||||
/* date input */
|
/* date input */
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
--date-picker-foreground: var(--base-content);
|
--date-picker-foreground: var(--color-base-content);
|
||||||
--date-picker-background: var(--base-300);
|
--date-picker-background: var(--color-base-300);
|
||||||
--date-picker-highlight-border: var(--primary);
|
--date-picker-highlight-border: var(--color-primary);
|
||||||
--date-picker-selected-color: var(--primary-content);
|
--date-picker-selected-color: var(--color-primary-content);
|
||||||
--date-picker-selected-background: var(--primary);
|
--date-picker-selected-background: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-time-field {
|
.date-time-field {
|
||||||
@apply input input-bordered rounded-lg px-0;
|
@apply input rounded-lg px-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-time-field input {
|
.date-time-field input {
|
||||||
@apply !h-full !w-full !rounded-lg !border-none !bg-inherit !px-4 !text-inherit;
|
@apply h-full! w-full! rounded-lg! border-none! bg-inherit! px-4! text-inherit!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tippy popover */
|
||||||
|
|
||||||
|
.tippy-target {
|
||||||
|
@apply z-tooltip pointer-events-none fixed inset-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-target > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-box {
|
||||||
|
@apply rounded-box shadow-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* emoji picker */
|
/* emoji picker */
|
||||||
|
|
||||||
emoji-picker {
|
emoji-picker {
|
||||||
--background: var(--base-100);
|
--background: var(--color-base-100);
|
||||||
--border-color: var(--base-100);
|
--border-color: var(--color-base-100);
|
||||||
--border-radius: var(--rounded-box);
|
--border-radius: var(--rounded-box);
|
||||||
--button-active-background: var(--base-content);
|
--button-active-background: var(--color-base-content);
|
||||||
--button-hover-background: var(--base-content);
|
--button-hover-background: var(--color-base-content);
|
||||||
--indicator-color: var(--base-content);
|
--indicator-color: var(--color-base-content);
|
||||||
--input-border-color: var(--base-100);
|
--input-border-color: var(--color-base-100);
|
||||||
--input-font-color: var(--base-content);
|
--input-font-color: var(--color-base-content);
|
||||||
--outline-color: var(--base-100);
|
--outline-color: var(--color-base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* progress */
|
/* progress */
|
||||||
@@ -384,24 +413,38 @@ progress[value]::-webkit-progress-value {
|
|||||||
|
|
||||||
/* content width for fixed elements */
|
/* content width for fixed elements */
|
||||||
|
|
||||||
.cw {
|
.left-content {
|
||||||
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
@apply md:left-[calc(18.5rem+var(--sail))];
|
||||||
}
|
}
|
||||||
|
|
||||||
.cw-full {
|
.left-content-full {
|
||||||
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
|
@apply md:left-[calc(3.5rem+var(--sail))];
|
||||||
}
|
}
|
||||||
|
|
||||||
.cb {
|
/* Keyboard open state adjustments */
|
||||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
|
||||||
|
body.keyboard-open {
|
||||||
|
--saib: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.keyboard-open .hide-on-keyboard {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.keyboard-open .chat__compose {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* chat view */
|
/* chat view */
|
||||||
|
|
||||||
.chat__compose {
|
.chat__compose {
|
||||||
@apply cb cw fixed;
|
@apply z-compose relative mb-14 shrink-0 md:mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__compose .chat__compose-inner {
|
||||||
|
@apply min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__scroll-down {
|
.chat__scroll-down {
|
||||||
@apply fixed bottom-28 right-4 md:bottom-16;
|
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<title>{NAME}</title>
|
||||||
|
<link rel="canonical" href="{URL}" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<meta name="theme-color" content="{ACCENT}" />
|
<meta name="theme-color" content="{ACCENT}" />
|
||||||
<meta name="description" content="{DESCRIPTION}" />
|
<meta name="description" content="{DESCRIPTION}" />
|
||||||
<meta name="og:url" content="{URL}" />
|
<meta property="og:url" content="{URL}" />
|
||||||
<meta name="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta name="og:title" content="{NAME}" />
|
<meta property="og:title" content="{NAME}" />
|
||||||
<meta name="og:description" content="{DESCRIPTION}" />
|
<meta property="og:description" content="{DESCRIPTION}" />
|
||||||
|
<meta property="og:image" content="" />
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="twitter:site" content="{URL}" />
|
<meta name="twitter:site" content="{URL}" />
|
||||||
<meta name="twitter:title" content="{NAME}" />
|
<meta name="twitter:title" content="{NAME}" />
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon-180x180.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon-180x180.png" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover" data-sveltekit-preload-code="eager">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
<script
|
<script
|
||||||
defer
|
defer
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {Room as LiveKitRoom} from "livekit-client"
|
||||||
|
import {derived, writable} from "svelte/store"
|
||||||
|
import {type Room} from "@app/core/state"
|
||||||
|
|
||||||
|
export type VoiceSession = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
room: LiveKitRoom
|
||||||
|
cameraOn: boolean
|
||||||
|
screenShareOn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mic mute state is separate so toggling it does not re-render video tiles. */
|
||||||
|
export const voiceMicMuted = writable(true)
|
||||||
|
|
||||||
|
export type Pubkey = string
|
||||||
|
|
||||||
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||||
|
|
||||||
|
export enum VoiceState {
|
||||||
|
Joining = "joining",
|
||||||
|
Connected = "connected",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||||
|
|
||||||
|
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||||
|
|
||||||
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
|
|
||||||
|
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||||
|
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||||
|
|
||||||
|
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||||
|
return pk ? {pubkey: pk, identity} : {identity}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||||
|
|
||||||
|
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||||
|
|
||||||
|
export const participantMediaState = writable(
|
||||||
|
new Map<string, {muted: boolean; cameraOn: boolean}>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const mediaStateByIdentity = derived(
|
||||||
|
[participantMediaState, currentVoiceSession, voiceMicMuted],
|
||||||
|
([$media, $session, $micMuted]) =>
|
||||||
|
(identity: string) => {
|
||||||
|
if ($session?.room.localParticipant.identity === identity) {
|
||||||
|
return {muted: $micMuted, cameraOn: $session.cameraOn}
|
||||||
|
}
|
||||||
|
return $media.get(identity) ?? {muted: true, cameraOn: false}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const isParticipantSpeaking = derived(
|
||||||
|
speakingParticipants,
|
||||||
|
$participants => (p: VoiceParticipant) =>
|
||||||
|
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const isLocalSpeaking = derived(
|
||||||
|
[currentVoiceSession, speakingParticipants],
|
||||||
|
([$session, $speaking]) => {
|
||||||
|
if (!$session?.room) return false
|
||||||
|
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
|
||||||
|
return $speaking.some(sp => participantKey(sp) === participantKey(local))
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {Track} from "livekit-client"
|
||||||
|
import {MediaQuery} from "svelte/reactivity"
|
||||||
|
import {derived, get, writable} from "svelte/store"
|
||||||
|
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
export enum VideoCallLayout {
|
||||||
|
Chat = "chat",
|
||||||
|
Video = "video",
|
||||||
|
Split = "split",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||||
|
|
||||||
|
export enum ViewportSize {
|
||||||
|
Desktop = "desktop",
|
||||||
|
Mobile = "mobile",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallViewportSync = {
|
||||||
|
previousLayout: undefined as ViewportSize | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||||
|
|
||||||
|
export const resetVideoCallLayout = () => {
|
||||||
|
videoCallViewportSync.previousLayout = undefined
|
||||||
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||||
|
|
||||||
|
export const toggleVideoPrimaryTile = (key: string) => {
|
||||||
|
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||||
|
|
||||||
|
const countLiveVisualFeeds = (session: VoiceSession): number => {
|
||||||
|
const room = session.room
|
||||||
|
let n = 0
|
||||||
|
const lp = room.localParticipant
|
||||||
|
if (session.cameraOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
for (const rp of room.remoteParticipants.values()) {
|
||||||
|
for (const source of VISUAL_SOURCES) {
|
||||||
|
const pub = rp.getTrackPublication(source)
|
||||||
|
if (pub?.isSubscribed && pub.track) n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
export const triggerVideoFeedCount = () => {
|
||||||
|
currentVoiceSession.update(s => (s ? {...s} : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
|
||||||
|
if ($state !== VoiceState.Connected || !$session) return 0
|
||||||
|
return countLiveVisualFeeds($session)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toggleCamera = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const cameraOn = !session.cameraOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(cameraOn)
|
||||||
|
currentVoiceSession.set({...session, cameraOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleScreenShare = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const screenShareOn = !session.screenShareOn
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
|
||||||
|
currentVoiceSession.set({...session, screenShareOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
|
||||||
|
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DisconnectReason,
|
||||||
|
LocalParticipant,
|
||||||
|
LocalTrackPublication,
|
||||||
|
Participant,
|
||||||
|
Room as LiveKitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
TrackPublication,
|
||||||
|
supportsAudioOutputSelection,
|
||||||
|
type AudioCaptureOptions,
|
||||||
|
} from "livekit-client"
|
||||||
|
import {derived, get} from "svelte/store"
|
||||||
|
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||||
|
import {signer} from "@welshman/app"
|
||||||
|
import {getLivekitEndpoint} from "$lib/livekit"
|
||||||
|
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
|
import {
|
||||||
|
currentVoiceRoom,
|
||||||
|
currentVoiceSession,
|
||||||
|
voiceMicMuted,
|
||||||
|
participantFromLiveKitIdentity,
|
||||||
|
participantKey,
|
||||||
|
participantMediaState,
|
||||||
|
speakingParticipants,
|
||||||
|
VoiceState,
|
||||||
|
type VoiceParticipant,
|
||||||
|
voiceState,
|
||||||
|
} from "@app/call/stores"
|
||||||
|
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||||
|
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
||||||
|
export {checkRelayHasLivekit} from "$lib/livekit"
|
||||||
|
|
||||||
|
export {supportsAudioOutputSelection}
|
||||||
|
|
||||||
|
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||||
|
|
||||||
|
export enum DeviceKind {
|
||||||
|
AudioInput = "audioinput",
|
||||||
|
AudioOutput = "audiooutput",
|
||||||
|
VideoInput = "videoinput",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const switchVoiceActiveDevice = async (
|
||||||
|
kind: DeviceKind,
|
||||||
|
targetDeviceId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||||
|
try {
|
||||||
|
await session.room.switchActiveDevice(kind, id)
|
||||||
|
} catch {
|
||||||
|
let label: string
|
||||||
|
switch (kind) {
|
||||||
|
case DeviceKind.AudioInput:
|
||||||
|
label = "microphone"
|
||||||
|
break
|
||||||
|
case DeviceKind.AudioOutput:
|
||||||
|
label = "speaker"
|
||||||
|
break
|
||||||
|
case DeviceKind.VideoInput:
|
||||||
|
label = "camera"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteParticipant = (identity: string) => {
|
||||||
|
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncParticipantMedia = (participant: Participant) => {
|
||||||
|
const state = {muted: !participant.isMicrophoneEnabled, cameraOn: participant.isCameraEnabled}
|
||||||
|
participantMediaState.update(m => {
|
||||||
|
const prev = m.get(participant.identity)
|
||||||
|
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
||||||
|
const next = new Map(m)
|
||||||
|
next.set(participant.identity, state)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
||||||
|
syncParticipantMedia(participant)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLivekitToken = async (
|
||||||
|
url: string,
|
||||||
|
groupId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{server_url: string; participant_token: string}> => {
|
||||||
|
const endpoint = getLivekitEndpoint(url, groupId)
|
||||||
|
|
||||||
|
const $signer = signer.get()
|
||||||
|
if (!$signer) throw new Error("No signer available")
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||||
|
|
||||||
|
const template = await makeHttpAuth(endpoint, "GET")
|
||||||
|
const signedEvent = await $signer.sign(template)
|
||||||
|
const authHeader = makeHttpAuthHeader(signedEvent)
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
headers: {Authorization: authHeader},
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(`Token request failed (${response.status}): ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deriveVoiceParticipants = (url: string, h: string) =>
|
||||||
|
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
||||||
|
derived(
|
||||||
|
[
|
||||||
|
participantMediaState,
|
||||||
|
currentVoiceRoom,
|
||||||
|
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||||
|
],
|
||||||
|
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||||
|
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||||
|
|
||||||
|
if (inCall) {
|
||||||
|
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
||||||
|
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||||
|
} else {
|
||||||
|
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
||||||
|
if (!latestEvent) return []
|
||||||
|
const participants = removeUndefined(
|
||||||
|
map(
|
||||||
|
(tag: string[]) => (tag[1] ? {pubkey: tag[1], identity: tag[1]} : undefined),
|
||||||
|
getTags("participant", latestEvent.tags),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const setUpMicrophone = async (
|
||||||
|
startMuted: boolean,
|
||||||
|
preferredMicId: string | undefined,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (startMuted) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let muted = true
|
||||||
|
let capture: AudioCaptureOptions | undefined = undefined
|
||||||
|
if (preferredMicId) {
|
||||||
|
capture = {deviceId: preferredMicId}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await participant.setMicrophoneEnabled(true, capture)
|
||||||
|
muted = false
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({theme: "error", message: "Could not access microphone"})
|
||||||
|
}
|
||||||
|
return muted
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
|
voiceMicMuted.set(true)
|
||||||
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
|
voiceState.set(VoiceState.Disconnected)
|
||||||
|
const message =
|
||||||
|
reason === DisconnectReason.JOIN_FAILURE
|
||||||
|
? "Could not connect to voice room. Please try again."
|
||||||
|
: "Voice connection lost."
|
||||||
|
pushToast({theme: "error", message})
|
||||||
|
}
|
||||||
|
speakingParticipants.set([])
|
||||||
|
participantMediaState.set(new Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTrackSubscribed = (track: Track) => {
|
||||||
|
if (track.kind === Track.Kind.Audio) {
|
||||||
|
const element = track.attach()
|
||||||
|
element.style.display = "none"
|
||||||
|
document.body.appendChild(element)
|
||||||
|
element.play().catch(() => {})
|
||||||
|
} else if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTrackUnsubscribed = (track: Track) => {
|
||||||
|
track.detach().forEach(el => el.remove())
|
||||||
|
if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoFeedCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||||
|
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const playJoinSound = () => {
|
||||||
|
const audio = new Audio("/join-voice-room.mp3")
|
||||||
|
audio.play().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onParticipantConnected = (participant: Participant) => {
|
||||||
|
syncParticipantMedia(participant)
|
||||||
|
playJoinSound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onParticipantDisconnected = (participant: {identity: string}) => {
|
||||||
|
deleteParticipant(participant.identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLocalTrackUnpublished = (
|
||||||
|
publication: LocalTrackPublication,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
) => {
|
||||||
|
if (publication.source !== Track.Source.ScreenShare) return
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
||||||
|
if (!session.screenShareOn) return
|
||||||
|
currentVoiceSession.set({...session, screenShareOn: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
let joinAbortController: AbortController | undefined
|
||||||
|
|
||||||
|
export const cancelJoinVoiceRoom = () => {
|
||||||
|
joinAbortController?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const joinVoiceRoom = async (
|
||||||
|
url: string,
|
||||||
|
h: string,
|
||||||
|
startMuted = true,
|
||||||
|
preferredMicId?: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
cancelJoinVoiceRoom()
|
||||||
|
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (session) await leaveVoiceRoom()
|
||||||
|
|
||||||
|
currentVoiceRoom.set(get(deriveRoom(url, h)))
|
||||||
|
voiceState.set(VoiceState.Joining)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
joinAbortController = controller
|
||||||
|
const signal = controller.signal
|
||||||
|
const isActive = () => joinAbortController === controller
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
|
||||||
|
|
||||||
|
if (signal.aborted) throw new AbortError()
|
||||||
|
|
||||||
|
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
|
||||||
|
|
||||||
|
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
|
||||||
|
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||||
|
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
|
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||||
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackMuted, onParticipantMediaChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackUnmuted, onParticipantMediaChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackPublished, onParticipantMediaChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.TrackUnpublished, onParticipantMediaChanged)
|
||||||
|
liveKitRoom.on(RoomEvent.LocalTrackPublished, onParticipantMediaChanged)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
||||||
|
whenTimeout(15_000, {
|
||||||
|
message: "Connection timed out. Please check your network and try again.",
|
||||||
|
}),
|
||||||
|
whenAborted(signal),
|
||||||
|
])
|
||||||
|
} catch (e) {
|
||||||
|
liveKitRoom.disconnect()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
participantMediaState.set(new Map())
|
||||||
|
syncParticipantMedia(liveKitRoom.localParticipant)
|
||||||
|
for (const p of liveKitRoom.remoteParticipants.values()) {
|
||||||
|
syncParticipantMedia(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
|
|
||||||
|
voiceMicMuted.set(muted)
|
||||||
|
currentVoiceSession.set({
|
||||||
|
url,
|
||||||
|
h,
|
||||||
|
room: liveKitRoom,
|
||||||
|
cameraOn: false,
|
||||||
|
screenShareOn: false,
|
||||||
|
})
|
||||||
|
voiceState.set(VoiceState.Connected)
|
||||||
|
playJoinSound()
|
||||||
|
} catch (e) {
|
||||||
|
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
||||||
|
if (e instanceof AbortError) return
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
if (isActive()) joinAbortController = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const leaveVoiceRoom = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const audio = new Audio("/leave-voice-room.mp3")
|
||||||
|
audio.play().catch(() => {})
|
||||||
|
|
||||||
|
if (session.cameraOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off camera."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(false)
|
||||||
|
} catch {
|
||||||
|
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceState.set(VoiceState.Disconnected)
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
|
voiceMicMuted.set(true)
|
||||||
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
|
session.room.disconnect()
|
||||||
|
speakingParticipants.set([])
|
||||||
|
participantMediaState.set(new Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rejoinVoiceRoom = async (): Promise<void> => {
|
||||||
|
const target = get(currentVoiceRoom)
|
||||||
|
if (!target) return
|
||||||
|
return joinVoiceRoom(target.url, target.h)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleMute = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
voiceMicMuted.update(not)
|
||||||
|
if (get(voiceMicMuted)) {
|
||||||
|
// Disable and re-enable microphone to trigger permission prompt
|
||||||
|
session.room.localParticipant.setMicrophoneEnabled(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setMicrophoneEnabled(true)
|
||||||
|
} catch (e) {
|
||||||
|
voiceMicMuted.set(true)
|
||||||
|
pushToast({theme: "error", message: "Could not access microphone"})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
import * as nip19 from "nostr-tools/nip19"
|
|
||||||
import {get} from "svelte/store"
|
|
||||||
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
|
|
||||||
import type {Feed} from "@welshman/feeds"
|
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
|
||||||
import {
|
|
||||||
DELETE,
|
|
||||||
REPORT,
|
|
||||||
PROFILE,
|
|
||||||
INBOX_RELAYS,
|
|
||||||
RELAYS,
|
|
||||||
FOLLOWS,
|
|
||||||
REACTION,
|
|
||||||
AUTH_JOIN,
|
|
||||||
ROOMS,
|
|
||||||
COMMENT,
|
|
||||||
ALERT_EMAIL,
|
|
||||||
ALERT_WEB,
|
|
||||||
ALERT_IOS,
|
|
||||||
ALERT_ANDROID,
|
|
||||||
isSignedEvent,
|
|
||||||
makeEvent,
|
|
||||||
displayProfile,
|
|
||||||
normalizeRelayUrl,
|
|
||||||
makeList,
|
|
||||||
addToListPublicly,
|
|
||||||
removeFromListByPredicate,
|
|
||||||
getTag,
|
|
||||||
getListTags,
|
|
||||||
getRelayTags,
|
|
||||||
getRelayTagValues,
|
|
||||||
toNostrURI,
|
|
||||||
getRelaysFromList,
|
|
||||||
RelayMode,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
|
|
||||||
import {Router} from "@welshman/router"
|
|
||||||
import {
|
|
||||||
pubkey,
|
|
||||||
signer,
|
|
||||||
repository,
|
|
||||||
publishThunk,
|
|
||||||
profilesByPubkey,
|
|
||||||
relaySelectionsByPubkey,
|
|
||||||
tagEvent,
|
|
||||||
tagEventForReaction,
|
|
||||||
userRelaySelections,
|
|
||||||
userInboxRelaySelections,
|
|
||||||
nip44EncryptToSelf,
|
|
||||||
loadRelay,
|
|
||||||
clearStorage,
|
|
||||||
dropSession,
|
|
||||||
tagEventForComment,
|
|
||||||
tagEventForQuote,
|
|
||||||
getThunkError,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import {
|
|
||||||
PROTECTED,
|
|
||||||
userMembership,
|
|
||||||
INDEXER_RELAYS,
|
|
||||||
NOTIFIER_PUBKEY,
|
|
||||||
NOTIFIER_RELAY,
|
|
||||||
userRoomsByUrl,
|
|
||||||
} from "@app/state"
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
|
|
||||||
export const getPubkeyHints = (pubkey: string) => {
|
|
||||||
const selections = relaySelectionsByPubkey.get().get(pubkey)
|
|
||||||
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
|
|
||||||
const hints = relays.length ? relays : INDEXER_RELAYS
|
|
||||||
|
|
||||||
return hints
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPubkeyPetname = (pubkey: string) => {
|
|
||||||
const profile = profilesByPubkey.get().get(pubkey)
|
|
||||||
const display = displayProfile(profile)
|
|
||||||
|
|
||||||
return display
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
|
|
||||||
if (parent) {
|
|
||||||
const nevent = nip19.neventEncode({
|
|
||||||
id: parent.id,
|
|
||||||
kind: parent.kind,
|
|
||||||
author: parent.pubkey,
|
|
||||||
relays: Router.get().Event(parent).limit(3).getUrls(),
|
|
||||||
})
|
|
||||||
|
|
||||||
tags = [...tags, tagEventForQuote(parent)]
|
|
||||||
content = toNostrURI(nevent) + "\n\n" + content
|
|
||||||
}
|
|
||||||
|
|
||||||
return {content, tags}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log out
|
|
||||||
|
|
||||||
export const logout = async () => {
|
|
||||||
const $pubkey = pubkey.get()
|
|
||||||
|
|
||||||
if ($pubkey) {
|
|
||||||
dropSession($pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
await clearStorage()
|
|
||||||
|
|
||||||
localStorage.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synchronization
|
|
||||||
|
|
||||||
export const broadcastUserData = async (relays: string[]) => {
|
|
||||||
const authors = [pubkey.get()!]
|
|
||||||
const kinds = [RELAYS, INBOX_RELAYS, FOLLOWS, PROFILE]
|
|
||||||
const events = repository.query([{kinds, authors}])
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
if (isSignedEvent(event)) {
|
|
||||||
await publishThunk({event, relays}).result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List updates
|
|
||||||
|
|
||||||
export const addSpaceMembership = async (url: string) => {
|
|
||||||
const list = get(userMembership) || makeList({kind: ROOMS})
|
|
||||||
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
|
|
||||||
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const removeSpaceMembership = async (url: string) => {
|
|
||||||
const list = get(userMembership) || makeList({kind: ROOMS})
|
|
||||||
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
|
|
||||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
|
||||||
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const addRoomMembership = async (url: string, room: string) => {
|
|
||||||
const list = get(userMembership) || makeList({kind: ROOMS})
|
|
||||||
const newTags = [
|
|
||||||
["r", url],
|
|
||||||
["group", room, url],
|
|
||||||
]
|
|
||||||
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
|
|
||||||
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const removeRoomMembership = async (url: string, room: string) => {
|
|
||||||
const list = get(userMembership) || makeList({kind: ROOMS})
|
|
||||||
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
|
|
||||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
|
||||||
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
|
|
||||||
const list = get(userRelaySelections) || makeList({kind: RELAYS})
|
|
||||||
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
|
|
||||||
|
|
||||||
if (read && write) {
|
|
||||||
tags.push(["r", url])
|
|
||||||
} else if (read) {
|
|
||||||
tags.push(["r", url, "read"])
|
|
||||||
} else if (write) {
|
|
||||||
tags.push(["r", url, "write"])
|
|
||||||
}
|
|
||||||
|
|
||||||
return publishThunk({
|
|
||||||
event: makeEvent(list.kind, {tags}),
|
|
||||||
relays: [
|
|
||||||
url,
|
|
||||||
...INDEXER_RELAYS,
|
|
||||||
...Router.get().FromUser().getUrls(),
|
|
||||||
...userRoomsByUrl.get().keys(),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
|
|
||||||
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
|
|
||||||
|
|
||||||
// Only update inbox policies if they already exist or we're adding them
|
|
||||||
if (enabled || getRelaysFromList(list).includes(url)) {
|
|
||||||
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
tags.push(["relay", url])
|
|
||||||
}
|
|
||||||
|
|
||||||
return publishThunk({
|
|
||||||
event: makeEvent(list.kind, {tags}),
|
|
||||||
relays: [
|
|
||||||
...INDEXER_RELAYS,
|
|
||||||
...Router.get().FromUser().getUrls(),
|
|
||||||
...userRoomsByUrl.get().keys(),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relay access
|
|
||||||
|
|
||||||
export const attemptAuth = (url: string) =>
|
|
||||||
Pool.get()
|
|
||||||
.get(url)
|
|
||||||
.auth.attemptAuth(e => signer.get()?.sign(e))
|
|
||||||
|
|
||||||
export const checkRelayAccess = async (url: string, claim = "") => {
|
|
||||||
const socket = Pool.get().get(url)
|
|
||||||
|
|
||||||
await attemptAuth(url)
|
|
||||||
|
|
||||||
const thunk = publishThunk({
|
|
||||||
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
|
|
||||||
relays: [url],
|
|
||||||
})
|
|
||||||
|
|
||||||
const error = await getThunkError(thunk)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const message =
|
|
||||||
socket.auth.details?.replace(/^\w+: /, "") ||
|
|
||||||
error?.replace(/^\w+: /, "") ||
|
|
||||||
"join request rejected"
|
|
||||||
|
|
||||||
// If it's a strict NIP 29 relay don't worry about requesting access
|
|
||||||
// TODO: remove this if relay29 ever gets less strict
|
|
||||||
if (message === "missing group (`h`) tag") return
|
|
||||||
|
|
||||||
// Ignore messages about the relay ignoring ours
|
|
||||||
if (error?.startsWith("mute: ")) return
|
|
||||||
|
|
||||||
// Ignore rejected empty claims
|
|
||||||
if (!claim && error?.includes("invite code")) {
|
|
||||||
return `failed to request access to relay`
|
|
||||||
}
|
|
||||||
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkRelayProfile = async (url: string) => {
|
|
||||||
const relay = await loadRelay(url)
|
|
||||||
|
|
||||||
if (!relay?.profile) {
|
|
||||||
return "Sorry, we weren't able to find that relay."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkRelayConnection = async (url: string) => {
|
|
||||||
const socket = Pool.get().get(url)
|
|
||||||
|
|
||||||
socket.attemptToOpen()
|
|
||||||
|
|
||||||
await poll({
|
|
||||||
signal: AbortSignal.timeout(3000),
|
|
||||||
condition: () => socket.status === SocketStatus.Open,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (socket.status !== SocketStatus.Open) {
|
|
||||||
return `Failed to connect`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkRelayAuth = async (url: string, timeout = 3000) => {
|
|
||||||
const socket = Pool.get().get(url)
|
|
||||||
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
|
|
||||||
|
|
||||||
await attemptAuth(url)
|
|
||||||
|
|
||||||
// Only raise an error if it's not a timeout.
|
|
||||||
// If it is, odds are the problem is with our signer, not the relay
|
|
||||||
if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
|
|
||||||
return `Failed to authenticate (${socket.auth.details})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const attemptRelayAccess = async (url: string, claim = "") => {
|
|
||||||
const checks = [
|
|
||||||
() => checkRelayConnection(url),
|
|
||||||
() => checkRelayAccess(url, claim),
|
|
||||||
() => checkRelayAuth(url),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const check of checks) {
|
|
||||||
const error = await check()
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
|
|
||||||
export const makeDelete = ({event, tags = []}: {event: TrustedEvent; tags?: string[][]}) => {
|
|
||||||
const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
|
|
||||||
const groupTag = getTag("h", event.tags)
|
|
||||||
|
|
||||||
if (groupTag) {
|
|
||||||
thisTags.push(PROTECTED, groupTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return makeEvent(DELETE, {tags: thisTags})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishDelete = ({
|
|
||||||
relays,
|
|
||||||
event,
|
|
||||||
tags = [],
|
|
||||||
}: {
|
|
||||||
relays: string[]
|
|
||||||
event: TrustedEvent
|
|
||||||
tags?: string[][]
|
|
||||||
}) => publishThunk({event: makeDelete({event, tags}), relays})
|
|
||||||
|
|
||||||
export type ReportParams = {
|
|
||||||
event: TrustedEvent
|
|
||||||
content: string
|
|
||||||
reason: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeReport = ({event, reason, content}: ReportParams) => {
|
|
||||||
const tags = [
|
|
||||||
["p", event.pubkey],
|
|
||||||
["e", event.id, reason],
|
|
||||||
]
|
|
||||||
|
|
||||||
return makeEvent(REPORT, {content, tags})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishReport = ({
|
|
||||||
relays,
|
|
||||||
event,
|
|
||||||
reason,
|
|
||||||
content,
|
|
||||||
}: ReportParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makeReport({event, reason, content}), relays})
|
|
||||||
|
|
||||||
export type ReactionParams = {
|
|
||||||
event: TrustedEvent
|
|
||||||
content: string
|
|
||||||
tags?: string[][]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeReaction = ({content, event, tags: paramTags = []}: ReactionParams) => {
|
|
||||||
const tags = [...paramTags, ...tagEventForReaction(event)]
|
|
||||||
|
|
||||||
const groupTag = getTag("h", event.tags)
|
|
||||||
|
|
||||||
if (groupTag) {
|
|
||||||
tags.push(PROTECTED)
|
|
||||||
tags.push(groupTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return makeEvent(REACTION, {content, tags})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makeReaction(params), relays})
|
|
||||||
|
|
||||||
export type CommentParams = {
|
|
||||||
event: TrustedEvent
|
|
||||||
content: string
|
|
||||||
tags?: string[][]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeComment = ({event, content, tags = []}: CommentParams) =>
|
|
||||||
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
|
|
||||||
|
|
||||||
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makeComment(params), relays})
|
|
||||||
|
|
||||||
export type AlertParams = {
|
|
||||||
feed: Feed
|
|
||||||
description: string
|
|
||||||
claims: Record<string, string>
|
|
||||||
email?: {
|
|
||||||
cron: string
|
|
||||||
email: string
|
|
||||||
handler: string[]
|
|
||||||
}
|
|
||||||
web?: {
|
|
||||||
endpoint: string
|
|
||||||
p256dh: string
|
|
||||||
auth: string
|
|
||||||
}
|
|
||||||
ios?: {
|
|
||||||
device_token: string
|
|
||||||
bundle_identifier: string
|
|
||||||
}
|
|
||||||
android?: {
|
|
||||||
device_token: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeAlert = async (params: AlertParams) => {
|
|
||||||
const tags = [
|
|
||||||
["feed", JSON.stringify(params.feed)],
|
|
||||||
["locale", LOCALE],
|
|
||||||
["timezone", TIMEZONE],
|
|
||||||
["description", params.description],
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const [relay, claim] of Object.entries(params.claims)) {
|
|
||||||
tags.push(["claim", relay, claim])
|
|
||||||
}
|
|
||||||
|
|
||||||
let kind: number
|
|
||||||
if (params.email) {
|
|
||||||
kind = ALERT_EMAIL
|
|
||||||
tags.push(...Object.entries(params.email).map(flatten))
|
|
||||||
} else if (params.web) {
|
|
||||||
kind = ALERT_WEB
|
|
||||||
tags.push(...Object.entries(params.web).map(flatten))
|
|
||||||
} else if (params.ios) {
|
|
||||||
kind = ALERT_IOS
|
|
||||||
tags.push(...Object.entries(params.ios).map(flatten))
|
|
||||||
} else if (params.android) {
|
|
||||||
kind = ALERT_ANDROID
|
|
||||||
tags.push(...Object.entries(params.android).map(flatten))
|
|
||||||
} else {
|
|
||||||
throw new Error("Alert has invalid params")
|
|
||||||
}
|
|
||||||
|
|
||||||
return makeEvent(kind, {
|
|
||||||
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
|
|
||||||
tags: [
|
|
||||||
["d", randomId()],
|
|
||||||
["p", NOTIFIER_PUBKEY],
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishAlert = async (params: AlertParams) =>
|
|
||||||
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {onMount} from "svelte"
|
|
||||||
import {preventDefault} from "@lib/html"
|
|
||||||
import {decrypt} from "@welshman/signer"
|
|
||||||
import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib"
|
|
||||||
import {
|
|
||||||
displayRelayUrl,
|
|
||||||
getTagValue,
|
|
||||||
getAddress,
|
|
||||||
THREAD,
|
|
||||||
MESSAGE,
|
|
||||||
EVENT_TIME,
|
|
||||||
COMMENT,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {Filter} from "@welshman/util"
|
|
||||||
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
|
|
||||||
import {pubkey, signer, getThunkError} from "@welshman/app"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
|
||||||
import {
|
|
||||||
alerts,
|
|
||||||
getMembershipUrls,
|
|
||||||
userMembership,
|
|
||||||
NOTIFIER_PUBKEY,
|
|
||||||
NOTIFIER_RELAY,
|
|
||||||
} from "@app/state"
|
|
||||||
import {loadAlertStatuses, requestRelayClaim} from "@app/requests"
|
|
||||||
import {publishAlert, attemptAuth} from "@app/commands"
|
|
||||||
import type {AlertParams} from "@app/commands"
|
|
||||||
import {platform, platformName, canSendPushNotifications, getPushInfo} from "@app/push"
|
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url?: string
|
|
||||||
channel?: string
|
|
||||||
notifyChat?: boolean
|
|
||||||
notifyThreads?: boolean
|
|
||||||
notifyCalendar?: boolean
|
|
||||||
hideSpaceField?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
url = "",
|
|
||||||
channel = "email",
|
|
||||||
notifyChat = true,
|
|
||||||
notifyThreads = true,
|
|
||||||
notifyCalendar = true,
|
|
||||||
hideSpaceField = false,
|
|
||||||
}: Props = $props()
|
|
||||||
|
|
||||||
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
|
|
||||||
const minute = randomInt(0, 59)
|
|
||||||
const hour = (17 - timezoneOffset) % 24
|
|
||||||
const WEEKLY = `0 ${minute} ${hour} * * 1`
|
|
||||||
const DAILY = `0 ${minute} ${hour} * * *`
|
|
||||||
|
|
||||||
let loading = $state(false)
|
|
||||||
let cron = $state(WEEKLY)
|
|
||||||
let claim = $state("")
|
|
||||||
let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "")
|
|
||||||
|
|
||||||
const back = () => history.back()
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (channel === "email" && !email.includes("@")) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "Please provide an email address",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "Please select a space",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!notifyThreads && !notifyCalendar && !notifyChat) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "Please select something to be notified about",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters: Filter[] = []
|
|
||||||
const display: string[] = []
|
|
||||||
|
|
||||||
if (notifyThreads) {
|
|
||||||
display.push("threads")
|
|
||||||
filters.push({kinds: [THREAD]})
|
|
||||||
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notifyCalendar) {
|
|
||||||
display.push("calendar events")
|
|
||||||
filters.push({kinds: [EVENT_TIME]})
|
|
||||||
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notifyChat) {
|
|
||||||
display.push("chat")
|
|
||||||
filters.push({kinds: [MESSAGE]})
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const claims = claim ? {[url]: claim} : {}
|
|
||||||
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url))
|
|
||||||
const description = `for ${displayList(display)} on ${displayRelayUrl(url)}`
|
|
||||||
const params: AlertParams = {feed, claims, description}
|
|
||||||
|
|
||||||
if (channel === "email") {
|
|
||||||
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
|
|
||||||
|
|
||||||
params.description = `${cadence} alert ${description}, sent via email.`
|
|
||||||
params.email = {
|
|
||||||
cron,
|
|
||||||
email,
|
|
||||||
handler: [
|
|
||||||
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
|
|
||||||
"wss://relay.nostr.band/",
|
|
||||||
"web",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
// @ts-ignore
|
|
||||||
params[platform] = await getPushInfo()
|
|
||||||
params.description = `${platformName} push notification ${description}.`
|
|
||||||
} catch (e: any) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: String(e),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we don't do this we'll get an event rejection
|
|
||||||
await attemptAuth(NOTIFIER_RELAY)
|
|
||||||
|
|
||||||
const thunk = await publishAlert(params)
|
|
||||||
const error = await getThunkError(thunk)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: `Failed to send your alert to the notification server (${error}).`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch our new status to make sure it's active
|
|
||||||
const address = getAddress(thunk.event)
|
|
||||||
const statusEvents = await loadAlertStatuses($pubkey!)
|
|
||||||
const statusEvent = statusEvents.find(event => getTagValue("d", event.tags) === address)
|
|
||||||
const statusTags = statusEvent
|
|
||||||
? parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, statusEvent.content))
|
|
||||||
: []
|
|
||||||
const {status = "error", message = "Your alert was not activated"}: Record<string, string> =
|
|
||||||
fromPairs(statusTags)
|
|
||||||
|
|
||||||
if (status === "error") {
|
|
||||||
return pushToast({theme: "error", message})
|
|
||||||
}
|
|
||||||
|
|
||||||
pushToast({message: "Your alert has been successfully created!"})
|
|
||||||
back()
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!canSendPushNotifications()) {
|
|
||||||
channel = "email"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
requestRelayClaim(url).then(code => {
|
|
||||||
if (code) {
|
|
||||||
claim = code
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
|
||||||
<ModalHeader>
|
|
||||||
{#snippet title()}
|
|
||||||
Add an Alert
|
|
||||||
{/snippet}
|
|
||||||
</ModalHeader>
|
|
||||||
{#if canSendPushNotifications()}
|
|
||||||
<FieldInline>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Alert Type*</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<select bind:value={channel} class="select select-bordered">
|
|
||||||
<option value="email">Email Digest</option>
|
|
||||||
<option value="push">Push Notification</option>
|
|
||||||
</select>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
{/if}
|
|
||||||
{#if channel === "email"}
|
|
||||||
<FieldInline>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Email Address*</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
|
||||||
<input placeholder="email@example.com" bind:value={email} />
|
|
||||||
</label>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
<FieldInline>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Frequency*</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<select bind:value={cron} class="select select-bordered">
|
|
||||||
<option value={WEEKLY}>Weekly</option>
|
|
||||||
<option value={DAILY}>Daily</option>
|
|
||||||
</select>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
{/if}
|
|
||||||
{#if !hideSpaceField}
|
|
||||||
<FieldInline>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Space*</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<select bind:value={url} class="select select-bordered">
|
|
||||||
<option value="" disabled selected>Choose a space URL</option>
|
|
||||||
{#each getMembershipUrls($userMembership) as url (url)}
|
|
||||||
<option value={url}>{displayRelayUrl(url)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
{/if}
|
|
||||||
<FieldInline>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Notifications*</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<div class="flex items-center justify-end gap-4">
|
|
||||||
<span class="flex gap-3">
|
|
||||||
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
|
|
||||||
Threads
|
|
||||||
</span>
|
|
||||||
<span class="flex gap-3">
|
|
||||||
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
|
|
||||||
Calendar
|
|
||||||
</span>
|
|
||||||
<span class="flex gap-3">
|
|
||||||
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
|
|
||||||
Chat
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
<FieldInline>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Invite Code</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
|
||||||
<input bind:value={claim} />
|
|
||||||
</label>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet info()}
|
|
||||||
<p>
|
|
||||||
To get notifications from private spaces, please provide an invite code which grants access
|
|
||||||
to the space.
|
|
||||||
</p>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button class="btn btn-link" onclick={back}>
|
|
||||||
<Icon icon="alt-arrow-left" />
|
|
||||||
Go back
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
|
||||||
<Spinner {loading}>Confirm</Spinner>
|
|
||||||
<Icon icon="alt-arrow-right" />
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</form>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
|
||||||
import type {Alert} from "@app/state"
|
|
||||||
import {NOTIFIER_RELAY, NOTIFIER_PUBKEY} from "@app/state"
|
|
||||||
import {publishDelete} from "@app/commands"
|
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
alert: Alert
|
|
||||||
}
|
|
||||||
|
|
||||||
const {alert}: Props = $props()
|
|
||||||
|
|
||||||
const confirm = () => {
|
|
||||||
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY], tags: [["p", NOTIFIER_PUBKEY]]})
|
|
||||||
pushToast({message: "Your alert has been deleted!"})
|
|
||||||
history.back()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Confirm {confirm} message="You'll no longer receive messages for this alert." />
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {parseJson} from "@welshman/lib"
|
|
||||||
import {displayFeeds} from "@welshman/feeds"
|
|
||||||
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import AlertDelete from "@app/components/AlertDelete.svelte"
|
|
||||||
import type {Alert} from "@app/state"
|
|
||||||
import {deriveAlertStatus} from "@app/state"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
alert: Alert
|
|
||||||
}
|
|
||||||
|
|
||||||
const {alert}: Props = $props()
|
|
||||||
|
|
||||||
const status = deriveAlertStatus(getAddress(alert.event))
|
|
||||||
const cron = $derived(getTagValue("cron", alert.tags))
|
|
||||||
const channel = $derived(getTagValue("channel", alert.tags))
|
|
||||||
const feeds = $derived(getTagValues("feed", alert.tags))
|
|
||||||
const description = $derived(
|
|
||||||
getTagValue("description", alert.tags) ||
|
|
||||||
[
|
|
||||||
`${cron?.endsWith("1") ? "Weekly" : "Daily"} alert for events`,
|
|
||||||
displayFeeds(feeds.map(parseJson)),
|
|
||||||
`sent via ${channel}.`,
|
|
||||||
].join(" "),
|
|
||||||
)
|
|
||||||
|
|
||||||
const startDelete = () => pushModal(AlertDelete, {alert})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<Button class="py-1" onclick={startDelete}>
|
|
||||||
<Icon icon="trash-bin-2" />
|
|
||||||
</Button>
|
|
||||||
<div class="flex-inline gap-1">{description}</div>
|
|
||||||
</div>
|
|
||||||
{#if $status}
|
|
||||||
{@const statusText = getTagValue("status", $status.tags) || "error"}
|
|
||||||
{#if statusText === "ok"}
|
|
||||||
<span
|
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
|
|
||||||
data-tip={getTagValue("message", $status.tags)}>
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
{:else if statusText === "pending"}
|
|
||||||
<span
|
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
|
|
||||||
data-tip={getTagValue("message", $status.tags)}>
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
|
|
||||||
data-tip={getTagValue("message", $status.tags)}>
|
|
||||||
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
|
|
||||||
data-tip="The notification server did not respond to your request.">
|
|
||||||
Inactive
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {getTagValue} from "@welshman/util"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import AlertAdd from "@app/components/AlertAdd.svelte"
|
|
||||||
import AlertItem from "@app/components/AlertItem.svelte"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
import {alerts} from "@app/state"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url?: string
|
|
||||||
channel?: string
|
|
||||||
hideSpaceField?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
|
|
||||||
|
|
||||||
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
|
|
||||||
|
|
||||||
const filteredAlerts = $derived(
|
|
||||||
url ? $alerts.filter(a => getTagValue("feed", a.tags)?.includes(url)) : $alerts,
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<strong class="flex items-center gap-3">
|
|
||||||
<Icon icon="inbox" />
|
|
||||||
Alerts
|
|
||||||
</strong>
|
|
||||||
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
|
|
||||||
<Icon icon="add-circle" />
|
|
||||||
Add Alert
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
{#each filteredAlerts as alert (alert.event.id)}
|
|
||||||
<AlertItem {alert} />
|
|
||||||
{:else}
|
|
||||||
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,36 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {page} from "$app/stores"
|
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey} from "@welshman/app"
|
||||||
|
import Dialog from "@lib/components/Dialog.svelte"
|
||||||
import Landing from "@app/components/Landing.svelte"
|
import Landing from "@app/components/Landing.svelte"
|
||||||
import Toast from "@app/components/Toast.svelte"
|
import Toast from "@app/components/Toast.svelte"
|
||||||
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
||||||
import EmailConfirm from "@app/components/EmailConfirm.svelte"
|
import {modal} from "@app/util/modal"
|
||||||
import PasswordReset from "@app/components/PasswordReset.svelte"
|
|
||||||
import {BURROW_URL} from "@app/state"
|
|
||||||
import {modals, pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet
|
children: Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
const {children}: Props = $props()
|
const {children}: Props = $props()
|
||||||
|
|
||||||
if (BURROW_URL && !$pubkey) {
|
|
||||||
if ($page.url.pathname === "/confirm-email") {
|
|
||||||
pushModal(EmailConfirm, {
|
|
||||||
email: $page.url.searchParams.get("email"),
|
|
||||||
confirm_token: $page.url.searchParams.get("confirm_token"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($page.url.pathname === "/reset-password") {
|
|
||||||
pushModal(PasswordReset, {
|
|
||||||
email: $page.url.searchParams.get("email"),
|
|
||||||
reset_token: $page.url.searchParams.get("reset_token"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
@@ -38,8 +19,8 @@
|
|||||||
<PrimaryNav>
|
<PrimaryNav>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</PrimaryNav>
|
</PrimaryNav>
|
||||||
{:else if !$modals[$page.url.hash.slice(1)]}
|
{:else if !$modal}
|
||||||
<Landing />
|
<Dialog noEscape children={{component: Landing, props: {}}} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|||||||
@@ -1,79 +1,25 @@
|
|||||||
<script module lang="ts">
|
|
||||||
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
|
||||||
import {Nip46Broker, makeSecret} from "@welshman/signer"
|
|
||||||
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
|
|
||||||
|
|
||||||
export class BunkerConnectController {
|
|
||||||
url = $state("")
|
|
||||||
bunker = $state("")
|
|
||||||
loading = $state(false)
|
|
||||||
clientSecret = makeSecret()
|
|
||||||
abortController = new AbortController()
|
|
||||||
broker = new Nip46Broker({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
|
|
||||||
onNostrConnect: (response: Nip46ResponseWithResult) => void
|
|
||||||
|
|
||||||
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
|
|
||||||
this.onNostrConnect = onNostrConnect
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
|
||||||
this.url = await this.broker.makeNostrconnectUrl({
|
|
||||||
perms: NIP46_PERMS,
|
|
||||||
url: PLATFORM_URL,
|
|
||||||
name: PLATFORM_NAME,
|
|
||||||
image: PLATFORM_LOGO,
|
|
||||||
})
|
|
||||||
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = await this.broker.waitForNostrconnect(this.url, this.abortController.signal)
|
|
||||||
} catch (errorResponse: any) {
|
|
||||||
if (errorResponse?.error) {
|
|
||||||
pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: `Received error from signer: ${errorResponse.error}`,
|
|
||||||
})
|
|
||||||
} else if (errorResponse) {
|
|
||||||
console.error(errorResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
this.loading = true
|
|
||||||
this.onNostrConnect(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.broker.cleanup()
|
|
||||||
this.abortController.abort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, onDestroy} from "svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import {slideAndFade} from "@lib/transition"
|
|
||||||
import QRCode from "@app/components/QRCode.svelte"
|
import QRCode from "@app/components/QRCode.svelte"
|
||||||
import {pushToast} from "@app/toast"
|
import type {Nip46Controller} from "@app/util/nip46"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
controller: BunkerConnectController
|
controller: Nip46Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
const {controller}: Props = $props()
|
const {controller}: Props = $props()
|
||||||
|
const {url, loading} = controller
|
||||||
onMount(() => {
|
|
||||||
controller.start()
|
|
||||||
})
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
controller.stop()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if controller.url}
|
{#if $url}
|
||||||
<div class="flex justify-center" out:slideAndFade>
|
{#if $loading}
|
||||||
<QRCode code={controller.url} />
|
<div class="flex justify-center">
|
||||||
</div>
|
<Spinner loading>Establishing connection...</Spinner>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<QRCode code={$url} />
|
||||||
|
<p class="text-sm opacity-75">Scan with your signer to log in, or click to copy.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {pushModal} from "@app/modal"
|
import {debounce} from "throttle-debounce"
|
||||||
import InfoBunker from "@app/components/InfoBunker.svelte"
|
import Scanner from "@lib/components/Scanner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import CpuBolt from "@assets/icons/cpu-bolt.svg?dataurl"
|
||||||
|
import QrCode from "@assets/icons/qr-code.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import InfoBunker from "@app/components/InfoBunker.svelte"
|
||||||
|
import type {Nip46Controller} from "@app/util/nip46"
|
||||||
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bunker: string
|
controller: Nip46Controller
|
||||||
loading: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {loading, bunker = $bindable("")}: Props = $props()
|
const {controller}: Props = $props()
|
||||||
|
const {loading, bunker} = controller
|
||||||
|
|
||||||
|
const toggleScanner = () => {
|
||||||
|
showScanner = !showScanner
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScan = debounce(1000, async (data: string) => {
|
||||||
|
showScanner = false
|
||||||
|
$bunker = data
|
||||||
|
})
|
||||||
|
|
||||||
|
let showScanner = $state(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
@@ -19,8 +35,11 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<Icon icon="cpu" />
|
<Icon icon={CpuBolt} />
|
||||||
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
|
<input disabled={$loading} bind:value={$bunker} class="grow" placeholder="bunker://" />
|
||||||
|
<Button onclick={toggleScanner}>
|
||||||
|
<Icon icon={QrCode} />
|
||||||
|
</Button>
|
||||||
</label>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
@@ -30,3 +49,6 @@
|
|||||||
</p>
|
</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
|
{#if showScanner}
|
||||||
|
<Scanner onscan={onScan} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,55 +1,64 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
|
import {getTagValue, getAddress} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey} from "@welshman/app"
|
||||||
|
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
||||||
import {publishDelete, publishReaction} from "@app/commands"
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
import {makeCalendarPath} from "@app/routes"
|
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
const {
|
type Props = {
|
||||||
url,
|
|
||||||
event,
|
|
||||||
showActivity = false,
|
|
||||||
}: {
|
|
||||||
url: string
|
url: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
showRoom?: boolean
|
||||||
showActivity?: boolean
|
showActivity?: boolean
|
||||||
} = $props()
|
}
|
||||||
|
|
||||||
const path = makeCalendarPath(url, event.id)
|
const {url, event, showRoom, showActivity}: Props = $props()
|
||||||
|
|
||||||
|
const h = getTagValue("h", event.tags)
|
||||||
|
const path = makeCalendarPath(url, getAddress(event))
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
||||||
|
|
||||||
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
|
const deleteReaction = async (event: TrustedEvent) =>
|
||||||
|
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||||
|
|
||||||
const createReaction = (template: EventContent) =>
|
const createReaction = async (template: EventContent) =>
|
||||||
publishReaction({...template, event, relays: [url]})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
{#if h && showRoom}
|
||||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
<ThunkStatusOrDeleted {event} />
|
Posted in #<RoomName {h} {url} />
|
||||||
{#if showActivity}
|
</Link>
|
||||||
<EventActivity {url} {path} {event} />
|
{/if}
|
||||||
{/if}
|
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||||
<EventActions {url} {event} noun="Event">
|
<ThunkStatusOrDeleted {event} />
|
||||||
{#snippet customActions()}
|
{#if showActivity}
|
||||||
{#if event.pubkey === $pubkey}
|
<EventActivity {url} {path} {event} />
|
||||||
<li>
|
{/if}
|
||||||
<Button onclick={editEvent}>
|
<EventActions {url} {event} noun="Event">
|
||||||
<Icon size={4} icon="pen" />
|
{#snippet customActions()}
|
||||||
Edit Event
|
{#if event.pubkey === $pubkey}
|
||||||
</Button>
|
<li>
|
||||||
</li>
|
<Button onclick={editEvent}>
|
||||||
{/if}
|
<Icon size={4} icon={Pen2} />
|
||||||
{/snippet}
|
Edit Event
|
||||||
</EventActions>
|
</Button>
|
||||||
</div>
|
</li>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</EventActions>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
h?: string
|
||||||
|
shareToChat?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url}: Props = $props()
|
const {url, h, shareToChat = false}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarEventForm {url}>
|
<CalendarEventForm {url} {h} {shareToChat}>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
{#snippet title()}
|
<ModalTitle>Create an Event</ModalTitle>
|
||||||
<div>Create an Event</div>
|
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
|
||||||
{/snippet}
|
|
||||||
{#snippet info()}
|
|
||||||
<div>Invite other group members to events online or in real life.</div>
|
|
||||||
{/snippet}
|
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarEventForm>
|
</CalendarEventForm>
|
||||||
|
|||||||
@@ -8,13 +8,16 @@
|
|||||||
|
|
||||||
const {event}: Props = $props()
|
const {event}: Props = $props()
|
||||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||||
const startDate = $derived(secondsToDate(parseInt(meta.start)))
|
const start = $derived(parseInt(meta.start))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
{#if !isNaN(start)}
|
||||||
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
|
{@const startDate = secondsToDate(start)}
|
||||||
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
|
<div
|
||||||
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
|
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
|
||||||
<span class="text-xs opacity-75"
|
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
|
||||||
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
|
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
|
||||||
</div>
|
<span class="text-xs opacity-75"
|
||||||
|
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {getTagValue} from "@welshman/util"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -24,12 +26,8 @@
|
|||||||
<CalendarEventForm {url} {initialValues}>
|
<CalendarEventForm {url} {initialValues}>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
{#snippet title()}
|
<ModalTitle>Edit this Event</ModalTitle>
|
||||||
<div>Edit this Event</div>
|
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
|
||||||
{/snippet}
|
|
||||||
{#snippet info()}
|
|
||||||
<div>Invite other group members to events online or in real life.</div>
|
|
||||||
{/snippet}
|
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarEventForm>
|
</CalendarEventForm>
|
||||||
|
|||||||
@@ -3,34 +3,53 @@
|
|||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {randomId, HOUR} from "@welshman/lib"
|
import {randomId, HOUR} from "@welshman/lib"
|
||||||
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||||
import {publishThunk} from "@welshman/app"
|
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {daysBetween} from "@lib/util"
|
import {daysBetween} from "@lib/util"
|
||||||
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
|
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {PROTECTED} from "@app/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {pushToast} from "@app/toast"
|
import {DraftKey} from "@app/util/drafts"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
d: string
|
||||||
|
title: string
|
||||||
|
content: string | object
|
||||||
|
location: string
|
||||||
|
start?: number
|
||||||
|
end?: number
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
h?: string
|
||||||
|
shareToChat?: boolean
|
||||||
header: Snippet
|
header: Snippet
|
||||||
initialValues?: {
|
initialValues?: Values
|
||||||
d: string
|
|
||||||
title: string
|
|
||||||
content: string
|
|
||||||
location: string
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, header, initialValues}: Props = $props()
|
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = draftKey.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
@@ -39,7 +58,7 @@
|
|||||||
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if ($uploading) return
|
if ($uploading || loading) return
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
@@ -63,33 +82,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
const event = makeEvent(EVENT_TIME, {
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
content: ed.getText({blockSeparator: "\n"}).trim(),
|
const tags = [
|
||||||
tags: [
|
["d", d],
|
||||||
["d", initialValues?.d || randomId()],
|
["title", title],
|
||||||
["title", title],
|
["location", location],
|
||||||
["location", location || ""],
|
["start", start.toString()],
|
||||||
["start", start.toString()],
|
["end", end.toString()],
|
||||||
["end", end.toString()],
|
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
...ed.storage.nostr.getEditorTags(),
|
||||||
...ed.storage.nostr.getEditorTags(),
|
]
|
||||||
PROTECTED,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
pushToast({message: "Your event has been saved!"})
|
loading = true
|
||||||
publishThunk({event, relays: [url]})
|
|
||||||
history.back()
|
try {
|
||||||
|
const protect = await shouldProtect
|
||||||
|
|
||||||
|
if (protect) {
|
||||||
|
tags.push(PROTECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
tags.push(["h", h])
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||||
|
const calendarThunk = publishThunk({event, relays: [url]})
|
||||||
|
const error = await waitForThunkError(calendarThunk)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return pushToast({theme: "error", message: error})
|
||||||
|
}
|
||||||
|
|
||||||
|
draftKey.clear()
|
||||||
|
history.back()
|
||||||
|
|
||||||
|
if (shareToChat) {
|
||||||
|
publishRoomQuote({url, h, parent: calendarThunk.event, protect})
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({message: "Your event has been saved!"})
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = initialValues?.content || ""
|
let loading = $state(false)
|
||||||
const editor = makeEditor({url, submit, uploading, content})
|
|
||||||
|
|
||||||
let title = $state(initialValues?.title || "")
|
const d = $state(initialValues?.d ?? randomId())
|
||||||
let location = $state(initialValues?.location || "")
|
let title = $state(initialValues?.title ?? "")
|
||||||
|
let location = $state(initialValues?.location ?? "")
|
||||||
let start: number | undefined = $state(initialValues?.start)
|
let start: number | undefined = $state(initialValues?.start)
|
||||||
let end: number | undefined = $state(initialValues?.end)
|
let end: number | undefined = $state(initialValues?.end)
|
||||||
let endDirty = Boolean(initialValues?.end)
|
let endDirty = $state(Boolean(initialValues?.end))
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({url, submit, uploading, onChange, content})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({d, title, location, start, end, content})
|
||||||
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!endDirty && start) {
|
if (!endDirty && start) {
|
||||||
@@ -100,71 +156,78 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form novalidate class="column gap-4" onsubmit={preventDefault(submit)}>
|
<Modal tag="form" novalidate onsubmit={preventDefault(submit)}>
|
||||||
{@render header()}
|
<ModalBody>
|
||||||
<Field>
|
{@render header()}
|
||||||
{#snippet label()}
|
<Field>
|
||||||
<p>Title*</p>
|
{#snippet label()}
|
||||||
{/snippet}
|
<p>Title*</p>
|
||||||
{#snippet input()}
|
{/snippet}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
{#snippet input()}
|
||||||
<input bind:value={title} class="grow" type="text" />
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
</label>
|
<input bind:value={title} class="grow" type="text" />
|
||||||
{/snippet}
|
</label>
|
||||||
</Field>
|
{/snippet}
|
||||||
<Field>
|
</Field>
|
||||||
{#snippet label()}
|
<Field>
|
||||||
<p>Summary</p>
|
{#snippet label()}
|
||||||
{/snippet}
|
<p>Summary</p>
|
||||||
{#snippet input()}
|
{/snippet}
|
||||||
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
{#snippet input()}
|
||||||
<div class="input-editor flex-grow overflow-hidden">
|
<div
|
||||||
<EditorContent {editor} />
|
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
||||||
|
<div class="input-editor grow overflow-hidden">
|
||||||
|
<EditorContent {editor} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
data-tip="Add an image"
|
||||||
|
class="center btn tooltip"
|
||||||
|
onclick={selectFiles}
|
||||||
|
disabled={loading}>
|
||||||
|
{#if $uploading}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Icon icon={GallerySend} />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
|
{/snippet}
|
||||||
{#if $uploading}
|
</Field>
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<Field>
|
||||||
{:else}
|
{#snippet label()}
|
||||||
<Icon icon="gallery-send" />
|
Start*
|
||||||
{/if}
|
{/snippet}
|
||||||
</Button>
|
{#snippet input()}
|
||||||
</div>
|
<DateTimeInput bind:value={start} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
Start*
|
End*
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<DateTimeInput bind:value={start} />
|
<DateTimeInput bind:value={end} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
End*
|
<p>Location (optional)</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<DateTimeInput bind:value={end} />
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
{/snippet}
|
<Icon icon={MapPoint} />
|
||||||
</Field>
|
<input bind:value={location} class="grow" type="text" />
|
||||||
<Field>
|
</label>
|
||||||
{#snippet label()}
|
{/snippet}
|
||||||
<p>Location (optional)</p>
|
</Field>
|
||||||
{/snippet}
|
</ModalBody>
|
||||||
{#snippet input()}
|
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
|
||||||
<Icon icon="map-point" />
|
|
||||||
<input bind:value={location} class="grow" type="text" />
|
|
||||||
</label>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
|
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
|
||||||
<Spinner loading={$uploading}>Save Event</Spinner>
|
<Spinner loading={$uploading || loading}>Save Event</Spinner>
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</Modal>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
formatTimestampAsTime,
|
formatTimestampAsTime,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -16,18 +17,22 @@
|
|||||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||||
const start = $derived(parseInt(meta.start))
|
const start = $derived(parseInt(meta.start))
|
||||||
const end = $derived(parseInt(meta.end))
|
const end = $derived(parseInt(meta.end))
|
||||||
const startDateDisplay = $derived(formatTimestampAsDate(start))
|
|
||||||
const endDateDisplay = $derived(formatTimestampAsDate(end))
|
|
||||||
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
<div class="flex flex-col justify-between gap-1">
|
||||||
<p class="text-xl">{meta.title || meta.name}</p>
|
<p class="text-lg">{meta.title || meta.name}</p>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
{#if !isNaN(start) && !isNaN(end)}
|
||||||
<Icon icon="clock-circle" size={4} />
|
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||||
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
|
{@const endDateDisplay = formatTimestampAsDate(end)}
|
||||||
{formatTimestampAsTime(start)} — {isSingleDay
|
{@const isSingleDay = startDateDisplay === endDateDisplay}
|
||||||
? formatTimestampAsTime(end)
|
<div class="flex flex-wrap gap-2 text-xs">
|
||||||
: formatTimestamp(end)}
|
<div class="flex items-center gap-2">
|
||||||
</div>
|
<Icon icon={ClockCircle} size={4} />
|
||||||
|
{formatTimestampAsDate(start)}
|
||||||
|
</div>
|
||||||
|
{formatTimestampAsTime(start)} — {isSingleDay
|
||||||
|
? formatTimestampAsTime(end)
|
||||||
|
: formatTimestamp(end)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue, getAddress} from "@welshman/util"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
||||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
import {makeCalendarPath} from "@app/routes"
|
import RoomLink from "@app/components/RoomLink.svelte"
|
||||||
|
import {makeCalendarPath} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -12,13 +14,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const h = getTagValue("h", event.tags)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
|
<Link
|
||||||
|
class="cv col-3 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||||
|
href={makeCalendarPath(url, getAddress(event))}>
|
||||||
<CalendarEventHeader {event} />
|
<CalendarEventHeader {event} />
|
||||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
|
{#if h}
|
||||||
|
in <RoomLink {url} {h} />
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<CalendarEventActions showActivity {url} {event} />
|
<CalendarEventActions showActivity {url} {event} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {fromPairs} from "@welshman/lib"
|
import {fromPairs} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
|
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
|
||||||
@@ -15,13 +17,13 @@
|
|||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<Icon icon="user-circle" size={4} />
|
<Icon icon={UserCircle} size={4} />
|
||||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
</span>
|
</span>
|
||||||
{#if meta.location}
|
{#if meta.location}
|
||||||
<span class="flex items-start gap-1">
|
<span class="flex items-start gap-1">
|
||||||
<Icon icon="map-point" class="mt-[2px]" size={4} />
|
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||||
<span class="break-words">{meta.location}</span>
|
<span class="wrap-break-word">{meta.location}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {writable} from "svelte/store"
|
|
||||||
import type {EventContent} from "@welshman/util"
|
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
|
||||||
import {makeEditor} from "@app/editor"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url?: string
|
|
||||||
onSubmit: (event: EventContent) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const {onSubmit, url}: Props = $props()
|
|
||||||
|
|
||||||
const autofocus = !isMobile
|
|
||||||
|
|
||||||
const uploading = writable(false)
|
|
||||||
|
|
||||||
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
|
||||||
|
|
||||||
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if ($uploading) return
|
|
||||||
|
|
||||||
const ed = await editor
|
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
|
||||||
const tags = ed.storage.nostr.getEditorTags()
|
|
||||||
|
|
||||||
if (!content) return
|
|
||||||
|
|
||||||
onSubmit({content, tags})
|
|
||||||
|
|
||||||
ed.chain().clearContent().run()
|
|
||||||
}
|
|
||||||
|
|
||||||
const editor = makeEditor({url, autofocus, submit, uploading, aggressive: true})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
|
||||||
<Button
|
|
||||||
data-tip="Add an image"
|
|
||||||
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
|
||||||
disabled={$uploading}
|
|
||||||
onclick={uploadFiles}>
|
|
||||||
{#if $uploading}
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
{:else}
|
|
||||||
<Icon icon="gallery-send" />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
<div class="chat-editor flex-grow overflow-hidden">
|
|
||||||
<EditorContent {editor} />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
|
||||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
|
||||||
disabled={$uploading}
|
|
||||||
onclick={submit}>
|
|
||||||
<Icon icon="plain" />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
|
||||||
import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
|
|
||||||
import {isMobile} from "@lib/html"
|
|
||||||
import TapTarget from "@lib/components/TapTarget.svelte"
|
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Content from "@app/components/Content.svelte"
|
|
||||||
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
|
||||||
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
|
|
||||||
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
|
|
||||||
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
|
|
||||||
import {colors} from "@app/state"
|
|
||||||
import {publishDelete, publishReaction} from "@app/commands"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
url: string
|
|
||||||
event: TrustedEvent
|
|
||||||
replyTo?: (event: TrustedEvent) => void
|
|
||||||
showPubkey?: boolean
|
|
||||||
inert?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
|
|
||||||
|
|
||||||
const thunk = $thunks[event.id]
|
|
||||||
const today = formatTimestampAsDate(now())
|
|
||||||
const profile = deriveProfile(event.pubkey, [url])
|
|
||||||
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
|
||||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
|
||||||
|
|
||||||
const reply = () => replyTo!(event)
|
|
||||||
|
|
||||||
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
|
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
|
||||||
|
|
||||||
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
|
|
||||||
|
|
||||||
const createReaction = (template: EventContent) =>
|
|
||||||
publishReaction({...template, event, relays: [url]})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TapTarget
|
|
||||||
data-event={event.id}
|
|
||||||
onTap={inert ? null : onTap}
|
|
||||||
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
|
|
||||||
<div class="flex w-full gap-3 overflow-auto">
|
|
||||||
{#if showPubkey}
|
|
||||||
<Button onclick={openProfile} class="flex items-start">
|
|
||||||
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={8} />
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<div class="w-8 min-w-8 max-w-8"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="min-w-0 flex-grow pr-1">
|
|
||||||
{#if showPubkey}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
|
||||||
{$profileDisplay}
|
|
||||||
</Button>
|
|
||||||
<span class="text-xs opacity-50">
|
|
||||||
{#if formatTimestampAsDate(event.created_at) === today}
|
|
||||||
Today
|
|
||||||
{:else}
|
|
||||||
{formatTimestampAsDate(event.created_at)}
|
|
||||||
{/if}
|
|
||||||
at {formatTimestampAsTime(event.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="text-sm">
|
|
||||||
<Content minimalQuote {event} {url} />
|
|
||||||
{#if thunk}
|
|
||||||
<ThunkStatus {thunk} class="mt-2" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-2 ml-10 mt-1">
|
|
||||||
<ReactionSummary
|
|
||||||
{url}
|
|
||||||
{event}
|
|
||||||
{deleteReaction}
|
|
||||||
{createReaction}
|
|
||||||
reactionClass="tooltip-right" />
|
|
||||||
</div>
|
|
||||||
{#if !isMobile}
|
|
||||||
<button
|
|
||||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
|
||||||
class:group-hover:opacity-100={!isMobile}>
|
|
||||||
<ChannelMessageEmojiButton {url} {event} />
|
|
||||||
{#if replyTo}
|
|
||||||
<Button class="btn join-item btn-xs" onclick={reply}>
|
|
||||||
<Icon icon="reply" size={4} />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
<ChannelMessageMenuButton {url} {event} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</TapTarget>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import {publishReaction} from "@app/commands"
|
|
||||||
|
|
||||||
const {url, event} = $props()
|
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) =>
|
|
||||||
publishReaction({event, relays: [url], content: emoji.unicode})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<EmojiButton {onEmoji} class="btn join-item btn-xs">
|
|
||||||
<Icon icon="smile-circle" size={4} />
|
|
||||||
</EmojiButton>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {pubkey} from "@welshman/app"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
|
||||||
import EventReport from "@app/components/EventReport.svelte"
|
|
||||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
const {url, event, onClick} = $props()
|
|
||||||
|
|
||||||
const report = () => {
|
|
||||||
onClick()
|
|
||||||
pushModal(EventReport, {url, event})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showInfo = () => {
|
|
||||||
onClick()
|
|
||||||
pushModal(EventInfo, {url, event})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showDelete = () => {
|
|
||||||
onClick()
|
|
||||||
pushModal(EventDeleteConfirm, {url, event})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl">
|
|
||||||
<li>
|
|
||||||
<Button onclick={showInfo}>
|
|
||||||
<Icon size={4} icon="code-2" />
|
|
||||||
Message Details
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{#if event.pubkey === $pubkey}
|
|
||||||
<li>
|
|
||||||
<Button onclick={showDelete} class="text-error">
|
|
||||||
<Icon size={4} icon="trash-bin-2" />
|
|
||||||
Delete Message
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{:else}
|
|
||||||
<li>
|
|
||||||
<Button class="text-error" onclick={report}>
|
|
||||||
<Icon size={4} icon="danger" />
|
|
||||||
Report Content
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||