Compare commits
938 Commits
ui-overhaul
...
v2.8.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 42ac269a56 | |||
| caa8e70703 | |||
| 8f53e3e53b | |||
| 5d4d0825c6 | |||
| b9b7351361 | |||
| 6dcae6385a | |||
| 13386bf0fd | |||
| 2ae2a3da18 | |||
| 1c06e070cd | |||
| f0c3ff1a80 | |||
| 13a0bb3e3a | |||
| 646ed9777f | |||
| 437613641a | |||
| d0836328a4 | |||
| 123f53e7a6 | |||
| 977fd000ea | |||
| 5132141aa2 | |||
| b6dc57eb85 | |||
| 016a7b4a7d | |||
| 7ae63883e9 | |||
| d4cf4ba0d8 | |||
| 399dc53395 | |||
| 699e505fb5 | |||
| 20839f4de3 | |||
| 4e9da2d168 | |||
| 32b477bd01 | |||
| 564459e12d | |||
| c97d0723a6 | |||
| 53da626461 | |||
| c79699ca71 | |||
| e58c031a85 | |||
| bc80dba826 | |||
| 611f97488e | |||
| a948725245 | |||
| dde9865284 | |||
| 3d825aef04 | |||
| 575603554b | |||
| dfb0a52603 | |||
| 545e6cf4be | |||
| eb978d651c | |||
| 7a52631eb2 | |||
| d48094ff68 | |||
| 247fbefa9b | |||
| 4a3c5df519 | |||
| 74478ee8ac | |||
| da94609855 | |||
| 8b90ef90f7 | |||
| 49f0ec2765 | |||
| 72c2170139 | |||
| a0082cbbcd | |||
| a8561f46f9 | |||
| 2c248f8269 | |||
| b8749f7064 | |||
| f800d55451 | |||
| ee8414f694 | |||
| 23ac55af6b | |||
| 2ef0642f6d | |||
| 18aacad290 | |||
| e82f0146d2 | |||
| 973defcd28 | |||
| 5bbd86ea90 | |||
| 65481d1280 | |||
| 3d4b40188e | |||
| a7f28e3963 | |||
| d2b6785ca7 | |||
| 2bab7ebe6e | |||
| e6fc7931b6 | |||
| c845f7286b | |||
| f5cdbb6f3a | |||
| b0759402cf | |||
| ef9c2eff89 | |||
| 2cde8fe1f8 | |||
| 9e26bb8209 | |||
| 7c14115119 | |||
| 12bc721952 | |||
| 6c5205cc75 | |||
| 737b197aa8 | |||
| 2cf3db0a51 | |||
| c54008cd3d | |||
| 7f16678acc | |||
| e77876ed16 | |||
| 03b68c3a24 | |||
| 2ab45a27d5 | |||
| e40f32a54f | |||
| c53e476dee | |||
| 3a06dcd4cb | |||
| d7144200fb | |||
| b5cb884004 | |||
| 0800b854ae | |||
| 3a98e38f7b | |||
| e198e8d572 | |||
| cf6364a84b | |||
| 34cae4c9ad | |||
| 0c686a2091 | |||
| d07bc64032 | |||
| e8acf45656 | |||
| 3c28e2b789 | |||
| dc43f723fb | |||
| c7ed31305d | |||
| 441eea160f | |||
| 4f056dfac0 | |||
| f16d5ea334 | |||
| ef8e6f9564 | |||
| 40f3179a63 | |||
| 3b35b084fd | |||
| 0ade19c51e | |||
| 81f3c9e755 | |||
| 657c0e43e3 | |||
| 5a72cf1fd0 | |||
| b3163ea2c9 | |||
| 1f545e7361 | |||
| 1b21edef19 | |||
| 2ba19fc135 | |||
| 934495a7d3 | |||
| a2a4c8b2a7 | |||
| c560bd8acd | |||
| 21907014e0 | |||
| 0b77980fc7 | |||
| 3bab0ef3e0 | |||
| cb52920259 | |||
| 6e4eff602a | |||
| 337d18951a | |||
| 31154f382d | |||
| 236e6aa211 | |||
| 8c684aeef2 | |||
| ab59960233 | |||
| 3565ebf098 | |||
| 0dcc2f2b93 | |||
| 4cbc9f64c1 | |||
| 7ccff2fbad | |||
| 83554c726d | |||
| 3adaf9709f | |||
| eebb6bf424 | |||
| 51d3acd076 | |||
| 58bcd56787 | |||
| 2b00cf9d7b | |||
| 2bfe712e2c | |||
| 30258d8ad1 | |||
| f8b9fdf8b9 | |||
| 7761d01c79 | |||
| 28d0f1ab2c | |||
| eb836e0eea | |||
| 7927b9806b | |||
| 6607066961 | |||
| a1ee51c29a | |||
| 10f128fb34 | |||
| 239ec43fbd | |||
| f390a88f29 | |||
| 33f9975262 | |||
| e5ac01f8a0 | |||
| 820404bed3 | |||
| 43fa17a7f8 | |||
| 99b4b2a5c7 | |||
| d9c69fb961 | |||
| 6d69676394 | |||
| 822446b3a9 | |||
| 92608f1471 | |||
| ea6aeda368 | |||
| 50b408cf9e | |||
| 2262fccc8e | |||
| bab370ae87 | |||
| e0917733a7 | |||
| c10434b336 | |||
| beb0665a30 | |||
| 5e46806bb5 | |||
| 9ed0237da8 | |||
| bae49e6123 | |||
| 843fb29f26 | |||
| 687fc9cb7d | |||
| 56dca6e9a0 | |||
| 652980b448 | |||
| f55325042a | |||
| b2f6f372f3 | |||
| 9465eb2215 | |||
| 01d98fa7bb | |||
| dcbc2737be | |||
| 99822afb82 | |||
| 324cdda5b9 | |||
| b29635762c | |||
| 7a6ef7a58b | |||
| 7f91afdc66 | |||
| 20abad9ee5 | |||
| 655fe98a46 | |||
| 9cea09b407 | |||
| 7a5164d6fc | |||
| 861281cfe7 | |||
| 2745732731 | |||
| 4e1854a9d9 | |||
| ffb1677fa2 | |||
| 31d76a8c4b | |||
| c69275e794 | |||
| f79046694a | |||
| 1839c2e697 | |||
| 400671529f | |||
| cc9ff7676a | |||
| c1de844922 | |||
| 7db479bb73 | |||
| d4670119d5 | |||
| 6e3bc2d3d1 | |||
| 92d003eac0 | |||
| f329915e88 | |||
| f173b975b7 | |||
| 00f936fcd8 | |||
| 151552868b | |||
| cb9231d135 | |||
| 13432b4865 | |||
| d4143ec47b | |||
| be84d96d5f | |||
| 0b7347210f | |||
| 6c175870cb | |||
| b3290b2234 | |||
| d855d71254 | |||
| 5fa96db1c3 | |||
| aefa5bc996 | |||
| c5909d3740 | |||
| 3ae7775a16 | |||
| bb29af595f | |||
| cb11512e10 | |||
| 7506e7a10c | |||
| 8b24196f2e | |||
| 6a1dc6a8d7 | |||
| b9c8904d6f | |||
| bf3e80d444 | |||
| c5c3f5d63c | |||
| 130d5d09c6 | |||
| d9357e624f | |||
| 0cfad7cbaa | |||
| 86e4fa6e24 | |||
| baaf586ea5 | |||
| 58112adfc6 | |||
| 57570f8037 | |||
| 609f9f20b0 | |||
| e6dd3a04b8 | |||
| eee74aa9bb | |||
| d80e1b0a70 | |||
| 0a643de87f | |||
| 8cc6d01b16 | |||
| 85632711c6 | |||
| 8574350778 | |||
| 2870d7a641 | |||
| 2424c96fb9 | |||
| 908585538a | |||
| d31397f60b | |||
| a6ba446e42 | |||
| 4c043eae55 | |||
| b8b7f638ee | |||
| a98384ab32 | |||
| 786ce799a1 | |||
| e33f306a64 | |||
| b2f8311d5e | |||
| b4696338aa | |||
| 008f6604a8 | |||
| 52f6ff9f9f | |||
| 85f3e5fd79 | |||
| 0972e3994b | |||
| 42edaa312e | |||
| b0130a66ea | |||
| 921d642f86 | |||
| 37b6a59fa1 | |||
| 11e143ce0e | |||
| 556a0f96de | |||
| 33d8b8cca8 | |||
| 50de894392 | |||
| 842dd82c32 | |||
| 771338014f | |||
| 0dbc62dd17 | |||
| 9708ec136c | |||
| ce6163662a | |||
| 7e3de000a2 | |||
| 0cb05cc52e | |||
| d2ec2a0f91 | |||
| ceb1995b6a | |||
| 34c9ab7492 | |||
| 4de49d48d9 | |||
| 4db0195079 | |||
| eb158967f9 | |||
| b851f42e6c | |||
| 057ac79179 | |||
| 9a3f1cac73 | |||
| bc3271ec65 | |||
| 4f59006fa4 | |||
| a83df0f56f | |||
| abd6cd95b1 | |||
| 39f288d819 | |||
| 96cf2bcbe0 | |||
| 2742e3d0da | |||
| 9c16b300ea | |||
| 72a297935c | |||
| ef49ad8862 | |||
| 1e3e985622 | |||
| 4124c9ab03 | |||
| e00a135eb0 | |||
| 17ac152d08 | |||
| 97da8ae822 | |||
| 89f0aecbc1 | |||
| c7473f824b | |||
| 88a5f45eac | |||
| e25f575d18 | |||
| 348b86d22b | |||
| 2b544d1f7a | |||
| 18868c3b2d | |||
| bf87c21587 | |||
| 3dac23d2af | |||
| e7e0236fa7 | |||
| 451d33f123 | |||
| d11cc212d7 | |||
| 157e04fed1 | |||
| 8f0de73d86 | |||
| 98b4df47d1 | |||
| 64ac109e6c | |||
| 81a765aef1 | |||
| 78d680fa37 | |||
| 2df55a731a | |||
| 00eb2128c3 | |||
| 4d40099426 | |||
| 9392cc5061 | |||
| 24ba779da6 | |||
| 0efd72268e | |||
| 3e7b7e824f | |||
| 8d7d81dd3f | |||
| ab48ab3aa3 | |||
| 60f745af0b | |||
| 431f3e43de | |||
| 47bccf4d7d | |||
| fb84d29898 | |||
| 4833f26847 | |||
| fefe2cae1e | |||
| 6100ad657c | |||
| 3f8fe5d1f6 | |||
| aa1a126154 | |||
| aee220e6b8 | |||
| 614966f764 | |||
| 90a1b17c07 | |||
| 75d5b7a09d | |||
| f66e6e80c1 | |||
| eed83796f2 | |||
| b84ddd5c39 | |||
| ca74e87ea3 | |||
| 6f187d580f | |||
| e63a08c2e2 | |||
| 29fd0c9a0f | |||
| 15b4549714 | |||
| 06432d2155 | |||
| 5500ccc188 | |||
| 407cc72a6e | |||
| 64546f5a7f | |||
| 7483c7e302 | |||
| 8e64f6a8ac | |||
| 5ccf7ae8e9 | |||
| 2a89242d25 | |||
| 850f35c29d | |||
| fe4162bd00 | |||
| 924bfc3486 | |||
| c74dbb6a3b | |||
| 27d88c34a8 | |||
| cca2732fdb | |||
| b1e6c89ac5 | |||
| 4672c8a35c | |||
| 9e6f02887c | |||
| a897d31a44 | |||
| e8cbdd2031 | |||
| bd01b9273e | |||
| 45ecc3cc8c | |||
| dfd5463511 | |||
| 15c6634512 | |||
| 60dda02a15 | |||
| 86a084f30d | |||
| c111ebc93e | |||
| 314654ca78 | |||
| 6f9257621a | |||
| 48c2a6de50 | |||
| ad0e2f4b18 | |||
| e21a958fdb | |||
| 0d3f44935c | |||
| 0b3db3eb8f | |||
| ab8457a856 | |||
| 1d8899b0ab | |||
| 5c86b64bb1 | |||
| def426b0e8 | |||
| 5244cc21b2 | |||
| ae3cdacff3 | |||
| 4ef932bad1 | |||
| 7d5ec66ec2 | |||
| d0590a204b | |||
| 7c50fa9a90 | |||
| d192d5ac19 | |||
| 609a5e7399 | |||
| b05ded032a | |||
| 76597ae774 | |||
| a28a86d723 | |||
| 076e9d2c37 | |||
| 55d873548d | |||
| 4d13c79633 | |||
| 1750215d1b | |||
| 5aba47908d | |||
| 16b597073d | |||
| 6ef4dd9812 | |||
| 256560cf3b | |||
| b975e55794 | |||
| 73bb2a1707 | |||
| 26d9338370 | |||
| a858053358 | |||
| 7bf805c9c3 | |||
| b08e6713e5 | |||
| d9fca8b0ec | |||
| 90fcd83994 | |||
| 11a51509ae | |||
| c1023ad4db | |||
| 6bcddda1cb | |||
| ba35eb0733 | |||
| 5582c178db | |||
| fad84655ef | |||
| 8335b8082c | |||
| 67a31a918a | |||
| 5bd4adb902 | |||
| 1ad335c122 | |||
| e1c7f63a51 | |||
| aea5b1f119 | |||
| df7200d046 | |||
| 44227900d7 | |||
| 14d46f0c6c | |||
| 72888d7f77 | |||
| 6ed3ac0026 | |||
| 13deea9895 | |||
| 519c25eb94 | |||
| dbc539e202 | |||
| addeea92a7 | |||
| f7c457b4d4 | |||
| e8821deb14 | |||
| bdb3868245 | |||
| c42c3ce116 | |||
| dcab5c95b7 | |||
| 708ebd9bef | |||
| b5f4e6febb | |||
| e4deebf320 | |||
| 5a04b071f1 | |||
| 5ca616b304 | |||
| 3392e0a91e | |||
| 7cdeead7b2 | |||
| 93108bc00e | |||
| 35b84c76dc | |||
| 6671908e2e | |||
| 0c2c42d039 | |||
| 4f32fee37a | |||
| e5dc8fd50b | |||
| ca55030c68 | |||
| 69a688706e | |||
| 2f8c8762e3 | |||
| 7f7db43910 | |||
| 4bb44ff210 | |||
| 6ccdeefdee | |||
| fed462dad5 | |||
| 31e1b58012 | |||
| df5c08ef27 | |||
| 747b95c125 | |||
| bbda106f7b | |||
| 7d8e2d1192 | |||
| e2a9277489 | |||
| 357e18e063 | |||
| 0b193b823f | |||
| 6d15204b47 | |||
| c983d406c9 | |||
| 553edf761e | |||
| 3adfe5d89a | |||
| 05332e31c9 | |||
| 30f6058228 | |||
| 03003e4541 | |||
| 91eb2fcee2 | |||
| e3f2941294 | |||
| 53a7c01a9e | |||
| 8620bb2bc7 | |||
| 7cdcea1586 | |||
| e1c66f3bba | |||
| 3a703a261e | |||
| 4fb67e3b1c | |||
| 3d9f760156 | |||
| 93c22dec2e | |||
| fd3446a2f5 | |||
| 58fd4c41c2 | |||
| ea3a1ff5bd | |||
| 93e9f7ca97 | |||
| 6b7bdb9322 | |||
| 4312a7c6f6 | |||
| 6812b3dd74 | |||
| ea825505cc | |||
| 0c5eae3ceb | |||
| 3ec8d1b9f9 | |||
| 121991f3e5 | |||
| 63e2a7d1a8 | |||
| 6e7fcb8732 | |||
| d5a54f6844 | |||
| 69f7ec9176 | |||
| d92ec350e4 | |||
| 671e3f14fe | |||
| 4dbf8b00ec | |||
| 0622efc781 | |||
| edf9f77060 | |||
| f762a8b0d7 | |||
| ee79b789a7 | |||
| 6a55092f2c | |||
| 59f1b07a03 | |||
| 1dbac90108 | |||
| c774405dc3 | |||
| c738b60c7b | |||
| 70e78b7e5f | |||
| 4e9c6b37d3 | |||
| c09775473a | |||
| af483d9989 | |||
| 7ea0f0977d | |||
| b10335efc1 | |||
| 9fd585ebdd | |||
| c2fee23582 | |||
| 0a7388ac2f | |||
| fed1bb9ce0 | |||
| b864a73573 | |||
| d52d9e25a5 | |||
| 7506ed7dec | |||
| 4188e926a4 | |||
| f4688137bc | |||
| 7ee35644e3 | |||
| b83d35fc75 | |||
| f08e3d6226 | |||
| 75337cc5bf | |||
| d1c53df4d4 | |||
| 059f75dbc5 | |||
| 6693f2c153 | |||
| 8436c7b787 | |||
| 710aa08818 | |||
| 935c121bab | |||
| d66eaf6aa4 | |||
| 774305f799 | |||
| 95a1e966bc | |||
| b6f90a03c4 | |||
| 51d50e3b33 | |||
| b53cb20d61 | |||
| 2b3a2e7daf | |||
| 5c4cf3011e | |||
| 81f1fd5d1f | |||
| 5d872e9a95 | |||
| 8efd7c7128 | |||
| 48744aa13d | |||
| b7d33577f1 | |||
| ffb9c93ee6 | |||
| 97ec528b50 | |||
| 4dd913d3ca | |||
| e41e8396d7 | |||
| cf10654ea6 | |||
| 0cf5614502 | |||
| 62517cc062 | |||
| b32ae751a2 | |||
| ad2e9a2ee9 | |||
| 0f85584294 | |||
| 1a53f3047d | |||
| dc959f6360 | |||
| c6ca9b8042 | |||
| f0724f705f | |||
| 48794fa3b4 | |||
| ce4a53b61e | |||
| 68ed98c7b5 | |||
| 24c4fe0dc7 | |||
| fe1061f81b | |||
| 881ddf3c81 | |||
| 2e5a262864 | |||
| 421d4f366e | |||
| 7777271df1 | |||
| 840769af21 | |||
| bfc8d1ab07 | |||
| f1000f1838 | |||
| 772a2de236 | |||
| 5920523b57 | |||
| ee8e4f0bcb | |||
| 4aa358d685 | |||
| f811245f90 | |||
| b0561a5503 | |||
| 522c265041 | |||
| 25ef304e42 | |||
| 590e592cf0 | |||
| 7dc1afc5a1 | |||
| a42522dda2 | |||
| 9a5d3e56fe | |||
| fe43906cf1 | |||
| 0d1d782437 | |||
| 7f93dcb3af | |||
| 3b9eef908f | |||
| 948e6b70b6 | |||
| afe2bf1c28 | |||
| ae3daef072 | |||
| da6cab8784 | |||
| 2722ee1dcd | |||
| 475843cd27 | |||
| fd97b76fbb | |||
| 587d7eb5ba | |||
| 5c6b9b3baf | |||
| f6947aca9b | |||
| 4df6197a9a | |||
| 559a52f46f | |||
| d317c6e714 | |||
| dafc576c40 | |||
| e841e46242 | |||
| 6a09bb5479 | |||
| c4778471bb | |||
| a3964662fa | |||
| 97cf2763a5 | |||
| 0bb55ebb97 | |||
| 5983583388 | |||
| b1d4237bee | |||
| a20a91de0d | |||
| be262fe0d6 | |||
| 8f065379a0 | |||
| 99e4fd0406 | |||
| e7f7d9419d | |||
| 907fdc1b70 | |||
| d0e8c5b64b | |||
| 904df8a776 | |||
| c085c2017f | |||
| c322f2796a | |||
| 2a66968198 | |||
| 5831013baf | |||
| 5d7547f70b | |||
| b9e82da61e | |||
| 90ebc19e79 | |||
| 9f9271cd64 | |||
| c494750efd | |||
| 1cf3646b2a | |||
| ce95c6c12c | |||
| c35a5b942c | |||
| 5f3af5e206 | |||
| 39949bb439 | |||
| 146d569b88 | |||
| 68e62ae705 | |||
| b3b9e73c9f | |||
| ac3cdf34b2 | |||
| a68cad44c3 | |||
| 743390edf7 | |||
| 558e5affea | |||
| ec7d7f4326 | |||
| 3520c0ad6b | |||
| c4f52b8aa7 | |||
| df44b1b2c6 | |||
| 8c6721a3fc | |||
| fb7676b760 | |||
| f97792b81e | |||
| f3c9a74b0f | |||
| 248b07f45c | |||
| a759e653de | |||
| 14939ff534 | |||
| c9b48aeaae | |||
| 24aa3a32d9 | |||
| 3dd229edfb | |||
| 0e13455c7a | |||
| 75a3453daa | |||
| 7d0f565101 | |||
| 3d744518b2 | |||
| d4590a9340 | |||
| 3c330efaa9 | |||
| 89c392fa63 | |||
| a76a971321 | |||
| d2eca1811d | |||
| fdb849aa1d | |||
| d3e0d177a5 | |||
| b7c88ecca8 | |||
| 09bd4096e2 | |||
| ed923bcde6 | |||
| d065580e47 | |||
| cb32405e55 | |||
| b9fee19510 | |||
| f6b209949a | |||
| 949bd5fde4 | |||
| 27d65bc389 | |||
| f2805ed9d8 | |||
| 0745d99e85 | |||
| 4bba4159f1 | |||
| 23977a64ca | |||
| c73c15de22 | |||
| a5159e040b | |||
| c2bf0bd88e | |||
| 9fec863f18 | |||
| 6aeed26642 | |||
| 3530754518 | |||
| 5049116a6f | |||
| 3b641a8d7c | |||
| 3e31d26660 | |||
| 65633bcac9 | |||
| 3cf8b20e97 | |||
| aa5f5d7640 | |||
| 0b1caeffa7 | |||
| dfeeb81ab8 | |||
| 2bce20ba03 | |||
| 208296f841 | |||
| 6488a0ed63 | |||
| 69929fc00d | |||
| 83b4290e62 | |||
| e8bf01b149 | |||
| fc950865c4 | |||
| 043d70fbe0 | |||
| 77db5965a9 | |||
| 2f8569c302 | |||
| 7465ad01d4 | |||
| ab9e8bfcd6 | |||
| d71d6de05f | |||
| f9eec18adb | |||
| ba08d749ac | |||
| 6eccacc06a | |||
| 8feaccf5dd | |||
| 1ac62aac06 | |||
| 041979de07 | |||
| 59556406a8 | |||
| 58bb3046e7 | |||
| 45242292d6 | |||
| ef9adb29e8 | |||
| 1d5f0541d7 | |||
| 0671910e67 | |||
| 7bb960b6b3 | |||
| e71d95fcc6 | |||
| 84496d30a1 | |||
| a0ca42af26 | |||
| 9905d39e19 | |||
| e91f4a2c63 | |||
| 98976c9ce9 | |||
| b1e0bcda63 | |||
| 634e161085 | |||
| ae41290b68 | |||
| 77b35995eb | |||
| 94bcf23b68 | |||
| 847b2f2f00 | |||
| 1eace996f5 | |||
| 9d4116b478 | |||
| c281764bd9 | |||
| a3e3202f21 | |||
| 09dac639c9 | |||
| b3bdf69d61 | |||
| 59fd1b2d14 | |||
| de26235621 | |||
| f4f07ce91f | |||
| 93d00ea4c0 | |||
| 2571f9d216 | |||
| f665ffa0c0 | |||
| 58ca29fb62 | |||
| a4e785e574 | |||
| f3b277bc23 | |||
| 42b901d769 | |||
| ba2c541c31 | |||
| 735de6ece9 | |||
| e5f7ece942 | |||
| 5ebc988190 | |||
| 53cc92d9d0 | |||
| 5c3dc851bc | |||
| 4e8cf62418 | |||
| 883b5b5760 | |||
| ff671bda39 | |||
| 9190f62b9e | |||
| 707b24f41e | |||
| 7e93dcba6c | |||
| 42abac7527 | |||
| 937da49cd7 | |||
| a26269ebbe | |||
| 0fcce88409 | |||
| 0ea1e55ee4 | |||
| 2c8cd11153 | |||
| 2a69747744 | |||
| 48881677b5 | |||
| c9f3a304e6 | |||
| ad364e4b19 | |||
| f413d29fa1 | |||
| 323c613222 | |||
| babfbc5b10 | |||
| 640a8328cf | |||
| e56523b819 | |||
| 177caded5c | |||
| d9d99d6b0b | |||
| 7dbfc31f04 | |||
| fb6f157c42 | |||
| 3504a24be5 | |||
| f49c20787e | |||
| 39fed90296 | |||
| 760e11138d | |||
| 534b8f0102 | |||
| 5cb4c9f950 | |||
| e37552c8ce | |||
| 3af32e167c | |||
| 3927a50633 | |||
| 0712034720 | |||
| c0a23061ee | |||
| 44be9e6e35 | |||
| fa813ed084 | |||
| 2c58a7b0fd | |||
| 532ff57c29 | |||
| f99c1d0b17 | |||
| 703bb6d3ab | |||
| 162d4eee43 | |||
| 810cbfba00 | |||
| 3e5af1922d | |||
| 3a540ffaa1 | |||
| 2b9ea24238 | |||
| 308f3098f3 | |||
| 77eee4f872 | |||
| 1b4399df68 | |||
| aacfb66e2c | |||
| a1be35f1f2 | |||
| fe5a622998 | |||
| 0f1103a607 | |||
| 8975d762ef | |||
| 704cb42e99 | |||
| 1db8b4d5b0 | |||
| b62da321f7 | |||
| c5b929187a | |||
| 119307d13b | |||
| 5b8d2d5c06 | |||
| e0024567ff | |||
| 0316331fd2 | |||
| 7807c994ff | |||
| cd90cbce0e | |||
| 650ba868b3 | |||
| 256e22f0bd | |||
| 3926a1c886 | |||
| b87b70fa72 | |||
| ac48231e82 | |||
| 5f891bbce4 | |||
| e17dbdc9c2 | |||
| a6ed6cd4da | |||
| 84bd0c9e17 | |||
| 11999c0e8b | |||
| 6c2cedf8ec | |||
| 0e117fa417 | |||
| c1f942210a | |||
| 302b756c54 | |||
| d239948757 | |||
| c8a126ffd6 | |||
| ba19a1045e | |||
| d53988aa0d | |||
| 0cbaffb77f | |||
| 24a7ae014d | |||
| 1e6ce6fb30 | |||
| c4cfc0bd2c | |||
| d4595d55bf | |||
| 15f10cd58a | |||
| d5bf21c853 | |||
| d7996c49db | |||
| 8ea55d2c53 | |||
| a38a80fdec | |||
| cd97f854c0 | |||
| 3ae03c3d17 | |||
| 966b71f0d8 | |||
| 2d1b270e8a | |||
| 0ac1db2085 | |||
| 39b2f79e38 | |||
| a70caae2da | |||
| 5cfd9a049f | |||
| b5f2d9bebb | |||
| d3fadeca09 | |||
| 343085684e | |||
| 7b66c795fe | |||
| 6d4d8ee9fb | |||
| b772be3139 | |||
| fd6b6b41bc | |||
| bc0b8f83d4 | |||
| 9dad7c2488 | |||
| baf91fef89 | |||
| 77752f8b65 | |||
| 5084c99367 | |||
| 4dbd52e914 | |||
| 9d6f2cefec | |||
| f92e013347 | |||
| c833021eea | |||
| cbffd73953 | |||
| 7701ce006d | |||
| 9a94d2b639 | |||
| 98077d4738 | |||
| 9cedada01d | |||
| b01e7e8fe5 | |||
| 6eaef58db3 | |||
| 4838ec3556 | |||
| a90ac34508 | |||
| deb10d0972 | |||
| 90b17255af | |||
| 8e19ccd518 | |||
| f11a149448 | |||
| 4fec16c281 | |||
| 1fe896f858 | |||
| 5a60bf7b8c | |||
| 22ef2619d7 | |||
| 983d355b75 | |||
| 6923b44f58 | |||
| e5a74a85f6 | |||
| 63e7b7f5e7 | |||
| 402acdcd16 | |||
| 3294cc331a | |||
| d1f5bdda22 | |||
| 3d984481fc | |||
| 75713b1e35 | |||
| e3f297e49d | |||
| 673c09c08d | |||
| 3014470398 | |||
| 61dca177c7 | |||
| 835b13b7e9 | |||
| 0bb59fd4ba | |||
| e066bdb482 | |||
| a486de06ba | |||
| 4a71c15b28 | |||
| 07fb778c4c | |||
| c7c7dd8b68 | |||
| 331b4a9b08 | |||
| 2cb55ee44d | |||
| 5dd185d711 | |||
| 06764648a5 | |||
| 825e5c790b | |||
| b459081dfb | |||
| 3a772af66e | |||
| 5c7691c426 | |||
| 003251028f | |||
| 6e101e89ef | |||
| 37513cd44c | |||
| 28ef1d725e | |||
| e2d30255bc | |||
| 9d6c9c2a40 | |||
| 9b9a04d468 | |||
| 12f03ce75d | |||
| 2d260b37b5 | |||
| 49a11e545b | |||
| 873c9abf32 | |||
| 207794e714 | |||
| 37b315f06f | |||
| 897a3f94a1 | |||
| a9b6665ba0 | |||
| 0fc86aaa5e | |||
| 61b02fe011 | |||
| 5b4138046a | |||
| ecda6619a5 | |||
| cd5b9adc5e | |||
| 0b4a88a83e | |||
| eed5c2fc5a | |||
| 20088b3d2b | |||
| 6ae0736576 | |||
| b895c6c696 | |||
| 259d33b71b | |||
| be1d6f1ece | |||
| 2a4cf19488 | |||
| 7031b09f45 |
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: merge-upstream
|
||||
description: Merge upstream changes from the Ditto repo (which Agora is a fork of) into Agora's main branch. Load when the user asks to "merge upstream", "pull from Ditto", "sync with Ditto", or otherwise update Agora with new commits from soapbox-pub/ditto.
|
||||
---
|
||||
|
||||
# Merge Upstream from Ditto
|
||||
|
||||
Agora is a fork of [Ditto](https://gitlab.com/soapbox-pub/ditto). This skill walks through pulling new commits from upstream Ditto and merging them into Agora's `main` branch, while making philosophy-aware decisions on merge conflicts.
|
||||
|
||||
## Philosophy: Agora vs. Ditto
|
||||
|
||||
Agora has diverged from Ditto on purpose in several areas. When resolving conflicts, side with Agora's direction unless the upstream change is clearly a generic bug fix or improvement that applies to both. Known divergences:
|
||||
|
||||
- **No Blobbi** — Agora has removed Blobbi support. If an upstream change adds or modifies Blobbi-related code, prefer to drop the Blobbi parts rather than reintroduce them.
|
||||
- **Lightning-only wallet** — Agora uses a Breeze Lightning wallet. **No onchain functionality exists in Agora**, even though Ditto includes it. Reject upstream onchain wallet code; keep onchain-related conflicts resolved to Agora's Lightning-only path.
|
||||
- **General rule** — if upstream reintroduces a feature Agora deliberately removed, the deliberate removal wins. When in doubt, ask the user before resolving a conflict that touches a known divergence.
|
||||
|
||||
Spend a moment scanning the conflict for these themes before mechanically resolving line-by-line.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1: Ensure the `ditto` remote exists
|
||||
|
||||
Check the current remotes:
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `ditto` is not listed (pointing to `https://gitlab.com/soapbox-pub/ditto.git` or the equivalent `git@gitlab.com:soapbox-pub/ditto.git`), add it:
|
||||
|
||||
```bash
|
||||
git remote add ditto https://gitlab.com/soapbox-pub/ditto.git
|
||||
```
|
||||
|
||||
If a `ditto` remote exists but points elsewhere, fix it with `git remote set-url ditto <url>`.
|
||||
|
||||
### Step 2: Confirm a clean working tree on `main`
|
||||
|
||||
```bash
|
||||
git status
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
The working tree must be clean and the current branch must be `main`. If not, stop and ask the user how to proceed — do not stash or switch branches automatically.
|
||||
|
||||
### Step 3: Fetch from Ditto
|
||||
|
||||
```bash
|
||||
git fetch ditto
|
||||
```
|
||||
|
||||
### Step 4: Preview what's incoming
|
||||
|
||||
Show the user (or at least review yourself) the commits that will be merged before merging:
|
||||
|
||||
```bash
|
||||
git log --oneline main..ditto/main
|
||||
```
|
||||
|
||||
If the list is empty, Agora is already up to date — stop here and tell the user.
|
||||
|
||||
### Step 5: Merge `ditto/main` into `main`
|
||||
|
||||
```bash
|
||||
git merge ditto/main
|
||||
```
|
||||
|
||||
If the merge succeeds without conflicts, proceed to Step 7.
|
||||
|
||||
### Step 6: Resolve conflicts (if any)
|
||||
|
||||
For each conflicted file:
|
||||
|
||||
1. Re-read the Philosophy section above.
|
||||
2. Inspect the conflict with `git diff` and decide based on Agora's direction, not just textual merge.
|
||||
3. For Blobbi-related conflicts, drop the Blobbi side.
|
||||
4. For onchain-wallet conflicts, keep Agora's Lightning-only path.
|
||||
5. For ambiguous cases that touch a known divergence, **ask the user** before resolving.
|
||||
6. After resolving each file, `git add <file>`.
|
||||
|
||||
When all conflicts are resolved, complete the merge:
|
||||
|
||||
```bash
|
||||
git commit
|
||||
```
|
||||
|
||||
Git will pre-populate a merge commit message listing the conflicted files. Keep that information and add a short note about how non-trivial conflicts were resolved (especially anything touching the divergences above), so the resolution rationale is preserved in history.
|
||||
|
||||
### Step 7: Validate the merge
|
||||
|
||||
Run the full test script to confirm the merged tree still type-checks, lints, tests, and builds:
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
If anything fails, fix it before declaring the merge done. Failures after an upstream merge are common — a removed Blobbi reference may now be re-imported by new upstream code, or onchain wallet types may leak into Lightning-only code paths. Fix forward in new commits on top of the merge commit; do not amend the merge commit itself.
|
||||
|
||||
### Step 8: Report back
|
||||
|
||||
Tell the user:
|
||||
|
||||
- How many commits were merged (`git rev-list --count main@{1}..main`).
|
||||
- Which files had conflicts and how each was resolved.
|
||||
- Whether `npm run test` passed.
|
||||
- That the merge is **not** pushed — the user decides when to push.
|
||||
|
||||
**Do not push to `origin` automatically.** The user will push when they're ready.
|
||||
@@ -5,7 +5,10 @@ VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
|
||||
VITE_NOSTR_PUSH_PUBKEY=""
|
||||
# Canonical origin used when generating shareable URLs (QR codes, copy-link, remote-login callbacks).
|
||||
# Primarily useful for native (Capacitor) builds, where window.location.origin is capacitor://localhost.
|
||||
# Example: VITE_SHARE_ORIGIN="https://ditto.pub"
|
||||
# Example: VITE_SHARE_ORIGIN="https://agora.spot"
|
||||
VITE_SHARE_ORIGIN=""
|
||||
# DeepL-backed translation worker for user-generated content.
|
||||
# Example: VITE_TRANSLATE_WORKER_URL="https://agora-translate.<your-subdomain>.workers.dev"
|
||||
VITE_TRANSLATE_WORKER_URL="https://agora-translate.lemonknowsall.workers.dev/"
|
||||
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
|
||||
# ALLOWED_HOSTS="*"
|
||||
# ALLOWED_HOSTS="*"
|
||||
|
||||
@@ -26,6 +26,44 @@ test:
|
||||
script:
|
||||
- npm run test
|
||||
|
||||
# Deploy the built web app to agora.spot on venus.vps via rsync over SSH.
|
||||
# Uses the per-site jailed deploy key documented in GITLAB_DEPLOY.md.
|
||||
# DEPLOY_SSH_KEY and DEPLOY_TARGET are protected CI/CD variables; they're
|
||||
# only exposed to jobs on the protected default branch.
|
||||
deploy-web:
|
||||
stage: deploy
|
||||
timeout: 10 minutes
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH && $DEPLOY_SSH_KEY && $DEPLOY_TARGET
|
||||
# Vite inlines VITE_* env vars at build time. These are sourced directly from
|
||||
# project-level CI/CD variables, which are already present in the job
|
||||
# environment — do NOT re-declare them here as `KEY: $KEY`. That self-reference
|
||||
# overwrites the real value with the literal string "$KEY" whenever the source
|
||||
# variable is out of scope (e.g. a Protected variable on an unprotected ref),
|
||||
# which is how "$VITE_TRANSLATE_WORKER_URL" leaked into the built app.
|
||||
script:
|
||||
# Build the web app
|
||||
- npm ci
|
||||
- npm run build
|
||||
- cp dist/index.html dist/404.html
|
||||
|
||||
# Install rsync + ssh client and load the deploy key
|
||||
- apt-get update -qq && apt-get install -y --no-install-recommends rsync openssh-client >/dev/null
|
||||
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
- echo "$DEPLOY_SSH_KEY" | tr -d '\r' > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
|
||||
- ssh-keyscan -H "${DEPLOY_TARGET##*@}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Two-phase rsync: upload hashed assets first, then index.html and sw.js,
|
||||
# so the site never serves an index.html that points at assets that
|
||||
# haven't finished uploading. sw.js is in the second pass for the same
|
||||
# reason — it's a stable filename that all browsers re-fetch to check
|
||||
# for updates, so we want it to land last. The destination ":/" is the
|
||||
# rrsync jail root on venus, which maps to /var/www/agora.spot/.
|
||||
- rsync -av --exclude=/sw.js --exclude=/index.html -e "ssh -i ~/.ssh/id_ed25519" dist/ "${DEPLOY_TARGET}:/"
|
||||
- rsync -av -e "ssh -i ~/.ssh/id_ed25519" dist/index.html dist/sw.js "${DEPLOY_TARGET}:/"
|
||||
|
||||
# Disabled: nsite deploy not needed right now; re-enable by restoring the
|
||||
# rules below to run on default branch (and ensure NSITE_NBUNKSEC is set).
|
||||
deploy-nsite:
|
||||
@@ -61,22 +99,6 @@ deploy-nsite:
|
||||
--use-fallback-relays
|
||||
--use-fallback-servers
|
||||
|
||||
build-web:
|
||||
stage: build
|
||||
timeout: 10 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
script:
|
||||
- npm ci
|
||||
- npm run build
|
||||
- cp dist/index.html dist/404.html
|
||||
artifacts:
|
||||
paths:
|
||||
- dist/
|
||||
|
||||
release-notes:
|
||||
stage: build
|
||||
timeout: 2 minutes
|
||||
@@ -88,7 +110,7 @@ release-notes:
|
||||
# release-notes.md is the full section (summary + bulleted lists), used as
|
||||
# the GitLab Release description. release-notes-summary.txt is the leading
|
||||
# plaintext paragraph only, used as the App Store / Play Store release
|
||||
# blurb. Falls back to "Ditto vX.Y.Z" when the section has no summary.
|
||||
# blurb. Falls back to "Agora vX.Y.Z" when the section has no summary.
|
||||
- mkdir -p artifacts
|
||||
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" > artifacts/release-notes.md
|
||||
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" --summary > artifacts/release-notes-summary.txt
|
||||
@@ -145,23 +167,33 @@ build-apk:
|
||||
# Write local.properties for Gradle
|
||||
- echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties
|
||||
|
||||
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility
|
||||
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility.
|
||||
# PKCS12 conceptually uses one password for the store and every entry; if the
|
||||
# store and key passwords differ, keytool protects the migrated entry with the
|
||||
# STORE password regardless of -destkeypass, so Gradle's later read with the
|
||||
# key password fails ("Given final block not properly padded"). Unlock the
|
||||
# source key with its own password ($KEY_PASSWORD), then write the PKCS12 with
|
||||
# a single uniform password ($KEY_PASSWORD) for both store and entry so the
|
||||
# key.properties below is internally consistent.
|
||||
- echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/my-upload-key.jks
|
||||
- keytool -importkeystore
|
||||
-srckeystore android/app/my-upload-key.jks
|
||||
-destkeystore android/app/my-upload-key.keystore
|
||||
-deststoretype pkcs12
|
||||
-srcstorepass "$KEYSTORE_PASSWORD"
|
||||
-deststorepass "$KEYSTORE_PASSWORD"
|
||||
-srcalias upload
|
||||
-destalias upload
|
||||
-srckeypass "$KEY_PASSWORD"
|
||||
-deststorepass "$KEY_PASSWORD"
|
||||
-destkeypass "$KEY_PASSWORD"
|
||||
-noprompt
|
||||
- rm android/app/my-upload-key.jks
|
||||
|
||||
# Write key.properties from CI/CD variables
|
||||
# Write key.properties from CI/CD variables. The PKCS12 above uses
|
||||
# $KEY_PASSWORD uniformly, so both storePassword and keyPassword point to it.
|
||||
- |
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=$KEYSTORE_PASSWORD
|
||||
storePassword=$KEY_PASSWORD
|
||||
keyPassword=$KEY_PASSWORD
|
||||
keyAlias=upload
|
||||
storeFile=my-upload-key.keystore
|
||||
@@ -264,27 +296,27 @@ build-ipa:
|
||||
ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Run match (cert verify + decrypt) and build_app to produce the IPA.
|
||||
# build_app writes ./artifacts/Ditto.ipa relative to the project root.
|
||||
# build_app writes ./artifacts/Agora.ipa relative to the project root.
|
||||
- cd ios
|
||||
- fastlane build_ipa
|
||||
- cd ..
|
||||
|
||||
# Move the IPA to a stable name in the artifact directory.
|
||||
- ls -lh artifacts/
|
||||
- test -f artifacts/Ditto.ipa
|
||||
- test -f artifacts/Agora.ipa
|
||||
|
||||
# Upload to the Generic Packages registry for a stable public download URL,
|
||||
# mirroring how build-apk publishes the APK and AAB.
|
||||
- |
|
||||
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||
--upload-file "artifacts/Ditto.ipa" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa"
|
||||
--upload-file "artifacts/Agora.ipa" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.ipa"
|
||||
after_script:
|
||||
# Wipe the API key so nothing sensitive sticks around between jobs.
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
artifacts:
|
||||
paths:
|
||||
- artifacts/Ditto.ipa
|
||||
- artifacts/Agora.ipa
|
||||
expire_in: 90 days
|
||||
|
||||
release:
|
||||
@@ -317,8 +349,8 @@ release:
|
||||
- name: Agora-${CI_COMMIT_TAG}.aab
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab
|
||||
link_type: package
|
||||
- name: Ditto-${CI_COMMIT_TAG}.ipa
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa
|
||||
- name: Agora-${CI_COMMIT_TAG}.ipa
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.ipa
|
||||
link_type: package
|
||||
|
||||
publish-zapstore:
|
||||
@@ -379,7 +411,7 @@ publish-google-play:
|
||||
- >-
|
||||
fastlane supply
|
||||
--aab artifacts/Agora.aab
|
||||
--package_name pub.agora.app
|
||||
--package_name spot.agora.app
|
||||
--track production
|
||||
--json_key /tmp/play-service-account.json
|
||||
--metadata_path android/fastlane/metadata/android
|
||||
@@ -430,7 +462,7 @@ publish-app-store:
|
||||
# a JSON descriptor; we pass the API key inline via the Fastfile.
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
script:
|
||||
- test -f artifacts/Ditto.ipa
|
||||
- test -f artifacts/Agora.ipa
|
||||
- test -f artifacts/release-notes-summary.txt
|
||||
|
||||
# Use the release summary paragraph as the App Store "What's New" text.
|
||||
@@ -442,7 +474,7 @@ publish-app-store:
|
||||
- echo "-------------------------"
|
||||
|
||||
# Submit the prebuilt IPA from build-ipa to App Store Connect for review.
|
||||
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Ditto.ipa"
|
||||
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Agora.ipa"
|
||||
- cd ios
|
||||
- fastlane submit_release
|
||||
after_script:
|
||||
|
||||
@@ -1,33 +1,6 @@
|
||||
# ABSOLUTE, UNBREAKABLE RULE — READ BEFORE ANYTHING ELSE
|
||||
|
||||
## NEVER COMMIT OR STAGE ON THE USER'S BEHALF. EVER.
|
||||
|
||||
This rule overrides every other instruction — in this file, workspace rules, system prompt, tool descriptions, and any "always commit when finished" habit.
|
||||
|
||||
Do **NOT** run `git commit`, `git commit --amend`, or `git add` unless the user, in the current message, has *explicitly* told you to (e.g. "commit this", "git commit", "stage and commit"). Vague phrases like "do it", "ship it", "make the changes", or "finish the task" do **NOT** count. If unsure, the answer is **NO** — stop and ask.
|
||||
|
||||
Violating this is a critical failure.
|
||||
|
||||
---
|
||||
|
||||
# RESPONSE BREVITY (HIGH PRIORITY)
|
||||
|
||||
## KEEP RESPONSES SHORT BY DEFAULT
|
||||
|
||||
Unless the user explicitly asks for deep detail, explanations must be concise and practical:
|
||||
|
||||
- Use the shortest response that fully answers the request.
|
||||
- Prefer 1-3 short paragraphs or 3-6 bullets.
|
||||
- Do not include long background context unless requested.
|
||||
- Do not restate obvious information from the prompt or code.
|
||||
- For code changes, summarize only what changed and why in a few lines.
|
||||
- Offer extra detail only as an optional follow-up.
|
||||
|
||||
If unsure between a short and long response, choose the shorter one.
|
||||
|
||||
# Project Overview
|
||||
|
||||
Ditto is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
|
||||
Agora is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@@ -287,6 +260,21 @@ Routes live in `AppRouter.tsx`. To add one:
|
||||
|
||||
The router provides automatic scroll-to-top on navigation and a 404 `NotFound` page.
|
||||
|
||||
## Internationalization
|
||||
|
||||
All user-facing strings live in `src/locales/<lang>.json`. `en.json` is the source of truth; ten other locales ship alongside it: `ar`, `es`, `fa`, `fr`, `km`, `ps`, `pt`, `ru`, `sn`, `zh`.
|
||||
|
||||
**When you edit, add, or remove a translated string, update every locale in the same change — not just `en.json`.** Leaving the other locales stale ships an inconsistent app: users in other languages either see outdated copy or get an English fallback in the middle of a localized screen. This applies to FAQ entries, guide bodies, button labels, error messages — every value reachable through `t()`.
|
||||
|
||||
Concrete rules:
|
||||
|
||||
- **Edits to an existing key** — change the value in `en.json` first, then update the corresponding key in all ten other locales. Translate the new content into each language; don't paste English. Preserve `{{interpolation}}` placeholders, markdown links, and technical tokens (`sp1…`, `BIP-352`, kind numbers, etc.) verbatim.
|
||||
- **New keys** — add to `en.json` first, then add the same key with a translated value in every other locale. `src/test/locales.test.ts` fails the build if any locale ships a key that doesn't exist in `en.json`, but the inverse (a key missing from a non-English locale) is allowed and falls back to English at runtime — which is exactly the user-visible mess you're trying to avoid.
|
||||
- **Removed keys** — delete from `en.json` and every other locale together. Leftover keys are dead translations and clutter future diffs.
|
||||
- **Parallelize the translation work** — when updating one English string across all ten locales, dispatch the per-language edits to subagents in parallel rather than translating ten files sequentially. Provide each subagent the new English source, the existing translation snippet (so it matches established voice), and explicit instructions to preserve placeholders and technical tokens.
|
||||
|
||||
Always run `npm run test` after locale changes — `locales.test.ts` catches structural drift, and the wider suite catches any `t()` calls that referenced a key you renamed.
|
||||
|
||||
## Development Practices
|
||||
|
||||
- React Query for data fetching and caching
|
||||
@@ -370,6 +358,12 @@ Load the **`capacitor-compat`** skill for the full list of installed plugins, pl
|
||||
|
||||
**Your task is not finished until the code type-checks and builds without errors.** Run validation in priority order. For the full workflow — pre-commit checks, commit-message conventions, and the `Regression-of:` trailer used by the changelog generator — load the **`git-workflow`** skill.
|
||||
|
||||
## Always Commit
|
||||
|
||||
**Every completed task ends with a git commit. This overrides any global default about waiting for explicit commit requests.** Once validation passes (or the task is non-code and there's nothing to validate), commit immediately — do not ask, do not leave changes uncommitted, do not stop at "ready to commit." The user expects a clean working tree at the end of every turn.
|
||||
|
||||
Pushing is still the user's call — commit, but do not push unless asked.
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
Ditto uses GitLab CI (`.gitlab-ci.yml`) with five stages:
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.6] - 2026-06-02
|
||||
|
||||
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
|
||||
|
||||
### Changed
|
||||
|
||||
- Update the app icon to the current Agora bolt on a brand-orange background.
|
||||
|
||||
## [2.8.5] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.4] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.3] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.2] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.1] - 2026-06-02
|
||||
|
||||
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
|
||||
|
||||
### Added
|
||||
|
||||
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
|
||||
- Organizations with their own events, pledges, members, and moderation tools.
|
||||
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
|
||||
- One-tap support: zap posts, profiles, campaigns, and organizations.
|
||||
- AI agent chat with a model selector, tool-calling, and slash commands.
|
||||
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
|
||||
- Comments and reactions on campaigns, and donation receipts shown inline.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed Agora branding, navigation, and app icons throughout.
|
||||
- Streamlined onboarding with country and people follows.
|
||||
- Polished campaign, organization, and donation flows end to end.
|
||||
|
||||
### Removed
|
||||
|
||||
- Direct messaging and ephemeral geo chat.
|
||||
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------------|----------------------------------------------------------------|
|
||||
| 20000 | Ephemeral Geo Chat (public) | Geo-anchored ephemeral chat message (kind 20000, public) |
|
||||
| 20001 | Ephemeral Geo Heartbeat | Geo-anchored ephemeral presence heartbeat (kind 20001) |
|
||||
| 33863 | Campaign | Self-authored fundraising campaign with a single Bitcoin wallet endpoint (`bc1...` or `sp1...`) |
|
||||
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
|
||||
| 36639 | Activist Action | Country-scoped activist challenge with a sats bounty |
|
||||
| 36639 | Pledge | Donor pledge for concrete submissions, stored as sats |
|
||||
|
||||
### Agora Protocols
|
||||
|
||||
@@ -23,6 +22,55 @@
|
||||
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
|
||||
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
|
||||
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
|
||||
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (hidden + featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns, organizations, and pledges identically. |
|
||||
| HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). |
|
||||
|
||||
### Agora Content Marker
|
||||
|
||||
Every event Agora publishes that represents a first-class Agora object carries the single-letter tag `["t", "agora"]`. This marker enables the Agora activity feed to filter strictly server-side via the relay-indexed `#t` filter (multi-letter tags like the NIP-89 `client` tag are not indexed by relays and are therefore unsuitable for this purpose).
|
||||
|
||||
#### Tagged kinds
|
||||
|
||||
| Kind | Object | Where tagged |
|
||||
|-------|---------------------|---------------------------------------------------------------|
|
||||
| 1 | Note (top-level, reply, quote) | `ComposeBox` default for top-level kind 1 publishes |
|
||||
| 1111 | NIP-22 comment | `usePostComment` (all comments authored in Agora) |
|
||||
| 8333 | Onchain zap | `useOnchainZap`, `useDonateCampaign`, `SendBitcoinDialog` |
|
||||
| 9041 | Zap goal | `CreateGoalDialog` |
|
||||
| 33863 | Campaign | `CreateCampaignPage` |
|
||||
| 31922 | Date calendar event | `CreateEventPage`, `CreateCommunityEventDialog` |
|
||||
| 31923 | Time calendar event | `CreateEventPage`, `CreateCommunityEventDialog` |
|
||||
| 34550 | Community | `CreateCommunityPage` |
|
||||
| 36639 | Pledge | `CreateActionPage` |
|
||||
|
||||
The tag is added at publish time via the `withAgoraTag` helper in `src/lib/agoraNoteTags.ts`, which dedupes against any user-supplied `t:agora` tag.
|
||||
|
||||
#### Untagged kinds (intentional)
|
||||
|
||||
Reactions, reposts, follow lists, profile metadata, lists, settings, badges, vanish requests, encrypted DMs, and live chat are user-state or response events rather than first-class Agora content. Tagging them would pollute `#agora` hashtag surfaces without adding value to the activity feed.
|
||||
|
||||
Untagged on purpose: 0, 3, 6, 7, 8, 16, 62, 1311, 30009, 10000-series, 30078, and any NIP-04 / NIP-44 encrypted kind.
|
||||
|
||||
#### Querying
|
||||
|
||||
The Agora activity feed combines a `t:agora`-strict layer with an intentionally cross-client world layer:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "kinds": [33863, 36639, 34550, 8333], "#t": ["agora", "Agora"] },
|
||||
{ "kinds": [1111], "#t": ["agora", "Agora"], "#K": ["33863", "36639", "34550"] },
|
||||
{ "kinds": [1111, 1068], "#k": ["iso3166", "geo"] },
|
||||
{ "kinds": [1], "#t": ["agora", "Agora"] }
|
||||
]
|
||||
```
|
||||
|
||||
The first two filters surface only Agora-created content. The third surfaces all country/geo-rooted comments and polls regardless of origin — the world layer is intentionally cross-client. The fourth captures any kind 1 note carrying `#agora` (including hashtags users type themselves), which preserves viral / opt-in discovery.
|
||||
|
||||
Clients filter both case variants (`agora` and `Agora`) because Nostr `t` tags are conventionally lowercase but some clients normalize hashtags to title case.
|
||||
|
||||
#### Backward compatibility
|
||||
|
||||
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
|
||||
|
||||
### Community Chat
|
||||
|
||||
@@ -72,6 +120,8 @@ Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) a
|
||||
|
||||
### Event Structure
|
||||
|
||||
Single-recipient zap (the common case — tipping a post or profile):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
@@ -87,6 +137,45 @@ Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) a
|
||||
}
|
||||
```
|
||||
|
||||
Multi-recipient zap (one transaction paying multiple recipients — community splits):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
"pubkey": "<sender-pubkey>",
|
||||
"content": "Great community!",
|
||||
"tags": [
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["p", "<recipient-1-pubkey>"],
|
||||
["p", "<recipient-2-pubkey>"],
|
||||
["p", "<recipient-3-pubkey>"],
|
||||
["amount", "<total-sats-paid-to-all-listed-recipients>"],
|
||||
["a", "34550:<community-author>:<community-d-tag>"],
|
||||
["K", "34550"],
|
||||
["alt", "Donation: 75000 sats across 3 recipients"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Campaign donation (one transaction paying a single campaign wallet — see Kind 33863 below):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
"pubkey": "<donor-pubkey>",
|
||||
"content": "Keep up the good work.",
|
||||
"tags": [
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["amount", "<sats-paid-to-campaign-wallet>"],
|
||||
["a", "33863:<campaign-author>:<campaign-d-tag>"],
|
||||
["K", "33863"],
|
||||
["alt", "Donation to Save the Last Bookstore: 25000 sats"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Campaign donation receipts MUST NOT include `p` tags — campaigns no longer have Nostr-identity recipients, only a `w` wallet endpoint. Verification matches tx outputs against the campaign's declared `w` address rather than derived Taproot addresses (see *Verification* and Kind 33863 below).
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
|
||||
@@ -96,21 +185,41 @@ The `content` field is a human-readable comment from the sender (may be empty).
|
||||
| Tag | Required | Description |
|
||||
|----------|----------|----------------------------------------------------------------------------------------------|
|
||||
| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:<txid>` where `<txid>` is a 64-char lowercase hex Bitcoin transaction ID. |
|
||||
| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). |
|
||||
| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address — *not* the total tx value. |
|
||||
| `p` | Yes (≥1) | 32-byte hex pubkey of a zap **recipient** (an author being paid). A single event MAY include multiple `p` tags when the transaction has one output per recipient — each `p` tag MUST correspond to at least one tx output paying that recipient's derived Taproot address. |
|
||||
| `amount` | Yes | **Total** amount paid in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the derived Taproot addresses of **all** listed `p` recipients combined — *not* the total tx value (it excludes the sender's change output). For single-recipient events this is the amount paid to that one recipient. |
|
||||
| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. |
|
||||
| `a` | If zapping an addressable event | Addressable event coordinate `<kind>:<pubkey>:<d-tag>`. Used instead of (or alongside) `e` for kinds 30000–39999. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. |
|
||||
|
||||
If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event).
|
||||
If neither `e` nor `a` is present, the zap targets the recipients' **profiles** (i.e. a tip to the pubkey(s), not to a specific event).
|
||||
|
||||
### Publishing Flow
|
||||
|
||||
1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
|
||||
1. Sender builds a Bitcoin transaction with one output per intended recipient, each paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
|
||||
2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`.
|
||||
3. Sender signs and publishes a kind 8333 event referencing that `txid` with the appropriate `e`/`a`/`p` tags.
|
||||
3. Sender signs and publishes a **single** kind 8333 event referencing that `txid` with one `p` tag per recipient and an `amount` tag carrying the total paid across all of them.
|
||||
4. The event is published **after** broadcast; the txid is already final at that point.
|
||||
|
||||
### Batch / Community Zaps
|
||||
|
||||
A single Bitcoin transaction MAY pay multiple recipients by including one output per recipient. Clients SHOULD publish **one kind 8333 event per transaction**, listing every recipient under its own `p` tag and putting the combined total in the single `amount` tag. Per-recipient amounts are not encoded in the event — clients that need them recompute them from the on-chain transaction during verification (each `p` tag's derived Taproot address is matched against tx outputs).
|
||||
|
||||
For community-level zaps, clients MAY include the community addressable coordinate in an `a` tag and the community kind in a `K` tag:
|
||||
|
||||
```json
|
||||
[
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["p", "<recipient-1-pubkey>"],
|
||||
["p", "<recipient-2-pubkey>"],
|
||||
["amount", "5000"],
|
||||
["a", "34550:<community-author>:<community-d-tag>"],
|
||||
["K", "34550"],
|
||||
["alt", "Bitcoin zap: 5000 sats across 2 recipients"]
|
||||
]
|
||||
```
|
||||
|
||||
The `amount` tag MUST be the sum of outputs paying the listed recipients; it MUST NOT include the sender's change output.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
**Querying onchain zaps for an event:**
|
||||
@@ -119,23 +228,37 @@ If neither `e` nor `a` is present, the zap targets the recipient's **profile** (
|
||||
{ "kinds": [8333], "#e": ["<target-event-id>"], "limit": 100 }
|
||||
```
|
||||
|
||||
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps, use `"#p": ["<pubkey>"]`.
|
||||
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps targeting a specific user, use `"#p": ["<pubkey>"]` — this matches both single-recipient events tagging that user and multi-recipient events where the user is one of several recipients.
|
||||
|
||||
**Verification (REQUIRED before trusting amounts):**
|
||||
|
||||
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
|
||||
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. Verification has two modes depending on the event shape:
|
||||
|
||||
*Identity-recipient mode* (the event has `p` tags — profile zaps, event zaps, community splits):
|
||||
|
||||
1. Extract the txid from the `i` tag.
|
||||
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
|
||||
3. Derive the recipient's expected Taproot address from the `p` tag pubkey.
|
||||
4. Sum the values of all outputs in the transaction that pay that address. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to the recipient.
|
||||
3. For each `p` tag, derive the recipient's expected Taproot address.
|
||||
4. Sum the values of all outputs in the transaction that pay any of the derived recipient addresses. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to listed recipients.
|
||||
|
||||
*Campaign-wallet mode* (the event has an `a` tag pointing at a kind 33863 campaign and no `p` tags):
|
||||
|
||||
1. Extract the txid from the `i` tag and the campaign coordinate from the `a` tag.
|
||||
2. Fetch the campaign event and read its `w` tag to get the campaign's declared bech32(m) wallet address. Reject the receipt if `w` is missing, malformed, or starts with `sp1` (silent-payment campaigns do not publish receipts; see Kind 33863).
|
||||
3. Fetch the transaction from a Bitcoin data source.
|
||||
4. Sum the values of all outputs in the transaction that pay the campaign's `w` address. This is the **verified amount**.
|
||||
|
||||
In both modes:
|
||||
|
||||
5. If the verified amount is 0, the event SHOULD be discarded.
|
||||
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
|
||||
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
|
||||
|
||||
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) equals the recipient pubkey from the `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals.
|
||||
When a client needs to attribute a multi-recipient event's amount to one specific recipient (e.g. rendering a profile zap-history entry), it MAY sum only the tx outputs paying that one recipient's derived Taproot address. Per-recipient amounts are not stored in the event — they are recomputed from the transaction at display time.
|
||||
|
||||
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per (txid, target) pair is canonical — when multiple events reference the same `txid` for the same target, the earliest is preferred.
|
||||
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) appears in any `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals. Outputs in the underlying transaction that pay the sender's own derived Taproot address are change outputs and MUST NOT be counted toward the verified amount regardless of the tag set.
|
||||
|
||||
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per `txid` is canonical — when multiple events reference the same `txid`, the earliest is preferred.
|
||||
|
||||
**Network scope:** This specification applies to Bitcoin **mainnet** only. Testnet, signet, and other networks are out of scope; addresses and txids on those networks MUST NOT be used in kind 8333 events.
|
||||
|
||||
@@ -155,42 +278,422 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
|
||||
|
||||
---
|
||||
|
||||
## Standard NIPs: Direct Messaging
|
||||
## Kind 33863: Campaign
|
||||
|
||||
This application implements encrypted direct messaging using two standard Nostr protocols:
|
||||
### Summary
|
||||
|
||||
### NIP-04 (Legacy Encrypted DMs)
|
||||
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Kind | 4 |
|
||||
| Spec | [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) |
|
||||
The author of the event is also the beneficiary. Campaigns are never authored on behalf of someone else; the event creator owns the wallet declared in `w` and receives the donations. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate.
|
||||
|
||||
Legacy encrypted direct messages. Content is encrypted with AES-256-CBC using a shared secret derived from the sender's private key and recipient's public key. The recipient is identified by a `p` tag.
|
||||
The kind is addressable so the creator can edit the story, banner, goal, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
|
||||
|
||||
Used for backward compatibility with older Nostr clients that do not support NIP-17.
|
||||
### Event Structure
|
||||
|
||||
### NIP-17 (Private Direct Messages)
|
||||
```json
|
||||
{
|
||||
"kind": 33863,
|
||||
"pubkey": "<creator-pubkey>",
|
||||
"content": "<markdown story>",
|
||||
"tags": [
|
||||
["d", "save-the-last-bookstore"],
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Kinds | 1059 (Gift Wrap), 1060 (Seal) |
|
||||
| Spec | [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) |
|
||||
["title", "Save the Last Bookstore"],
|
||||
["summary", "Help our 40-year-old neighborhood bookstore make rent through winter."],
|
||||
["banner", "https://blossom.example/abc123.jpg"],
|
||||
["imeta",
|
||||
"url https://blossom.example/abc123.jpg",
|
||||
"m image/jpeg",
|
||||
"x abc123def456...",
|
||||
"dim 1600x900",
|
||||
"blurhash LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
|
||||
"alt Storefront of the Last Bookstore at dusk"
|
||||
],
|
||||
["alt", "Fundraising campaign: Save the Last Bookstore"],
|
||||
|
||||
Modern private direct messages using the Gift Wrap protocol. Messages are triple-layered:
|
||||
["w", "bc1p7w2k3xq9...xyz"],
|
||||
["w", "sp1qq...verylongsilentpaymentcode..."],
|
||||
|
||||
1. **Rumor** (kind 14) — unsigned plaintext message
|
||||
2. **Seal** (kind 13) — rumor encrypted to the recipient, signed by the sender
|
||||
3. **Gift Wrap** (kind 1059) — seal encrypted to the recipient, signed by a random ephemeral key
|
||||
["goal", "25000"],
|
||||
|
||||
This provides metadata protection: relays and observers cannot determine the sender, recipient, or content. The application uses NIP-17 as the default send protocol, with optional NIP-04 compatibility for older clients.
|
||||
["i", "iso3166:US"],
|
||||
["k", "iso3166"],
|
||||
["t", "legal-defense"],
|
||||
["t", "mutual-aid"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Protocol Configuration
|
||||
A silent-payment-only campaign omits the `bc1…` `w` tag and carries only the `sp1…`:
|
||||
|
||||
Users can configure their preferred send protocol via Settings > Messages:
|
||||
```json
|
||||
["w", "sp1qq...verylongsilentpaymentcode..."]
|
||||
```
|
||||
|
||||
- **NIP-17 only** (default) — maximum privacy, only modern clients can read
|
||||
- **NIP-04 + NIP-17** — sends via both protocols for compatibility with legacy clients
|
||||
An on-chain-only campaign omits the `sp1…` `w` tag and carries only the `bc1…`:
|
||||
|
||||
```json
|
||||
["w", "bc1p7w2k3xq9...xyz"]
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is the **campaign story**, formatted as Markdown. Clients SHOULD render it with the same Markdown renderer they use for NIP-23 long-form content. Empty content is permitted (e.g. for a campaign that lives entirely in its summary).
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|-----------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `d` | Yes | Campaign slug, unique per author. Forms the addressable coordinate `33863:<pubkey>:<d>`. |
|
||||
| `title` | Yes | Display title of the campaign (plain text, max ~200 chars). |
|
||||
| `w` | Yes | Bitcoin wallet endpoint. The 2nd element is a single bech32(m) string: a mainnet on-chain address starting with `bc1q` (P2WPKH/P2WSH) or `bc1p` (P2TR), **or** a silent-payment code starting with `sp1` per BIP-352. A campaign MUST carry at least one `w` tag and MAY carry up to two — at most one per mode (on-chain and silent payment). |
|
||||
| `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. |
|
||||
| `banner` | Recommended | HTTPS URL of the wide banner image. Clients MUST sanitize the URL (see `sanitizeUrl()` in `nostr-security`) before rendering, and SHOULD pair the URL with a NIP-92 `imeta` tag for dimensions, blurhash, MIME type, and SHA-256. |
|
||||
| `imeta` | Recommended | NIP-92 media metadata for the banner. The first `url <value>` pair MUST match the `banner` URL; clients SHOULD ignore an `imeta` whose URL does not match. |
|
||||
| `goal` | Optional | Fundraising goal in **integer US Dollars** (no unit suffix, no decimals). Clients MAY display an estimated sat-equivalent at view time using a live exchange rate. |
|
||||
| `i` | Recommended | NIP-73 country identifier. SHOULD be `iso3166:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
|
||||
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
|
||||
| `t` | Optional | User-entered discovery/category tags. Agora also adds `t:agora` as the app marker; other `t` values are freeform topics such as `legal-defense` or `mutual-aid`. |
|
||||
| `alt` | Recommended | NIP-31 human-readable fallback. |
|
||||
|
||||
### Wallet Modes
|
||||
|
||||
The prefix of each `w` value selects one of two donation modes. Clients MUST detect the mode from the prefix; the event carries no other mode discriminator. When a campaign carries both an on-chain and a silent-payment endpoint, the client SHOULD present a single combined QR (see "Combined QR" below) so a scan offers the donor's wallet whichever endpoint it supports, while still rendering on-chain aggregate UI from the on-chain endpoint and the silent-payment privacy notice from the silent-payment endpoint.
|
||||
|
||||
| Prefix | Mode | Description |
|
||||
|---------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `bc1q…` / `bc1p…` | On-chain | Public mainnet bech32(m) address. Donations are traceable; clients show a progress bar, total raised, and donation list. |
|
||||
| `sp1…` | Silent payment | BIP-352 silent-payment code. Donations are **unlinkable by design**. Clients MUST hide all aggregate totals and progress UI (see below). |
|
||||
|
||||
Other prefixes (`tb1…`, `bcrt1…`, `tsp1…`, lightning invoices, etc.) MUST be rejected at parse time; the campaign does not render. A campaign carrying two `w` tags of the same mode (e.g., two `bc1…` addresses) is invalid and MUST NOT render — only one endpoint per mode is permitted.
|
||||
|
||||
Clients SHOULD validate the bech32(m) checksum of each `w` value, not just its prefix.
|
||||
|
||||
### Combined QR
|
||||
|
||||
When a campaign declares both endpoints, clients SHOULD render a single BIP-21 URI that combines them:
|
||||
|
||||
```
|
||||
bitcoin:<bc1-address>?sp=<sp1-code>
|
||||
```
|
||||
|
||||
BIP-352-aware wallets pick the `sp=` parameter and use the silent-payment flow; legacy wallets fall back to the on-chain address. Clients MAY also surface each endpoint's raw string as a copyable affordance so donors who prefer one over the other can choose explicitly. A single-endpoint campaign uses the standard form: `bitcoin:<bc1-address>` (on-chain only) or `bitcoin:?sp=<sp1-code>` (silent payment only).
|
||||
|
||||
### Client Behavior by Mode
|
||||
|
||||
Each endpoint type drives its own UI elements independently. A dual-endpoint campaign shows the on-chain aggregate UI (computed from the on-chain endpoint) **and** the silent-payment privacy notice (because at least some donations may flow through the SP endpoint and not be visible in any aggregate).
|
||||
|
||||
| UI element | On-chain (`bc1`) present | Silent payment (`sp1`) present |
|
||||
|-----------------------------|-----------------------------------------------------------------|-------------------------------------------------------|
|
||||
| QR code | bech32(m) address in BIP-21 `bitcoin:` URI | SP code in BIP-21 `?sp=` extension (combined with on-chain address when both are present) |
|
||||
| "Raised X" / progress bar | Shown, computed from cumulative `chain_stats.funded_txo_sum` on the on-chain `w` address via an Esplora endpoint (default mempool.space). Kind 8333 receipts are **not** the source of this total — donors paying the BIP-21 QR with a native wallet would otherwise be missed. | **Not contributed.** When the on-chain endpoint is absent, aggregate UI is hidden entirely. |
|
||||
| Donor / recent-donation list| Shown, populated from verified kind 8333 receipts against the on-chain address (attribution only — these do not feed the headline total). | **Not contributed.** |
|
||||
| Goal display | Shown as USD target with optional sat-equivalent estimate | Shown as USD target; no progress computation when on-chain endpoint is absent |
|
||||
| Donation receipt published | Donor's client publishes a kind 8333 receipt against the on-chain endpoint (see below) | **No receipt published.** Publishing one would defeat SP unlinkability and is forbidden. |
|
||||
|
||||
For campaigns with **only** a silent-payment endpoint (no on-chain endpoint), clients MUST NOT attempt to scan the chain, MUST NOT publish receipts, and MUST NOT display any aggregate that could leak donation activity. For dual-endpoint campaigns, the on-chain aggregate UI is permitted but clients SHOULD render a privacy notice indicating that silent-payment donations are not reflected in the totals.
|
||||
|
||||
### Donation Flow — On-chain (`bc1`)
|
||||
|
||||
1. Donor opens the campaign and chooses an amount.
|
||||
2. Donor's client constructs and broadcasts a Bitcoin transaction paying the campaign's `w` address.
|
||||
3. After broadcast, the donor's client publishes a single kind 8333 receipt:
|
||||
|
||||
```json
|
||||
[
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["amount", "<sats-paid-to-campaign-wallet>"],
|
||||
["a", "33863:<campaign-author-pubkey>:<campaign-d-tag>"],
|
||||
["K", "33863"],
|
||||
["alt", "Donation to <campaign-title>: <total-amount> sats"]
|
||||
]
|
||||
```
|
||||
|
||||
The receipt MUST NOT carry `p` tags — campaigns are not Nostr-identity recipients. The `amount` tag is the sum of tx outputs paying the campaign's `w` address (excluding the donor's change output).
|
||||
|
||||
4. The receipt is published **after** the tx is broadcast; the txid is already final at that point. A receipt-publish failure does not roll back the donation — the on-chain transaction stands.
|
||||
|
||||
### Donation Flow — Silent Payment (`sp1`)
|
||||
|
||||
1. Donor opens the campaign and chooses an amount.
|
||||
2. Donor's client uses the campaign's SP code to derive a fresh, one-time Taproot output script per BIP-352.
|
||||
3. Donor broadcasts a Bitcoin transaction paying that derived output.
|
||||
4. **No Nostr event is published.** The campaign owner discovers the donation by scanning the chain locally with their SP private key.
|
||||
|
||||
Silent-payment unlinkability is the entire point of this mode. Clients MUST NOT publish receipts, MUST NOT advertise the donation in any other Nostr event (replies, mentions, etc.) on the donor's behalf, and MUST NOT correlate the donor's pubkey with the campaign in any persisted client telemetry.
|
||||
|
||||
### Querying
|
||||
|
||||
**List campaigns (newest first):**
|
||||
|
||||
```json
|
||||
{ "kinds": [33863], "limit": 50 }
|
||||
```
|
||||
|
||||
**Fetch a specific campaign:**
|
||||
|
||||
```json
|
||||
{ "kinds": [33863], "authors": ["<creator-pubkey>"], "#d": ["<slug>"], "limit": 1 }
|
||||
```
|
||||
|
||||
**Compute the "raised" total:**
|
||||
|
||||
The headline "raised" amount for an on-chain campaign MUST be sourced **directly from the campaign's on-chain `w` address**, not by aggregating kind 8333 receipts. Specifically, clients SHOULD query a mempool.space-compatible Esplora endpoint for the address and use the cumulative `chain_stats.funded_txo_sum` (plus `mempool_stats.funded_txo_sum` if surfacing pending donations) as the total raised.
|
||||
|
||||
This is the source of truth for three reasons:
|
||||
|
||||
1. **Completeness.** Donors who pay the BIP-21 QR with a native wallet do not publish a kind 8333 receipt. Aggregating receipts would undercount the campaign.
|
||||
2. **Forgery resistance.** A single Esplora `GET /address/<addr>` call cannot be spoofed by Nostr publishers, whereas verifying 500+ receipts against the chain is slower and more brittle (relay availability, pagination, replay attempts).
|
||||
3. **Stability.** `funded_txo_sum` is cumulative — it does not regress when the beneficiary spends from the address.
|
||||
|
||||
**List individual donations (for the recent-donations sidebar):**
|
||||
|
||||
```json
|
||||
{ "kinds": [8333], "#a": ["33863:<creator-pubkey>:<slug>"], "limit": 500 }
|
||||
```
|
||||
|
||||
Kind 8333 receipts are used **only** to attribute individual donations to Nostr identities (donor pubkey, comment, timestamp) — not to compute the campaign total. Clients MUST still verify each receipt on-chain per the *Campaign-wallet mode* verification rules in the kind 8333 section before displaying it.
|
||||
|
||||
**Filter by country:**
|
||||
|
||||
```json
|
||||
{ "kinds": [33863], "#i": ["iso3166:VE"], "limit": 50 }
|
||||
```
|
||||
|
||||
**Fetch pinned event comments:**
|
||||
|
||||
Event owners MAY pin important comments or activity feed events with a NIP-78 app-specific data event (`kind: 30078`) authored by the root event owner. The `d` tag is scoped to the root event coordinate. Agora uses this for campaigns (`33863`), pledges (`36639`), organizations (`34550`), and calendar events (`31922` / `31923`).
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "<root-event-author-pubkey>",
|
||||
"content": "{\"pinnedEvents\":[\"<event-id-2>\",\"<event-id-1>\"]}",
|
||||
"tags": [
|
||||
["d", "agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"],
|
||||
["a", "<kind>:<root-event-author-pubkey>:<d-tag>"],
|
||||
["k", "<kind>"],
|
||||
["alt", "Pinned event comments"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients SHOULD query the pin list with:
|
||||
|
||||
```json
|
||||
{ "kinds": [30078], "authors": ["<root-event-author-pubkey>"], "#d": ["agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"], "limit": 1 }
|
||||
```
|
||||
|
||||
The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned event removes it. Clients SHOULD ignore pin lists not authored by the root event owner.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
- **Wallet validity:** clients MUST reject events that carry no `w` tag, that carry more than one `w` tag of the same mode (e.g., two `bc1…` addresses), or whose `w` values fail bech32(m) checksum validation for one of the supported prefixes. Invalid campaigns do not render.
|
||||
- **Editability:** the creator MAY republish the same `(33863, pubkey, d)` triple to update any field, including the `w` wallet endpoint. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention).
|
||||
- **Closing a campaign:** there is no `status` tag. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate. Clients SHOULD honor the deletion by removing the campaign from discovery feeds. Historical kind 8333 receipts MAY still be rendered against the (now-deleted) campaign coordinate so donors can find their past donations.
|
||||
- **Categories:** clients MAY use user-entered `t` tags for topic filtering and discovery. Agora reserves `t:agora` as its app marker but does not reserve any other topic namespace.
|
||||
- **Migration:** kind 33863 has no relationship to any earlier campaign kind. Clients MUST NOT read, merge, or migrate events of any other kind into the kind 33863 namespace.
|
||||
|
||||
### Agora Moderation Labels
|
||||
|
||||
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
|
||||
Campaigns, organizations, and pledges share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the three streams is the kind prefix on the `a` tag of each label:
|
||||
|
||||
- `33863:<author-pubkey>:<d>` — campaign (kind 33863, see "Open Campaigns" above).
|
||||
- `34550:<author-pubkey>:<d>` — organization (kind 34550, NIP-72 community definition).
|
||||
- `36639:<author-pubkey>:<d>` — pledge (kind 36639, see "Pledge" below).
|
||||
|
||||
A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. A client surfacing pledges MUST filter to `36639:`. Mixing the streams would let a moderator's `featured` label on a campaign appear to feature an unrelated pledge with the same `d` tag, or any other cross-surface bleed.
|
||||
|
||||
#### Namespace
|
||||
|
||||
```
|
||||
agora.moderation
|
||||
```
|
||||
|
||||
Each label event carries the namespace twice, per NIP-32:
|
||||
|
||||
- A capital-`L` "namespace" tag (relay-indexed for queries).
|
||||
- A lowercase `l` tag where the 2nd element is the label value and the 3rd is the namespace.
|
||||
|
||||
#### Label values
|
||||
|
||||
Two independent axes are defined; the newest moderator-signed label per axis per coordinate wins. All three surfaces (campaigns, organizations, pledges) use the same two axes — every Agora-tagged entity is publicly visible by default, and moderation reduces to suppressing unwanted entries (`hide`) and lifting curated ones into a featured row (`featured`).
|
||||
|
||||
| Axis | Values | Surfaces | Meaning |
|
||||
|----------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
|
||||
| hide | `hidden`, `unhidden` | campaigns, organizations, pledges | `hidden` suppresses the target everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
|
||||
| featured | `featured`, `unfeatured` | campaigns, organizations, pledges | `featured` places the target in a hand-picked Featured row. `unfeatured` retracts. |
|
||||
|
||||
> **Legacy `approved` / `unapproved` labels.** A previous revision of this spec defined a third axis ("approval") used only by campaigns to gate which campaigns appeared on the home page. The axis was retired once `featured` became the single positive-curation mechanism on the home page. Clients MUST ignore `approved` / `unapproved` labels and SHOULD NOT publish new ones. Existing labels in relay archives are dead data.
|
||||
|
||||
Surfacing rules (hide always wins):
|
||||
|
||||
**Campaigns**
|
||||
|
||||
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank (see Moderator-driven Ordering), descending.
|
||||
- **Discover shelf on `/campaigns`** — iff the latest hide label is not `hidden`. Every non-hidden campaign on the network is enumerable here; the home page's Featured row is a curated subset, not a gate.
|
||||
- **Moderator-only "Hidden"** — iff hidden. Surfaces the suppressed set so moderators can unhide.
|
||||
|
||||
**Organizations**
|
||||
|
||||
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank, descending (see Moderator-driven Ordering).
|
||||
- **"My organizations" shelf on `/communities`** — intentionally ignores all moderation labels. A user's own founded, moderated, or followed organizations always render regardless of label state.
|
||||
- **Moderator-only "Needs review"** — iff `t:agora` AND not featured AND not hidden. Surfaces orgs minted through Agora's create flow that haven't been triaged into Featured or Hidden yet.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
- **Hide enforcement on other organization discovery surfaces** — clients SHOULD suppress `hidden` organizations from any future "All organizations" / browse surface for non-moderators. Moderators MAY see hidden organizations with a "Hidden" treatment so they can unhide.
|
||||
|
||||
**Pledges**
|
||||
|
||||
- **Discovery surfaces on `/pledges`** — non-moderators MUST NOT see `hidden` pledges in the active / upcoming / past sections, the search results grid, or any future browse surface. Moderators MAY opt-in to seeing hidden pledges via a Show-hidden toggle so they can unhide.
|
||||
- **Author-own surfaces** — a pledge author's own pledges in their profile always render regardless of moderation state. Moderation governs public discovery, not authorship.
|
||||
- **Direct-URL access** — a pledge's detail page (`/<naddr>`) renders regardless of moderation state. Hidden pledges remain reachable by anyone who has the link; moderation only governs which surfaces enumerate them.
|
||||
- **Featured** — reserved for a future curated pledge shelf. The `featured` axis is defined for symmetry with campaigns/organizations, and clients MAY use it when implementing such a shelf.
|
||||
|
||||
#### Moderator-driven Ordering
|
||||
|
||||
The Featured row is sorted by the **effective rank** of the moderator's latest `featured` label per campaign coordinate, descending.
|
||||
|
||||
A label's effective rank is the numeric value of its `["rank", "<number>"]` tag if present, falling back to the label's `created_at` when no rank tag is set. Labels published before this feature existed — and any normal hide / feature actions that don't carry a rank — surface with their `created_at` as the effective rank, so newer feature actions naturally float to the top.
|
||||
|
||||
The fold rule per `(coord, axis)` is unchanged: the newest event by `created_at` wins. Encoding order in the `created_at` itself would conflict with that rule the moment a moderator tried to lower a campaign's position — the new label would have an older `created_at` than the existing one and lose the fold. The rank tag decouples sort key from event recency so reorder publishes always use `created_at = now` and the fold always picks them up.
|
||||
|
||||
A moderator MAY reorder the row by republishing the `featured` label for a campaign with a `rank` tag carrying a chosen integer. Three operations cover the common cases:
|
||||
|
||||
- **Move to top** — publish with `rank = max(freshRank, currentTopRank + 1)`, where `freshRank` is a strictly-monotonic integer the client SHOULD source from current wall-clock time at sub-second resolution (Agora uses `Date.now() * 1000`). The `max` guard handles a (rare) clock-skewed existing rank that's already above `freshRank`.
|
||||
- **Move up by one** — publish with `rank = neighborAbove.rank + 1`, where `neighborAbove` is the label sorted directly above the campaign being moved.
|
||||
- **Move down by one** — publish with `rank = neighborBelow.rank - 1`. Only the moved campaign's label is republished; the neighbor below is untouched.
|
||||
|
||||
A general "drop at index `j`" (e.g. drag-and-drop in a moderator UI) is implemented by computing the two new neighbors of the moved campaign in the rearranged list and choosing any integer rank strictly between their ranks. When the gap is too tight (`prev.rank - next.rank < 2`), clients SHOULD pick `next.rank + 1` and accept that the rendered list may briefly be off by one until the next reorder leaves a wider gap. Using a sub-second-resolution `freshRank` keeps inter-rank gaps wide enough for many midpoint inserts before any renumbering is needed.
|
||||
|
||||
The conflict model matches the rest of the moderation namespace: the newest label per `(coord, axis)` from any moderator wins. Concurrent reorders by two moderators resolve to whoever's publish lands later; clients SHOULD refetch labels after a reorder publish to surface the authoritative order.
|
||||
|
||||
Reorder labels remain valid moderation labels in every other respect. Clients that don't recognize the `rank` tag simply read the label's axis state and ignore the rank — the labels are not a separate kind, not a separate namespace, and not a new tag namespace. Non-Agora clients see exactly the same hide / feature state they always have.
|
||||
|
||||
The featured row is the only Agora surface that uses moderator-driven ordering today. The same mechanism MAY be applied to the organization or pledge featured shelves if those grow a curation UI; until then, those shelves sort by `created_at` (the legacy behavior, identical to using a missing rank tag).
|
||||
|
||||
#### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "featured", "agora.moderation"],
|
||||
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
|
||||
["alt", "Campaign moderation: featured"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
An organization label has the same shape with a kind 34550 `a` tag:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "featured", "agora.moderation"],
|
||||
["a", "34550:<author-pubkey>:<organization-d-tag>"],
|
||||
["alt", "Organization moderation: featured"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A pledge label has the same shape with a kind 36639 `a` tag:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "hidden", "agora.moderation"],
|
||||
["a", "36639:<author-pubkey>:<pledge-d-tag>"],
|
||||
["alt", "Pledge moderation: hidden"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Required tags:
|
||||
|
||||
- `L` set to `agora.moderation`.
|
||||
- `l` with the label value as the 2nd element and `agora.moderation` as the 3rd.
|
||||
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization, `36639:<pubkey>:<d>` for a pledge).
|
||||
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it.
|
||||
|
||||
Optional tags:
|
||||
|
||||
- `rank` — single string element parsed as an integer. Used on `featured` labels to position the target within the moderator-curated Featured row; see Moderator-driven Ordering above. Labels without this tag sort by `created_at` (descending), which is the correct behavior for all non-reorder uses.
|
||||
|
||||
A label with a rank tag looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "featured", "agora.moderation"],
|
||||
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
|
||||
["rank", "1700000000123000"],
|
||||
["alt", "Campaign moderation: featured"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Trust Model
|
||||
|
||||
Only label events authored by current members of the **Team Soapbox** follow pack are honored. The pack is a kind 39089 (NIP-51 follow pack) addressable event:
|
||||
|
||||
```
|
||||
kind: 39089
|
||||
pubkey: 932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d
|
||||
d-tag: k4p5w0n22suf
|
||||
```
|
||||
|
||||
The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
|
||||
|
||||
- Self-promotion is impossible unless the pack author has added you.
|
||||
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive on the Featured row only by their labels fall off the row until another moderator features them.
|
||||
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
|
||||
|
||||
The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace.
|
||||
|
||||
#### Querying
|
||||
|
||||
Step 1 — fetch the pack:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [39089],
|
||||
"authors": ["932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d"],
|
||||
"#d": ["k4p5w0n22suf"],
|
||||
"limit": 1
|
||||
}
|
||||
```
|
||||
|
||||
Step 2 — fetch label events from pack members in the namespace:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [1985],
|
||||
"authors": ["<pack p-tag 1>", "<pack p-tag 2>", "..."],
|
||||
"#L": ["agora.moderation"],
|
||||
"limit": 2000
|
||||
}
|
||||
```
|
||||
|
||||
Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the relevant kind prefix (`33863:` for campaigns or `34550:` for organizations). Then fetch the targeted events themselves — one filter per author (bundled in a single REQ) keyed by their d-tags.
|
||||
|
||||
#### Client Behavior
|
||||
|
||||
- Clients SHOULD render hide/feature controls only for users whose pubkey appears in the pack.
|
||||
- Clients MAY display "Hidden" badges on hidden campaigns/organizations/pledges when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
|
||||
- Authors' own campaigns, organizations, and pledges are visible at their NIP-19 routes regardless of moderation state. The campaign URL remains live and donatable even when the campaign is not on the home page's Featured row.
|
||||
|
||||
---
|
||||
|
||||
@@ -290,22 +793,19 @@ After resolution (assuming `$follows` = `["pk1", "pk2"]`):
|
||||
|
||||
---
|
||||
|
||||
## Kind 36639: Activist Action
|
||||
## Kind 36639: Pledge
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event kind for publishing **activist actions** (called "challenges" internally for backwards compatibility). An action is a country-scoped task — take a photo, make art, gather information, or take direct action — with an optional sats bounty paid out via NIP-57 zaps to the best **submissions**.
|
||||
Addressable event kind for publishing **pledges**. A pledge is donor intent to fund a concrete action, evidence request, or outcome — take a photo, make art, gather information, clean a beach, or take direct action — with an optional country scope, optional community scope, and a sats-denominated pledge amount paid out via zaps or donation receipts to the best **submissions**.
|
||||
|
||||
Submissions are **NIP-22 comments** (kind 1111) authored under the action's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
|
||||
Submissions are **NIP-22 comments** (kind 1111) authored under the pledge's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
|
||||
|
||||
### Trust model
|
||||
|
||||
Anyone can publish a kind 36639 event, but clients SHOULD only display actions whose author is either:
|
||||
Pledges are user-generated. Anyone can publish a kind 36639 event, and Agora displays valid pledges without platform-admin or country-organizer author filtering.
|
||||
|
||||
1. A platform-level admin (see `src/lib/admins.ts`), or
|
||||
2. An organizer for the action's country (see kind 30078 `agora-organizers`).
|
||||
|
||||
This authorization model is identical to the per-country pin model — see Kind 30078 in this document for the storage shape.
|
||||
Community-scoped pledges inherit the community's moderation context. Clients rendering a specific community SHOULD query by the community `A` tag and apply that community's moderation and membership filters.
|
||||
|
||||
### Event Structure
|
||||
|
||||
@@ -316,14 +816,17 @@ This authorization model is identical to the per-country pin model — see Kind
|
||||
"tags": [
|
||||
["d", "plant-a-tree-1729000000000"],
|
||||
["title", "Plant a tree in your neighborhood"],
|
||||
["challenge-type", "photo"],
|
||||
["bounty", "10000"],
|
||||
["i", "iso3166:US"],
|
||||
["A", "34550:<community-pubkey>:<community-d-tag>"],
|
||||
["K", "34550"],
|
||||
["P", "<community-pubkey>"],
|
||||
["t", "agora-action"],
|
||||
["t", "tree-planting"],
|
||||
["t", "local-action"],
|
||||
["image", "https://example.com/cover.jpg"],
|
||||
["start", "1729000000"],
|
||||
["deadline", "1729604800"],
|
||||
["alt", "Agora activist action: Plant a tree in your neighborhood"]
|
||||
["alt", "Agora pledge: Plant a tree in your neighborhood"]
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -334,30 +837,36 @@ This authorization model is identical to the per-country pin model — see Kind
|
||||
|------------------|----------|----------------------------------------------------------------------------------------------------------|
|
||||
| `d` | Yes | Unique identifier (typically slug + timestamp). Forms the addressable coordinate `36639:<pubkey>:<d>`. |
|
||||
| `title` | Yes | Short title shown on cards. |
|
||||
| `challenge-type` | Yes | One of `photo`, `art`, `info`, `action`. Drives the display icon and submission expectations. |
|
||||
| `bounty` | Yes | Bounty in **sats**, as an unsigned integer string. Paid out via zaps to the chosen submission(s). |
|
||||
| `i` | Yes | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. |
|
||||
| `t` | Yes | Discovery tag. Canonical write value is `agora-action`. Read aliases: `pathos-challenge`, `agora-challenge`. |
|
||||
| `bounty` | Yes | Pledge amount in **sats**, as an unsigned integer string. Paid out via zaps or donation receipts to chosen submission(s). |
|
||||
| `i` | No | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. |
|
||||
| `A` | No | Community root coordinate for community-scoped pledges, e.g. `34550:<pubkey>:<d-tag>`. |
|
||||
| `K` | No | Root kind hint for community-scoped pledges. Use `34550` when `A` points to a NIP-72 community. |
|
||||
| `P` | No | Root author hint for community-scoped pledges. Use the community definition author pubkey. |
|
||||
| `t` | Yes | Discovery and category tags. Canonical write value includes `agora-action`; additional `t` tags are optional hashtags/categories. Read aliases: `pathos-challenge`, `agora-challenge`. |
|
||||
| `image` | No | Cover image URL. |
|
||||
| `start` | No | Unix timestamp when the action becomes active. Defaults to `created_at`. |
|
||||
| `deadline` | No | Unix timestamp when the action expires. Defaults to `start + 48h`. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora activist action: <title>"`. |
|
||||
| `start` | No | Legacy. Unix timestamp when the pledge becomes active. Defaults to `created_at`. New pledges omit it; the `created_at` is the start. |
|
||||
| `deadline` | No | Optional Unix timestamp when the pledge expires. Omit for open-ended pledges. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora pledge: <title>"`. |
|
||||
|
||||
### Content
|
||||
|
||||
Long-form description of the action. Plain text or light markdown. Clients render this as the action's body on the detail page.
|
||||
Long-form description of the pledge. Plain text or light markdown. Clients render this as the pledge's body on the detail page.
|
||||
|
||||
### Categories
|
||||
|
||||
Clients SHOULD use optional `t` tags for filtering and discovery instead of the deprecated `challenge-type` tag. Suggested user-entered tags include values like `beach-cleanup`, `protest-documentation`, `internet-blackout`, `legal-defense`, or `mutual-aid`.
|
||||
|
||||
### Submissions
|
||||
|
||||
Submissions are kind 1111 NIP-22 comments addressed to the action's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
|
||||
Submissions are kind 1111 NIP-22 comments addressed to the pledge's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
|
||||
|
||||
- Sort top-level submissions by **total zap amount** (sum of NIP-57 zap receipts on each submission), descending.
|
||||
- Show the bounty as the prize pool that organizers can distribute to top submissions via zaps.
|
||||
- Hide submissions with `created_at` after the action's `deadline` for "past" leaderboards (or surface them separately as "late submissions").
|
||||
- Sort top-level submissions by **total funded amount** (sum of kind 9735 zap receipts and kind 8333 donation receipts on each submission), descending.
|
||||
- Show the pledge amount, total funded, and remaining amount as a trust-based progress indicator. There is no escrow guarantee.
|
||||
- Hide submissions with `created_at` after the pledge's `deadline` for "past" leaderboards (or surface them separately as "late submissions"). Open-ended pledges have no deadline cutoff.
|
||||
|
||||
### Discovery
|
||||
|
||||
Clients querying actions globally:
|
||||
Clients querying pledges globally:
|
||||
|
||||
```json
|
||||
{ "kinds": [36639], "#t": ["agora-action", "pathos-challenge", "agora-challenge"], "limit": 50 }
|
||||
@@ -374,7 +883,17 @@ Per country:
|
||||
}
|
||||
```
|
||||
|
||||
After fetching, clients MUST filter the results down to events whose author is either an admin or an organizer for the event's country.
|
||||
Per community:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [36639],
|
||||
"#A": ["34550:<community-pubkey>:<community-d-tag>"],
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
|
||||
Country and community scopes are independent. A future action MAY include both `i` and `A`/`K`/`P` tags when both scopes are useful.
|
||||
|
||||
---
|
||||
|
||||
@@ -492,66 +1011,6 @@ After fetching, take the event with the highest `created_at` and parse it. Cache
|
||||
|
||||
---
|
||||
|
||||
## Kinds 20000 / 20001: Ephemeral Geo Chat
|
||||
|
||||
### Summary
|
||||
|
||||
Ephemeral events used to power realtime location-anchored chat on the world map. Both kinds live in NIP-01's ephemeral range (`20000 ≤ kind < 30000`), so relays MUST NOT persist them — they are short-lived signals only.
|
||||
|
||||
- **Kind 20000** — public chat message. The `content` field carries the message text.
|
||||
- **Kind 20001** — presence "heartbeat". Same tag schema, but `content` MAY be empty (the event simply broadcasts that someone is listening at the geohash).
|
||||
|
||||
This kind range is shared with the wider Bitchat / geo-chat ecosystem; Agora interoperates with Pathos and other clients producing the same shape.
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Purpose |
|
||||
|-----|----------|-------------------------------------------------------------------------|
|
||||
| `g` | Yes | Geohash anchoring the message. Any precision is allowed; the dialog filters by exact-match `g` value, while the map clusters by full geohash. |
|
||||
| `n` | No | Display nickname (≤ 16 chars after client-side truncation). Anonymous senders pick a random "ghost" handle; logged-in senders may use their account display name. |
|
||||
|
||||
Events without a `g` tag MUST be ignored — they cannot be plotted.
|
||||
|
||||
### Identity
|
||||
|
||||
There are two valid signing paths:
|
||||
|
||||
1. **Real identity** — a logged-in user signs with their existing Nostr key (typically via NIP-07 / NIP-46). Other clients can correlate the chat message with the author's public profile.
|
||||
2. **Ephemeral "ghost" identity** — the client generates a fresh in-memory keypair (never persisted) and signs locally. Only the chosen `n` nickname is persisted (in `localStorage`) so the user keeps a stable handle even though the pubkey rotates per session.
|
||||
|
||||
Clients SHOULD let logged-in users toggle between modes per-session and SHOULD default to the ghost mode when no account is available.
|
||||
|
||||
### Relay Routing
|
||||
|
||||
Because ephemeral events are not stored, latency dominates the experience. Clients SHOULD:
|
||||
|
||||
1. Always include a baseline of widely-reachable relays (`wss://nos.lol`, `wss://relay.damus.io`, `wss://relay.primal.net`).
|
||||
2. Augment with geo-located relays drawn from the [permissionlesstech/georelays](https://github.com/permissionlesstech/georelays) CSV catalogue (`relayUrl,latitude,longitude` per line).
|
||||
3. For a specific geohash conversation, prefer the relays nearest the decoded coordinates (Haversine distance, top-N).
|
||||
4. For the global map heatmap, take a rotating window (e.g. 8 relays, rotated every 5 minutes) so coverage spreads without saturating any single relay.
|
||||
|
||||
### Time Window
|
||||
|
||||
Clients SHOULD only surface events from the last hour (`since = now - 3600`). Older ephemeral events are uninteresting for "what's happening right now" and most relays will have dropped them anyway.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 20000,
|
||||
"created_at": 1734567890,
|
||||
"pubkey": "...",
|
||||
"tags": [
|
||||
["g", "u4pruydqqvj"],
|
||||
["n", "stealthranger4242"]
|
||||
],
|
||||
"content": "anyone in berlin tonight?",
|
||||
"sig": "..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flat Communities
|
||||
|
||||
Flat communities on Nostr, composed from existing event kinds. Communities have one membership badge, explicit moderators, and no recursive badge-chain authority.
|
||||
@@ -607,6 +1066,9 @@ A kind `34550` event defines the community, extending [NIP-72](https://github.co
|
||||
| `image` | No | Image URL. |
|
||||
| `a` | Yes (1) | Member badge definition reference with role marker `"member"`. |
|
||||
| `p` | No | Moderator pubkeys. The 4th element SHOULD be `"moderator"`. |
|
||||
| `i` | No | Agora extension: NIP-73 country identifier (`iso3166:XX`) for country-scoped group discovery. This is not part of NIP-72. |
|
||||
| `k` | Recommended if `i` is present | Agora extension: external content kind hint. Use `iso3166` for country identifiers. |
|
||||
| `t` | No | Agora extension: user-entered discovery/category tags. Agora also adds `t:agora` as the app marker. |
|
||||
| `relay` | No | Recommended relay URL for community content (per [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). |
|
||||
| `alt` | No | [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) description. |
|
||||
|
||||
@@ -632,6 +1094,10 @@ The fourth element is a strict protocol marker, not a display label. Communities
|
||||
["image", "https://example.com/clan-banner.jpg"],
|
||||
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member", "", "member"],
|
||||
["p", "<co-moderator-pubkey>", "", "moderator"],
|
||||
["i", "iso3166:US"],
|
||||
["k", "iso3166"],
|
||||
["t", "local-news"],
|
||||
["t", "mutual-aid"],
|
||||
["relay", "wss://relay.example.com"],
|
||||
["alt", "Community: The Arbiter's Guard"]
|
||||
]
|
||||
@@ -1120,3 +1586,84 @@ Albums are represented as kind 34139 playlist events with a `["t", "album"]` tag
|
||||
- Track ordering follows the order of `a` tags in the event
|
||||
- The same detail view, playback, and commenting features apply to both albums and playlists
|
||||
|
||||
---
|
||||
|
||||
## Agora HD Wallet Derivation
|
||||
|
||||
### Summary
|
||||
|
||||
Agora's Bitcoin wallet is hierarchical-deterministic and derived from the user's Nostr secret key (`nsec`). The user backs up either the nsec or the 24-word BIP-39 mnemonic — the mnemonic is a deterministic, one-way function of the nsec, so anyone with the nsec can regenerate the mnemonic at will.
|
||||
|
||||
This specification covers two derivation generations:
|
||||
|
||||
- **v2 (current)** — nsec → HKDF → BIP-39 24-word mnemonic → PBKDF2 → BIP-32 master seed. The resulting mnemonic imports into any BIP-39-compatible wallet (Sparrow, Electrum, Trezor, Ledger, BlueWallet, Phoenix, …) at the standard BIP-86 / BIP-352 paths.
|
||||
- **v1 (legacy, migration-only)** — nsec used directly as the BIP-32 master seed (`HDKey.fromMasterSeed(nsec_bytes)`). v1 and v2 produce different addresses for the same nsec.
|
||||
|
||||
### v2 Derivation
|
||||
|
||||
The v2 pipeline turns a 32-byte nsec into a 64-byte BIP-32 master seed in three steps:
|
||||
|
||||
```
|
||||
entropy = HKDF-SHA256(ikm = nsec_bytes,
|
||||
salt = "" (default per RFC 5869),
|
||||
info = "agora/v1",
|
||||
length = 32 bytes)
|
||||
mnemonic = BIP-39 encoding of (entropy || SHA256(entropy)[0]) // 24 words
|
||||
seed = PBKDF2-HMAC-SHA512(password = mnemonic,
|
||||
salt = "mnemonic",
|
||||
iterations = 2048,
|
||||
dkLen = 64)
|
||||
master = HDKey.fromMasterSeed(seed) // BIP-32 root
|
||||
```
|
||||
|
||||
The `"agora/v1"` HKDF info string is a versioning hook: changing it would derive a completely independent wallet from the same nsec. The `"mnemonic"` PBKDF2 salt is the literal BIP-39 default (no user passphrase).
|
||||
|
||||
#### Properties
|
||||
|
||||
- **Deterministic** — the same nsec always produces the same mnemonic, seed, and BIP-32 master.
|
||||
- **One-way** — the mnemonic is a hash of the nsec; an attacker who learns the mnemonic learns only the wallet, not the Nostr identity.
|
||||
- **Interoperable** — the resulting 24-word phrase is a standard BIP-39 mnemonic. Any BIP-39-compatible wallet can import it at the BIP-86 / BIP-352 paths and recover the same on-chain addresses.
|
||||
|
||||
### Address Derivation
|
||||
|
||||
Once the BIP-32 master is in hand, addresses derive at the standard paths:
|
||||
|
||||
#### BIP-86 (Taproot single-key, key-path-only)
|
||||
|
||||
```
|
||||
m/86'/0'/0'/<chain>/<index>
|
||||
```
|
||||
|
||||
- `chain ∈ {0, 1}` — `0` = receive, `1` = change.
|
||||
- `index` — advanced per receive (no address reuse).
|
||||
|
||||
Output script is P2TR with the derived x-only pubkey as `internalPubkey` (no tapscript tree).
|
||||
|
||||
#### BIP-352 (Silent Payments)
|
||||
|
||||
```
|
||||
m/352'/0'/0'/0'/0 // spend keypair
|
||||
m/352'/0'/0'/1'/0 // scan keypair
|
||||
```
|
||||
|
||||
The silent-payment address (`sp1q…`) is the bech32m encoding of `(scan_pubkey || spend_pubkey)` with version `0` and HRP `sp`. The address is **static** — a user publishes one `sp1q…` and reuses it; each sender derives a fresh, unlinkable Taproot output per payment.
|
||||
|
||||
### v1 → v2 Migration
|
||||
|
||||
The v1 derivation (`HDKey.fromMasterSeed(nsec_bytes)`) produces a different BIP-32 master than v2 for the same nsec, so a user upgrading from v1 to v2 has funds at addresses that the v2 wallet never scans. Agora ships a one-shot migration page (`/wallet/migrate-v1`) that:
|
||||
|
||||
1. Detects v1 funds by scanning the v1 xpub against the configured Blockbook indexer and reading the v1 silent-payment UTXO doc from the user's relays (NIP-78 d-tag `${appId}/hdwallet/sp-utxos`).
|
||||
2. If any v1 funds exist, builds a single sweep PSBT consuming every v1 BIP-86 UTXO + every v1 SP UTXO, with one output (`total − fee`) at the v2 wallet's first BIP-86 receive address.
|
||||
3. Signs every input using v1-derived keys (`HDKey.fromMasterSeed(nsec_bytes)`) and broadcasts via Blockbook.
|
||||
|
||||
The v1 derivation code is retained indefinitely so users can migrate at any time. New scans, sends, and receives always run against v2.
|
||||
|
||||
### NIP-78 Storage
|
||||
|
||||
Agora stores per-wallet auxiliary state as a NIP-78 encrypted addressable event (kind 30078, NIP-44 to the user's own pubkey). The v2 d-tag suffix is `hdwallet/sp-utxos/v2`; the legacy v1 d-tag is `hdwallet/sp-utxos`. The two are independent: v2 never writes to the v1 tag, and the v1 tag is read only by the migration sweep.
|
||||
|
||||
### Security Notes
|
||||
|
||||
- The nsec is both the Nostr identity secret and the wallet seed source. Anyone with the nsec controls both. The 24-word mnemonic is the wallet half of that secret and is safer to share with Bitcoin-side tools (it can't impersonate the user on Nostr).
|
||||
- The wallet is gated to nsec logins. Browser-extension (NIP-07) and remote-signer (NIP-46) logins do not expose the raw secret key, so the wallet cannot derive child keys and surfaces an "unsupported" state.
|
||||
- Spend signing happens locally in the browser using the derived BIP-32 leaves. The nsec never leaves the device.
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
NIP-DC
|
||||
======
|
||||
|
||||
Nostr Webxdc
|
||||
------------
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
This NIP defines how to share and run [webxdc](https://webxdc.org/) apps over Nostr. Webxdc apps are `.xdc` (ZIP) files containing sandboxed HTML5 applications. They are attached to regular Nostr events using `imeta` tags (NIP-92), and state is coordinated through a unique identifier.
|
||||
|
||||
This spec covers public webxdc communication only. Private communication may be addressed in a future update.
|
||||
|
||||
## Attachment
|
||||
|
||||
A webxdc app is attached to any event by including the `.xdc` file URL in the content and an `imeta` tag with MIME type `application/x-webxdc`.
|
||||
|
||||
The `imeta` tag SHOULD include a `webxdc` property with a randomly generated unique string. This serves as the coordination identifier for state updates and realtime channels. If omitted, the app can still run but state won't work.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1,
|
||||
"content": "Let's play chess! https://blossom.example.com/abc123.xdc",
|
||||
"tags": [
|
||||
["imeta",
|
||||
"url https://blossom.example.com/abc123.xdc",
|
||||
"m application/x-webxdc",
|
||||
"x a1b2c3d4e5f6...",
|
||||
"webxdc 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A webxdc MAY also be published as a kind `1063` (NIP-94) file metadata event:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1063,
|
||||
"content": "A collaborative chess game. Play with friends over Nostr!",
|
||||
"tags": [
|
||||
["url", "https://blossom.example.com/abc123.xdc"],
|
||||
["m", "application/x-webxdc"],
|
||||
["x", "a1b2c3d4e5f6..."],
|
||||
["alt", "Webxdc app: Chess"],
|
||||
["webxdc", "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Kind `4932`: State Update
|
||||
|
||||
A regular event carrying a state update, mapping to the webxdc [`sendUpdate()`](https://webxdc.org/docs/spec/sendUpdate.html) API. Updates are ordered by `created_at` and assigned serial numbers by the client.
|
||||
|
||||
### Tags
|
||||
|
||||
- `i`: The `webxdc` identifier from the originating event (required)
|
||||
- `alt`: NIP-31 human-readable description (required)
|
||||
- `info`: Short info message, max ~50 chars (optional)
|
||||
- `document`: Document name being edited (optional)
|
||||
- `summary`: Short summary text, e.g. "8 votes" (optional)
|
||||
|
||||
The optional tags correspond to fields in the webxdc `sendUpdate()` API.
|
||||
|
||||
### Content
|
||||
|
||||
JSON-serialized payload from `sendUpdate()`.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 4932,
|
||||
"content": "{\"move\":\"e2e4\",\"player\":\"white\"}",
|
||||
"tags": [
|
||||
["i", "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"],
|
||||
["alt", "Webxdc update"],
|
||||
["info", "White played e2-e4"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Kind `20932`: Realtime Data (Ephemeral)
|
||||
|
||||
An ephemeral event carrying realtime data, mapping to the webxdc [`joinRealtimeChannel`](https://webxdc.org/docs/spec/joinRealtimeChannel.html) API. Relays forward these to active subscribers but do not store them.
|
||||
|
||||
### Tags
|
||||
|
||||
- `i`: The `webxdc` identifier from the originating event (required)
|
||||
|
||||
### Content
|
||||
|
||||
Base64-encoded `Uint8Array` payload (max 128,000 bytes raw).
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 20932,
|
||||
"content": "SGVsbG8gZnJvbSBucHViMWFiYy4uLg==",
|
||||
"tags": [
|
||||
["i", "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Flow
|
||||
|
||||
1. A user uploads a `.xdc` file (e.g. to Blossom) and publishes an event with the URL in content and an `imeta` tag. The `imeta` SHOULD include a `webxdc` property.
|
||||
2. A client detects the `imeta` tag, downloads the `.xdc`, extracts it, and runs `index.html` in a sandboxed iframe or webview.
|
||||
3. `sendUpdate()` publishes a kind `4932` event with the `webxdc` identifier in an `i` tag.
|
||||
4. The client subscribes to kind `4932` events with `#i` matching the identifier and delivers them via `setUpdateListener()`.
|
||||
5. `joinRealtimeChannel()` subscribes to kind `20932` events with `#i` matching the identifier. `send()` publishes ephemeral kind `20932` events. `leave()` closes the subscription.
|
||||
6. `selfAddr` and `selfName` MAY map to the user's npub and display name, or any other values.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Webxdc apps MUST be sandboxed with no network access, per the [webxdc spec](https://webxdc.org/docs/spec/messenger.html).
|
||||
- Clients SHOULD verify the `.xdc` file hash (`x` tag) before running it.
|
||||
- All communication in this spec is public. Webxdc apps designed for private chats or small groups may not work as expected.
|
||||
- Webxdc apps have no access to Nostr signatures or identity verification. Any participant can claim to be anyone within the app. Apps should not rely on `selfAddr` or `selfName` for trust decisions.
|
||||
@@ -7,14 +7,14 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "pub.agora.app"
|
||||
namespace = "spot.agora.app"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "pub.agora.app"
|
||||
applicationId "spot.agora.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.14.4"
|
||||
versionName "2.8.6"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -10,6 +10,7 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-barcode-scanner')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-keyboard')
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
# Keep Capacitor classes (WebView JS bridge)
|
||||
-keep class com.getcapacitor.** { *; }
|
||||
-keep class pub.ditto.app.** { *; }
|
||||
-keep class spot.agora.app.** { *; }
|
||||
|
||||
# Keep WebView JS interfaces
|
||||
-keepclassmembers class * {
|
||||
@@ -19,6 +19,14 @@
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
|
||||
# Barcode scanner plugin (@capacitor/barcode-scanner -> OutSystems ionbarcode)
|
||||
# references Gson's @SerializedName, but Gson isn't on the release classpath.
|
||||
# Suppress the missing-class warning, keep the annotation attribute, and keep
|
||||
# the plugin's model classes so R8 doesn't strip/rename serialized fields.
|
||||
-dontwarn com.google.gson.**
|
||||
-keepattributes *Annotation*
|
||||
-keep class com.outsystems.plugins.barcode.** { *; }
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
@@ -60,4 +60,6 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<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" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package pub.ditto.app;
|
||||
package spot.agora.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
@@ -1,4 +1,4 @@
|
||||
package pub.ditto.app;
|
||||
package spot.agora.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
@@ -63,7 +63,7 @@ public class MainActivity extends BridgeActivity {
|
||||
private void handleNotificationIntent(Intent intent) {
|
||||
if (intent == null) return;
|
||||
Uri data = intent.getData();
|
||||
if (data != null && "ditto.pub".equals(data.getHost())) {
|
||||
if (data != null && "agora.spot".equals(data.getHost())) {
|
||||
String path = data.getPath();
|
||||
if (path != null && !path.isEmpty()) {
|
||||
// Wait for WebView to be ready, then navigate
|
||||
@@ -1,4 +1,4 @@
|
||||
package pub.ditto.app;
|
||||
package spot.agora.app;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
@@ -337,7 +337,7 @@ public class NostrPoller {
|
||||
if (manager == null) return;
|
||||
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
intent.setData(Uri.parse("https://ditto.pub/notifications"));
|
||||
intent.setData(Uri.parse("https://agora.spot/notifications"));
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(
|
||||
context, id, intent,
|
||||
@@ -1,4 +1,4 @@
|
||||
package pub.ditto.app;
|
||||
package spot.agora.app;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
@@ -83,7 +83,7 @@ public class NotificationRelayService extends Service {
|
||||
// + REQ + up to 5 events + EOSE + metadata fetch + disconnect.
|
||||
private static final long FETCH_WAKELOCK_TIMEOUT_MS = 30_000;
|
||||
|
||||
private static final String ACTION_FETCH = "pub.ditto.app.ACTION_FETCH";
|
||||
private static final String ACTION_FETCH = "spot.agora.app.ACTION_FETCH";
|
||||
|
||||
// Backoff bounds for relay connect failures (separate from alarm interval).
|
||||
private static final long INITIAL_BACKOFF_MS = 1_000;
|
||||
@@ -1,50 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="100"
|
||||
android:viewportHeight="100">
|
||||
|
||||
<!--
|
||||
Ditto logo from public/logo.svg.
|
||||
SVG viewBox is "-5 -10 100 100", so we shift all paths by (+5, +10)
|
||||
to place the origin at (0,0) for the 100x100 viewport.
|
||||
Then scale to 60% around the content center (50, 40) to fit within
|
||||
Android's adaptive icon safe zone (66% of 108dp).
|
||||
-->
|
||||
|
||||
<group
|
||||
android:translateX="5"
|
||||
android:translateY="10"
|
||||
android:scaleX="0.7"
|
||||
android:scaleY="0.7"
|
||||
android:pivotX="50"
|
||||
android:pivotY="40">
|
||||
|
||||
<!-- path1: bottom arc / bottom-right swash -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 71.719615,49.36907 -0.62891,0.37109 c -0.12891,0.07031 -0.26172,0.14844 -0.39062,0.21875 -3.9883,10.309 -14.008,17.617 -25.699,17.617 -4.1211,0 -8.0312,-0.89844 -11.539,-2.5391 -0.12891,0.03906 -0.26172,0.07031 -0.39063,0.10156 l -0.35156,0.08984 h -0.02734 l -0.25,0.05859 -0.07813,0.01953 -0.10938,0.03125 c -0.55859,0.12891 -1.1289,0.26172 -1.6992,0.39062 -0.10156,0.03125 -0.19922,0.05078 -0.30078,0.07031 l -0.30078,0.10156 -0.18359,0.0078 c -0.26953,0.05859 -1.3086,0.26953 -1.3086,0.26953 -0.28906,0.05859 -0.55859,0.10937 -0.82813,0.17187 4.9805,3.3086 10.961,5.2305 17.371,5.2305 15.059,0 27.699,-10.602 30.828,-24.738 -0.75,0.48828 -1.5195,0.96875 -2.2891,1.4414 -0.59375,0.36328 -1.2031,0.72656 -1.8242,1.0859 z" />
|
||||
|
||||
<!-- path2: small left accent dot/arc -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 30.926615,29.47807 c 0.36328,-0.48828 0.75,-0.95312 1.1523,-1.3828 0.75781,-0.80469 0.71484,-2.0703 -0.08984,-2.8281 -0.80469,-0.75781 -2.0703,-0.71484 -2.8281,0.08984 -0.50781,0.53906 -0.99219,1.125 -1.4492,1.7383 -0.65625,0.88672 -0.47266,2.1406 0.41406,2.7969 0.35938,0.26562 0.77344,0.39453 1.1875,0.39453 0.61719,0 1.2227,-0.27734 1.6133,-0.80859 z" />
|
||||
|
||||
<!-- path3: left vertical bar -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 26.742615,32.67807 c -1.0586,-0.3125 -2.1719,0.29687 -2.4805,1.3594 -0.55859,1.9062 -0.83984,3.9141 -0.83984,5.9609 0,2.3789 0.39062,4.7227 1.1602,6.9609 0.28516,0.82812 1.0625,1.3516 1.8906,1.3516 0.21484,0 0.43359,-0.03516 0.64844,-0.10938 1.043,-0.35938 1.6016,-1.4961 1.2422,-2.543 -0.625,-1.8203 -0.94141,-3.7227 -0.94141,-5.6602 0,-1.668 0.22656,-3.2969 0.67969,-4.8398 0.30859,-1.0586 -0.30078,-2.168 -1.3594,-2.4805 z" />
|
||||
|
||||
<!-- path4: main ring arc -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 14.691615,48.83807 c 0.10156,0.33984 0.19922,0.67969 0.32812,1.0195 0.42969,1.3516 0.94922,2.6484 1.5781,3.9102 0.10156,-0.01172 0.21094,-0.01172 0.32031,-0.01953 l 0.16016,-0.01172 0.80078,-0.07031 c 0.37109,-0.03906 0.67188,-0.07031 0.98047,-0.10156 0.51172,-0.05859 1.0195,-0.12109 1.5586,-0.19922 l 0.21875,-0.03125 c 0.07031,-0.01172 0.14062,-0.01953 0.21094,-0.03125 -1.2188,-2.2109 -2.1484,-4.6016 -2.7305,-7.1211 -0.16016,-0.71094 -0.30078,-1.4297 -0.39844,-2.1602 -0.19922,-1.3086 -0.30078,-2.6602 -0.30078,-4.0312 0,-0.89844 0.03906,-1.7812 0.12891,-2.6484 0.07031,-0.71094 0.16016,-1.4102 0.28906,-2.1016 2.25,-12.949 13.57,-22.828 27.16,-22.828 6.0508,0 11.648,1.9609 16.211,5.3008 0.57031,0.41016 1.1289,0.85938 1.6719,1.3203 1.6914,1.4219 3.2109,3.0703 4.5,4.8789 0.42969,0.60156 0.83984,1.2109 1.2188,1.8398 1.3203,2.1602 2.3398,4.5117 3.0195,7 0.23828,-0.17188 0.42969,-0.30859 0.62891,-0.46875 0.64844,-0.48047 1.2109,-0.92188 1.7383,-1.3398 0.28125,-0.23047 0.5,-0.41016 0.71094,-0.57812 0.10156,-0.07813 0.19141,-0.16016 0.28125,-0.23828 -0.42969,-1.3516 -0.96094,-2.6484 -1.5898,-3.8984 -0.14062,-0.32812 -0.30859,-0.64844 -0.48047,-0.96875 -0.32812,-0.64844 -0.69922,-1.2891 -1.0898,-1.9102 -1.6797,-2.7109 -3.7695,-5.1406 -6.1719,-7.2188 -0.57031,-0.48828 -1.1484,-0.96875 -1.7617,-1.4102 -0.55859,-0.42188 -1.1406,-0.82812 -1.7305,-1.2109 -4.9414,-3.2188 -10.852,-5.0898 -17.16,-5.0898 -14.961,0 -27.531,10.469 -30.75,24.469 -0.17188,0.67969 -0.30859,1.3711 -0.42188,2.0703 -0.12891,0.73828 -0.21875,1.5 -0.28906,2.2617 -0.07813,0.91016 -0.12109,1.8398 -0.12109,2.7812 0,2.3008 0.25,4.5508 0.71875,6.7109 0.17188,0.71484 0.35156,1.4258 0.5625,2.125 z" />
|
||||
|
||||
<!-- path5: top-right swash / outer arc with tail -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 90.441615,21.60007 c -2.1797,-5.3398 -9.4102,-7.3984 -21,-6.0391 1.8906,1.8906 3.5391,3.9688 4.9297,6.2109 0.28906,0.46094 0.55859,0.92187 0.80859,1.3789 5.5391,-0.12109 7.6094,1.0391 7.8398,1.4492 0.12891,0.46875 -0.55078,2.7305 -4.5898,6.4805 -0.01953,0.01953 -0.03125,0.03125 -0.03906,0.03906 -0.26172,0.23828 -0.51953,0.48047 -0.80078,0.71875 -0.19922,0.17969 -0.41016,0.35938 -0.62891,0.53906 -0.10938,0.10156 -0.21875,0.19141 -0.33984,0.28906 -0.23828,0.19922 -0.5,0.41016 -0.76172,0.62109 -0.12891,0.10156 -0.26172,0.21094 -0.39844,0.32031 -0.42969,0.33984 -0.89063,0.69141 -1.3711,1.0508 -0.26953,0.21094 -0.53906,0.41016 -0.82812,0.60938 -0.32031,0.23047 -0.64062,0.46875 -0.98047,0.69922 0,0.01172 -0.01172,0.01172 -0.01172,0.01172 -0.26953,0.19141 -0.55078,0.37891 -0.82812,0.57031 -0.28125,0.19141 -0.55859,0.37109 -0.85156,0.55859 -0.25,0.16016 -0.5,0.32812 -0.76172,0.48828 -6,3.8984 -13.48,7.7188 -21.379,10.922 -8.0117,3.2383 -15.871,5.6602 -22.93,7.0391 -0.30078,0.05859 -0.60156,0.12109 -0.89062,0.17188 -0.60938,0.12109 -1.2188,0.21875 -1.8203,0.32031 -0.07031,0.01172 -0.12891,0.01953 -0.19922,0.03125 h -0.01953 c -0.28906,0.05078 -0.57031,0.08984 -0.83984,0.12891 -0.30859,0.05078 -0.60938,0.08984 -0.91016,0.12891 -0.57031,0.07813 -1.1094,0.14844 -1.6406,0.21094 -0.35156,0.03906 -0.69141,0.07031 -1.0195,0.10156 -0.30078,0.03125 -0.58984,0.05078 -0.87891,0.07813 -0.48047,0.03125 -0.92969,0.05859 -1.3711,0.07813 -0.39844,0.01953 -0.78125,0.03125 -1.1484,0.03906 -5.5116996,0.10938 -7.5702996,-1.0391 -7.8007996,-1.4492 -0.12891,-0.48047 0.55078,-2.7383 4.6093996,-6.5 -0.12891,-0.48828 -0.26172,-1 -0.37891,-1.5391 -0.51953,-2.4219 -0.78906,-4.8906 -0.78906,-7.3594 0,-0.17969 0,-0.37109 0.01172,-0.55078 -9.2733996,7.082 -13.0229996,13.59 -10.8749996,18.949 1.7383,4.2695 6.7188,6.4492 14.5899996,6.4492 2.8594,0 6.1016,-0.28906 9.7109,-0.87109 0.17188,-0.03125 0.33984,-0.05859 0.51953,-0.08984 0.17188,-0.03125 0.35156,-0.05859 0.51953,-0.08984 l 1.2188,-0.21875 c 0.57031,-0.10156 1.1484,-0.21875 1.7305,-0.33984 0.53125,-0.10938 1.0508,-0.21094 1.5781,-0.32812 0.01172,0 0.03125,-0.01172 0.03906,-0.01172 0.05078,-0.01172 0.08984,-0.01953 0.14062,-0.03125 0.07031,-0.01172 0.12891,-0.03125 0.19922,-0.05078 0.57812,-0.12891 1.1602,-0.26172 1.7383,-0.39844 0.05078,-0.01172 0.10156,-0.03125 0.14844,-0.03906 0.21094,-0.05078 0.42188,-0.10156 0.64062,-0.14844 h 0.01172 c 6.0898,-1.5117 12.559,-3.6406 19.102,-6.2891 6.5508,-2.6602 12.68,-5.6289 18.109,-8.7812 0.21875,-0.12891 0.44141,-0.26172 0.66016,-0.39062 0.58984,-0.33984 1.1797,-0.69141 1.7617,-1.0508 1.5703,-0.96094 3.0586,-1.9297 4.4805,-2.9102 0.12891,-0.08984 0.26172,-0.17969 0.39062,-0.26953 11.242,-7.8477 15.941,-15.09 13.594,-20.938 z" />
|
||||
|
||||
</group>
|
||||
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 868 B After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 940 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 531 B After Width: | Height: | Size: 730 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#7c52e0</color>
|
||||
<color name="ic_launcher_background">#e9673f</color>
|
||||
</resources>
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
<resources>
|
||||
<string name="app_name">Agora</string>
|
||||
<string name="title_activity_main">Agora</string>
|
||||
<string name="package_name">pub.agora.app</string>
|
||||
<string name="custom_url_scheme">pub.agora.app</string>
|
||||
<string name="package_name">spot.agora.app</string>
|
||||
<string name="custom_url_scheme">spot.agora.app</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-barcode-scanner'
|
||||
project(':capacitor-barcode-scanner').projectDir = new File('../node_modules/@capacitor/barcode-scanner/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
minSdkVersion = 26
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'pub.agora.app',
|
||||
appId: 'spot.agora.app',
|
||||
appName: 'Agora',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Agora" />
|
||||
<meta property="og:description" content="Power to the people." />
|
||||
<meta property="og:image" content="https://agora.spot/og-image.png" />
|
||||
<meta property="og:image" content="https://agora.spot/og-image.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
@@ -21,15 +21,15 @@
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agora" />
|
||||
<meta name="twitter:description" content="Power to the people." />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.png" />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.jpg" />
|
||||
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; child-src 'self' blob:; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/icon-192.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#0a0c14" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#e85d3c" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#ff6600" media="(prefers-color-scheme: light)">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<style>@keyframes agora-spin{to{transform:rotate(360deg)}}</style>
|
||||
</head>
|
||||
|
||||
@@ -323,9 +323,9 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.14.4;
|
||||
MARKETING_VERSION = 2.8.6;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -347,8 +347,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
MARKETING_VERSION = 2.8.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
@@ -33,7 +33,7 @@ public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
static let bgTaskIdentifier = "pub.ditto.app.notification-refresh"
|
||||
static let bgTaskIdentifier = "spot.agora.app.notification-refresh"
|
||||
private static let prefsKey = "ditto_notification_config"
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Agora needs camera access to take photos and videos for your posts.</string>
|
||||
<string>Agora needs camera access to take photos and videos for your posts, and to scan QR codes when sending Bitcoin.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Agora needs access to your microphone to record voice messages.</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
@@ -61,7 +61,7 @@
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>pub.agora.app.notification-refresh</string>
|
||||
<string>spot.agora.app.notification-refresh</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -13,6 +13,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorBarcodeScanner", path: "../../../node_modules/@capacitor/barcode-scanner"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
|
||||
@@ -28,6 +29,7 @@ let package = Package(
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
app_identifier("pub.ditto.app")
|
||||
app_identifier("spot.agora.app")
|
||||
team_id("GZLTTH5DLM")
|
||||
|
||||
@@ -3,7 +3,7 @@ default_platform(:ios)
|
||||
platform :ios do
|
||||
# ─── Lanes ────────────────────────────────────────────────────────────
|
||||
|
||||
desc "Build and sign the App Store IPA. Output at ../artifacts/Ditto.ipa."
|
||||
desc "Build and sign the App Store IPA. Output at ../artifacts/Agora.ipa."
|
||||
lane :build_ipa do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
submit_release_for_review!(ipa_path)
|
||||
end
|
||||
|
||||
desc "Build, sign, and submit Ditto to the App Store for review (single-step convenience)."
|
||||
desc "Build, sign, and submit Agora to the App Store for review (single-step convenience)."
|
||||
lane :release do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
@@ -83,7 +83,7 @@ platform :ios do
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
output_directory: "../artifacts",
|
||||
output_name: "Ditto.ipa",
|
||||
output_name: "Agora.ipa",
|
||||
clean: true,
|
||||
# Override the Xcode project's Automatic signing for this build only.
|
||||
# Match has already installed the AppStore cert + profile into the
|
||||
@@ -93,7 +93,7 @@ platform :ios do
|
||||
xcargs: [
|
||||
"CODE_SIGN_STYLE=Manual",
|
||||
"CODE_SIGN_IDENTITY='Apple Distribution'",
|
||||
"PROVISIONING_PROFILE_SPECIFIER='match AppStore pub.ditto.app'",
|
||||
"PROVISIONING_PROFILE_SPECIFIER='match AppStore spot.agora.app'",
|
||||
"DEVELOPMENT_TEAM=GZLTTH5DLM",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
@@ -101,7 +101,7 @@ platform :ios do
|
||||
signingStyle: "manual",
|
||||
teamID: "GZLTTH5DLM",
|
||||
provisioningProfiles: {
|
||||
"pub.ditto.app" => "match AppStore pub.ditto.app",
|
||||
"spot.agora.app" => "match AppStore spot.agora.app",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git_url("https://gitlab.com/soapbox-pub/certificates.git")
|
||||
storage_mode("git")
|
||||
type("appstore")
|
||||
app_identifier(["pub.ditto.app"])
|
||||
app_identifier(["spot.agora.app"])
|
||||
team_id("GZLTTH5DLM")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"private": true,
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -15,7 +15,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -30,30 +30,18 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@fontsource-variable/comfortaa": "^5.2.8",
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/fredoka": "^5.2.10",
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lora": "^5.2.8",
|
||||
"@fontsource-variable/merriweather": "^5.2.6",
|
||||
"@fontsource-variable/montserrat": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource/bungee-shade": "^5.2.7",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/caveat": "^5.2.8",
|
||||
"@fontsource/cherry-bomb-one": "^5.2.7",
|
||||
"@fontsource/comic-neue": "^5.2.7",
|
||||
"@fontsource/comic-relief": "^5.2.2",
|
||||
"@fontsource/courier-prime": "^5.2.8",
|
||||
"@fontsource/creepster": "^5.2.7",
|
||||
"@fontsource/luckiest-guy": "^5.2.8",
|
||||
"@fontsource/noto-sans-nushu": "^5.2.6",
|
||||
"@fontsource/noto-sans-tc": "^5.2.9",
|
||||
"@fontsource/pacifico": "^5.2.7",
|
||||
"@fontsource/permanent-marker": "^5.2.7",
|
||||
"@fontsource/pirata-one": "^5.2.8",
|
||||
"@fontsource/press-start-2p": "^5.2.7",
|
||||
"@fontsource/silkscreen": "^5.2.8",
|
||||
"@fontsource/special-elite": "^5.2.8",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
@@ -71,23 +59,19 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.52.0",
|
||||
"@nostrify/react": "^0.6.0",
|
||||
"@nostrify/nostrify": "^0.52.2",
|
||||
"@nostrify/react": "^0.6.2",
|
||||
"@nostrify/types": "^0.37.0",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
@@ -102,14 +86,15 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@samthomson/nostr-messaging": "^0.17.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@scure/btc-signer": "^2.2.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -120,35 +105,26 @@
|
||||
"dompurify": "^3.3.3",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
"hls.js": "^1.6.15",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^26.0.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"iso-3166": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-blurhash": "^0.3.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.3",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.12.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"slugify": "^1.6.8",
|
||||
"smol-toml": "^1.6.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uri-templates": "^0.2.0",
|
||||
@@ -168,19 +144,18 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"@webxdc/types": "^2.1.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"iso-3166": "^4.4.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"GZLTTH5DLM.pub.agora.app"
|
||||
"GZLTTH5DLM.spot.agora.app"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "pub.agora.app",
|
||||
"package_name": "spot.agora.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71",
|
||||
"E5:B1:A9:13:C9:37:35:3C:A5:E7:27:89:C0:9D:3D:0D:A5:4F:F5:26:88:06:BD:24:46:21:AB:61:6B:CC:C5:E5"
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.6] - 2026-06-02
|
||||
|
||||
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
|
||||
|
||||
### Changed
|
||||
|
||||
- Update the app icon to the current Agora bolt on a brand-orange background.
|
||||
|
||||
## [2.8.5] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.4] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.3] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.2] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.1] - 2026-06-02
|
||||
|
||||
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
|
||||
|
||||
### Added
|
||||
|
||||
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
|
||||
- Organizations with their own events, pledges, members, and moderation tools.
|
||||
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
|
||||
- One-tap support: zap posts, profiles, campaigns, and organizations.
|
||||
- AI agent chat with a model selector, tool-calling, and slash commands.
|
||||
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
|
||||
- Comments and reactions on campaigns, and donation receipts shown inline.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed Agora branding, navigation, and app icons throughout.
|
||||
- Streamlined onboarding with country and people follows.
|
||||
- Polished campaign, organization, and donation flows end to end.
|
||||
|
||||
### Removed
|
||||
|
||||
- Direct messaging and ephemeral geo chat.
|
||||
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
|
After Width: | Height: | Size: 569 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 970 KiB |
|
After Width: | Height: | Size: 1008 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 985 B |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,236 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1152" height="720">
|
||||
|
||||
<defs>
|
||||
|
||||
<linearGradient id="grad">
|
||||
<stop stop-color="#84be86" offset="0"/>
|
||||
|
||||
<stop stop-color="#328c4e" offset="1"/>
|
||||
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path fill="#f4e109" d="m0,0,0,720,1152,0,0-720z"/>
|
||||
|
||||
<path fill="#da251c" d="m596.99,620,555.01,56.187,0-634.28-1110,0,0,634.28"/>
|
||||
|
||||
<g fill="#29166f">
|
||||
<path d="m597,359.06,0-317.16-277.69,0z"/>
|
||||
|
||||
<path d="m597,359.06,555-317.16-278.03,0z"/>
|
||||
|
||||
<path d="m1152,200.47,0,158.59-1110,0,0,158.56z"/>
|
||||
|
||||
<path d="m1152,676.14,0-158.51-1110-317.16,0-158.56z"/>
|
||||
|
||||
</g>
|
||||
<path fill="#f4e109" d="m392.87,329.97,102.05,17.726c-0.408,3.732-0.632,7.516-0.632,11.355,0,3.868,0.225,7.685,0.64,11.441l-102.04,17.813,102.92-11.563c1.158,6.703,2.963,13.182,5.344,19.374l-94.205,43.224,96.647-37.403c1.266,2.774,2.643,5.487,4.14,8.123l178.54,0.001c1.495-2.636,2.874-5.349,4.14-8.123l96.646,37.403-94.206-43.224c2.382-6.192,4.187-12.671,5.345-19.374l102.92,11.563-102.04-17.813c0.414-3.757,0.641-7.573,0.641-11.441,0-3.839-0.227-7.623-0.632-11.355l102.05-17.726-102.94,11.476c-1.148-6.719-2.944-13.211-5.321-19.419l94.22-43.177-96.657,37.357c-3.226-7.082-7.215-13.735-11.879-19.849l78.264-68.147-82.245,63.261c-10.31-11.931-23.281-21.47-38.01-27.65l33.979-98.248-39.859,96.01c-10.556-3.681-21.882-5.707-33.686-5.707-11.803,0-23.128,2.026-33.684,5.707l-39.86-96.01,33.98,98.248c-14.729,6.181-27.701,15.719-38.01,27.65l-82.246-63.261,78.266,68.147c-4.665,6.114-8.654,12.768-11.878,19.849l-96.659-37.357,94.22,43.177c-2.378,6.208-4.171,12.7-5.321,19.419z"/>
|
||||
|
||||
<path fill="#fff" d="M596.99,359.05,1152,676.19h-1110l554.99-317.14z"/>
|
||||
|
||||
<g fill="#da251c" stroke="#000" stroke-width="0.476">
|
||||
|
||||
<path fill="#f1c700" d="m629.96,593.89c0,18.271-14.811,33.082-33.081,33.082-18.268,0-33.082-14.812-33.082-33.082,0-18.269,14.814-33.079,33.082-33.079,18.27,0,33.081,14.81,33.081,33.079z"/>
|
||||
|
||||
<path d="m624.99,593.89c0,15.526-12.586,28.112-28.112,28.112s-28.112-12.586-28.112-28.112,12.586-28.112,28.112-28.112,28.112,12.586,28.112,28.112z"/>
|
||||
|
||||
<path fill="#e87817" d="m620.54,593.89c0,13.069-10.594,23.663-23.663,23.663s-23.663-10.594-23.663-23.663,10.594-23.663,23.663-23.663,23.663,10.594,23.663,23.663z"/>
|
||||
|
||||
<path fill="#29166f" d="m620.54,593.9c0,13.065-10.594,23.661-23.663,23.661s-23.702-10.596-23.702-23.661c0-6.541,5.301-11.858,11.848-11.858,6.552,0,11.836,5.317,11.836,11.858,0,6.542,5.296,11.849,11.85,11.849,6.542-0.001,11.831-5.307,11.831-11.849z"/>
|
||||
|
||||
<path fill="#e87817" d="m588.41,593.89c0,1.868-1.509,3.382-3.38,3.382s-3.382-1.514-3.382-3.382c0-1.864,1.511-3.384,3.382-3.384s3.38,1.519,3.38,3.384z"/>
|
||||
|
||||
<path fill="#29166f" d="m612.11,593.89c0,1.868-1.508,3.382-3.38,3.382-1.871,0-3.385-1.514-3.385-3.382,0-1.864,1.514-3.384,3.385-3.384,1.873,0,3.38,1.519,3.38,3.384z"/>
|
||||
|
||||
<path d="m596.87,556.27c-1.38,0-2.501-1.117-2.501-2.5,0-1.385,1.121-2.507,2.501-2.507,1.384,0,2.502,1.122,2.502,2.507,0,1.382-1.118,2.5-2.502,2.5z"/>
|
||||
|
||||
<path d="m596.81,558.2c0,1.385-1.117,2.506-2.502,2.506s-2.501-1.121-2.501-2.506c0-1.378,1.116-2.502,2.501-2.502s2.502,1.124,2.502,2.502z"/>
|
||||
|
||||
<path d="m601.94,558.2c0,1.385-1.116,2.506-2.5,2.506-1.386,0-2.506-1.121-2.506-2.506,0-1.378,1.12-2.502,2.506-2.502,1.384,0,2.5,1.124,2.5,2.502z"/>
|
||||
|
||||
<path d="m596.87,631.52c1.379,0,2.506,1.12,2.506,2.5,0,1.384-1.127,2.502-2.506,2.502-1.384,0-2.5-1.118-2.5-2.502,0-1.38,1.116-2.5,2.5-2.5z"/>
|
||||
|
||||
<path d="m596.94,629.58c0-1.38,1.118-2.5,2.502-2.5,1.382,0,2.504,1.12,2.504,2.5,0,1.386-1.122,2.506-2.504,2.506-1.384,0-2.502-1.12-2.502-2.506z"/>
|
||||
|
||||
<path d="m591.81,629.58c0-1.38,1.116-2.5,2.5-2.5s2.505,1.12,2.505,2.5c0,1.386-1.121,2.506-2.505,2.506s-2.5-1.12-2.5-2.506z"/>
|
||||
|
||||
<path d="m639.5,593.89c0,1.3813-1.1197,2.501-2.501,2.501s-2.501-1.1197-2.501-2.501,1.1197-2.501,2.501-2.501,2.501,1.1197,2.501,2.501z"/>
|
||||
|
||||
<path d="m635.07,596.46c0,1.3824-1.1206,2.503-2.503,2.503s-2.503-1.1206-2.503-2.503,1.1206-2.503,2.503-2.503,2.503,1.1206,2.503,2.503z"/>
|
||||
|
||||
<path d="m632.57,588.83c-1.381,0-2.502,1.115-2.502,2.5s1.121,2.504,2.502,2.504c1.382,0,2.502-1.119,2.502-2.504s-1.12-2.5-2.502-2.5z"/>
|
||||
|
||||
<path d="m559.25,593.89c0-1.378-1.115-2.504-2.5-2.504-1.384,0-2.501,1.126-2.501,2.504,0,1.383,1.117,2.5,2.501,2.5,1.385,0,2.5-1.118,2.5-2.5z"/>
|
||||
|
||||
<path d="m563.68,591.33c0,1.3824-1.1206,2.503-2.503,2.503s-2.503-1.1206-2.503-2.503,1.1206-2.503,2.503-2.503,2.503,1.1206,2.503,2.503z"/>
|
||||
|
||||
<path d="m563.68,596.45c0,1.3818-1.1202,2.502-2.502,2.502s-2.502-1.1202-2.502-2.502,1.1202-2.502,2.502-2.502,2.502,1.1202,2.502,2.502z"/>
|
||||
|
||||
</g>
|
||||
<g stroke-width="0.476">
|
||||
|
||||
<g fill="#e0609b" stroke="#000">
|
||||
|
||||
<path fill="#e87817" d="m600.33,516.36c0,1.84-1.487,3.326-3.325,3.326-1.84,0-3.326-1.486-3.326-3.326,0-1.838,1.486-3.324,3.326-3.324,1.838,0,3.325,1.486,3.325,3.324z"/>
|
||||
|
||||
<path fill="#e12211" d="m622.04,486.76c-1.398,5.898-12.077,10.491-25.054,10.491-13.008,0-23.717-4.616-25.07-10.542-4.437,2.376-7.1,5.4-7.1,8.691,0,7.66,14.382,13.867,32.124,13.867,17.74,0,32.118-6.207,32.118-13.867,0-3.268-2.629-6.274-7.018-8.64z"/>
|
||||
|
||||
<path d="m597.44,497.25c-2.56,0-5.03-0.179-7.36-0.514-0.354,0.997-2.361,8.101,7.313,12.53,7.429-2.847,7.275-10.72,6.511-12.41-2.063,0.254-4.228,0.394-6.464,0.394z"/>
|
||||
|
||||
<path d="m571.8,485.93c-1.184,0.502-7.366,2.488-5.974,13.064,9.224,2.535,10.25-5.694,10.28-6.938-2.553-1.761-4.104-3.864-4.306-6.126z"/>
|
||||
|
||||
<path d="m588.87,496.54c0.079,1.021,0.6,9.042-9.55,10.257-7.443-6.215-2.961-13.113-1.307-13.851,2.84,1.615,6.58,2.866,10.857,3.594z"/>
|
||||
|
||||
<path d="m617.82,492.06c0.027,1.243,1.056,9.473,10.279,6.938,1.392-10.576-4.792-12.563-5.976-13.064-0.198,2.261-1.752,4.364-4.303,6.126z"/>
|
||||
|
||||
<path d="m615.91,492.95c1.654,0.737,6.138,7.636-1.308,13.851-10.149-1.215-9.625-9.235-9.55-10.257,4.278-0.728,8.019-1.979,10.858-3.594z"/>
|
||||
|
||||
<path fill="#f1c700" d="m627.78,491.52c0.831,1.236,1.284,2.536,1.284,3.881,0,7.66-14.378,13.867-32.118,13.867-17.741,0-32.124-6.207-32.124-13.867,0-1.237,0.381-2.438,1.089-3.585-3.936,2.409-6.231,5.296-6.231,8.405,0,8.402,16.749,15.207,37.411,15.207,20.669,0,37.421-6.805,37.421-15.207,0.001-3.238-2.495-6.233-6.732-8.701z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g fill="#fff">
|
||||
|
||||
<path d="m597.12,504.23c-1.458-2.815-0.844-5.285-0.803-6.996,0.16,0.004,0.317,0.005,0.478,0.005-0.06,0.894-0.052,3.593,0.325,6.991z"/>
|
||||
|
||||
<path d="m572.84,488.82c-0.661,0.648-2.937,3.078-4.184,6.476,2.1-2.619,4.031-5.024,4.688-5.757-0.181-0.236-0.351-0.477-0.504-0.719z"/>
|
||||
|
||||
<path d="m582.4,494.9c-0.296,0.59-1.664,3.014-1.664,6.553,1.033-3.603,2.047-5.626,2.423-6.3-0.261-0.084-0.511-0.164-0.759-0.253z"/>
|
||||
|
||||
<path d="m620.59,489.54c0.657,0.732,2.588,3.138,4.688,5.757-1.247-3.397-3.522-5.827-4.184-6.476-0.154,0.242-0.321,0.483-0.504,0.719z"/>
|
||||
|
||||
<path d="m610.77,495.16c0.373,0.674,1.387,2.697,2.419,6.3,0-3.539-1.367-5.963-1.664-6.553-0.247,0.089-0.496,0.169-0.755,0.253z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g stroke="#000">
|
||||
|
||||
<path fill="#e12211" stroke-linejoin="round" stroke-linecap="round" d="m576.26,461.85s-2.332-2.805-5.756-4.99c-3.421-2.194-5.785-0.894-9.277-1.612-3.316-0.688-10.426-2.966-8.386-15.539,2.053,11.032,7.61,7.926,8.077,6.467,0.581-1.814-0.708-4.537-2.603-5.753-2.393-1.538-6.708-5.063-6.708-10.131,0-1.56,0.377-3.218,0.901-4.756,0.566-1.675,3.003-3.86,2.557-6.782,1.879,4.552-2.285,4.675,2.022,11.125,1.16,1.736,2.86,0.363,3.148-0.958,0.442-2.052-0.822-4.79-1.779-6.984-0.962-2.188-1.82-5.789-0.962-8.896,1.235-4.453,3.868-5.447,4.794-8.768,0.561-2.009-2.656-6.852,2.054-10.679-2.584,5.996,0.959,7.939,1.779,9.723,0.822,1.78-0.417,5.209-0.547,6.435-0.171,1.612,2.178,2.282,3.695,1.265,1.955-1.3,1.678-5.234,1.819-7.422,0.136-2.197,1.985-3.532,4.311-4.627,2.329-1.094,6.149-5.379,3.219-11.946,7.264,5.734,5.718,11.09,5.029,13.282-0.683,2.191-2.052,6.301-1.771,9.035,0.27,2.742,2.184,2.602,2.868,1.508,0.688-1.095-0.373-4.674-0.138-6.573,0.4-3.227,5.537-3.862,5.804-6.267,0.222-2.001-4.978-6.153-4.694-9.609,0.445-5.436,4.978-3.62,3.275-9.52,3.697,3.697,1.232,6.499,0.959,7.867-0.273,1.371,0.055,3.479,1.78,3.291,1.233-0.141,2.381-1.729,3.149-4.795,2.085-8.319-4.873-6.527-4.146-12.84,2.566,4.148,5.99,1.523,9.867,7.603,1.874,2.938,0.988,5.373,0.579,7.425-0.411,2.058,1.371,4.114,2.603,2.195,2.794-4.354-1.274-5.243,1.916-10.888-1.149,8.436,3.851,5.978,3.147,14.858-0.291,3.675-4.79,6.982-5.206,10-0.347,2.572,0.449,5.575,1.406,6.533,1.584,1.586,3.188,1.326,3.699-0.549,0.407-1.503-0.413-3.114-0.962-4.759-0.621-1.868,0.601-3.651,2.979-4.243,1.821-0.453,3.977-3.488,3.56-6.16-0.426-2.792-4.774-4.581-0.408-11.643-2.45,7.937,4.134,6.937,4.962,12.396,0.353,2.322-0.43,3.844-0.648,5.063-0.282,1.556,4.211,3.806,2.811-2.258,4.718,3.862,5.063,8.629,4.517,10.407-0.551,1.78-2.056,3.968-1.098,5.886s3.249,2.671,4.52,0.822c1.059-1.541,1.366-3.745,0.582-6.299-0.844-2.749-4.469-3.581-0.513-9.793,0,6.212,4.919,8.503,5.714,11.504,0.393,1.481-0.442,3.219,0.242,4.176,0.685,0.962,4.938,0.58,1.645-6.843,5.86,4.789,2.737,10.437,0.82,11.948-1.914,1.506-5.339,5.441-5.339,8.867,0,3.422,3.704,6.521,4.929,3.559,1.353-3.271-1.383-6.007,1.507-10.547-0.765,4.582,0.693,9.567,2.872,9.859,1.819,0.244,2.356-2.655,2.982-4.622,1.839,5.22-2.16,7.878-2.982,9.109-0.822,1.229-1.197,4.484-2.738,5.342-1.29,0.713-4.38,1.406-6.296,2.769-1.916,1.368-4.174,4.275-0.683,4.799,3.212,0.479,5.377-0.508,7.391-5.48,2.98,9.223-5.41,12.905-9.586,13.423-3.398,0.415-9.174,0.544-10.818,1.776-1.643,1.231-1.643,3.214-1.643,3.214s-2.602,4.729-7.805,5.824c-5.201,1.097-19.718,1.917-23.962,0.273s-7.4-3.84-9.036-6.097zm66.648-45.118c-5.9,5.665-0.803,12.679,0.446,8.628,1.39-4.507-2.096-3.241-0.446-8.628zm-16.259,21.773c-1.02,1.02-0.98,2.195-0.445,2.808,0.445,0.512,1.472,0.037,2.156-0.475,0.687-0.517,1.664-1.645,1.715-3.082,0.09-2.557-2.178-2.633-2.433-5.856-1.2,2.4,2.089,3.523-0.993,6.605zm-5.342-23.107c-1.089,3.51,1.18,4.697,1.493,6.006,0.308,1.287-0.256,1.855,0.151,2.93,0.411,1.075,1.594,2.105,2.26,0.769,0.75-1.494,0.432-2.606-0.099-3.952-0.686-1.743-3.805-1.993-3.805-5.753zm-6.11-12.98c1.332,2.896,0.14,3.552,0.716,6.829,0.206,1.17,1.912,3.409,2.519,1.591,0.512-1.535,0.41-3.078-0.617-4.105-2.288-2.287-0.356-2.552-2.618-4.315zm-6.574,11.655c4.591,5.847-0.048,11.04-1.026,11.864-0.977,0.821-1.542,1.692-1.797,2.513-0.255,0.826-0.052,2.363,1.384,2.004,1.439-0.361,2.673-1.848,3.339-3.081,0.668-1.229,1.598-4.002,1.696-6.265,0.15-3.439-1.505-6.773-3.596-7.035zm-6.832-1.077c-1.868,2.38,0.052,3.88-0.203,5.494-0.149,0.946-0.924,1.438-0.768,2.206,0.15,0.773,1.229,1.593,1.949,0.976,0.716-0.614,1.334-2.668,0.82-3.539-0.511-0.875-2.979-2.11-1.798-5.137zm-10.214-6.161c-4.343,5.054-1.492,10.216-0.927,10.889,0.566,0.664,2.617,0.929,2.72-0.98,0.208-3.886-3.511-3.886-1.793-9.909zm-5.958,9.653c-2.987,8.139,0.14,8.804,0.716,8.42,0.772-0.511,1.334-1.54,0.979-2.258-0.361-0.72-2.432-0.107-1.695-6.162zm-6.676-3.597c0.979,4.318-1.437,4.527-2.058,9.865-0.236,2.036,0.67,4.361,1.079,5.182,0.409,0.826,1.596,1.42,2.314,0.46,0.77-1.025,0.973-2.206,0.408-3.337-0.463-0.928-2.241-1.436-2.211-3.542,0.033-2.143,2.449-6.185,0.468-8.628zm-10.94,0.363c-2.247,4.872,2.253,5.163,2.93,6.979,0.414,1.11,1.229,2.056,1.897,0.98,0.67-1.079,0.413-3.081-0.465-3.906-0.871-0.819-4.362-1.265-4.362-4.053zm-4.108,8.473c-1.598,2.482,1.319,3.732,0.512,6.723-0.445,1.646-0.615,2.771-0.308,3.393,0.308,0.615,1.695,0.924,2.313-0.258,0.615-1.178,0.702-2.16,0.511-3.135-0.357-1.824-3.028-3.174-3.028-6.723zm11.4-17.44c-0.453,0.623-0.874,2.411-0.566,3.54,0.311,1.131,1.802,0.835,2.107,0.257,0.463-0.871-0.183-1.63-0.053-2.613,0.253-1.906,2.894-1.781,2.208-5.139-0.553,3.096-3.03,3.029-3.696,3.955zm18.637-2.621c-0.215,2.016,0.618,5.6,1.899,6.776,1.285,1.182,2.946-0.305,3.028-1.744,0.104-1.831-1.932-2.553-2.253-4.616-0.617-3.965,4.399-5.675,0.409-10.788,1.866,5.655-2.465,4.573-3.083,10.372z"/>
|
||||
|
||||
<path fill="#f1c700" d="m615.28,477.31c1.009,1.263,1.566,2.65,1.566,4.11,0,5.835-8.932,10.569-19.953,10.569-11.018,0-19.952-4.734-19.952-10.569,0-1.4,0.521-2.734,1.455-3.958-4.11,2.104-6.614,4.901-6.614,7.977,0,6.521,11.282,11.808,25.208,11.808,13.924,0,25.213-5.286,25.213-11.808,0-3.154-2.634-6.012-6.923-8.129z"/>
|
||||
|
||||
<path fill="#e12211" d="m615.59,477.73c-0.229,0.014-0.333,0.168-0.563-0.064-0.336-0.343-1.774-5.841-2.258-7.72-3.385,1.708-9.229,2.841-15.875,2.841-6.458,0-12.236-1.075-15.655-2.727-0.51,1.98-1.901,7.273-2.233,7.605-0.296,0.305-0.373-0.063-0.834,0.111-0.788,1.137-1.228,2.361-1.228,3.644,0,5.835,8.935,10.569,19.952,10.569,11.021,0,19.953-4.734,19.953-10.569,0-1.299-0.451-2.542-1.259-3.69z"/>
|
||||
|
||||
<path fill="#e87817" d="m579.67,467.35c1.768,4.648-2.587,10.577-1.52,13.252,1.063,2.667,3.491,4.079,5.861,4.878,5.991,2.019,7.075-3.606,8.075-14.169-3.69,0-9.599-1.144-12.416-3.961z"/>
|
||||
|
||||
<path fill="#29166f" d="m614.32,467.35c-1.768,4.648,2.587,10.577,1.52,13.252-1.063,2.667-3.491,4.079-5.861,4.878-5.991,2.019-7.075-3.606-8.075-14.169,3.691,0,9.599-1.144,12.416-3.961z"/>
|
||||
|
||||
<path fill="#fff" d="m602.32,471.78c0.75,2.538,2.585,11.549,1.657,13.4-1.125,2.239-4.041,3.375-6.981,3.406s-5.856-1.167-6.981-3.406c-0.928-1.852,0.907-10.862,1.657-13.4h10.648z"/>
|
||||
|
||||
<path fill="#e87817" d="m579.97,432c-5.032-3.902-7.604-3.569-7.604-3.569s-2.019,1.635-2.66,7.97c-0.405,4.006-0.633,10.451,2.174,16.628,2.774,6.112,7.654,12.183,9.485,14.039,1.834,1.854,3.345,3.692,3.345,3.692l10.187-4.366s-0.293-2.36-0.374-4.969c-0.077-2.607-1.494-10.163-4.002-16.389-2.543-6.29-7.373-10.568-10.551-13.036z"/>
|
||||
|
||||
<path fill="#29166f" d="m613.86,432c5.032-3.902,7.604-3.569,7.604-3.569s2.02,1.635,2.66,7.97c0.406,4.006,0.633,10.451-2.173,16.628-2.774,6.112-7.654,12.183-9.485,14.039-1.834,1.854-3.345,3.692-3.345,3.692l-10.187-4.366s0.293-2.36,0.374-4.969c0.077-2.607,1.494-10.163,4.002-16.389,2.542-6.29,7.372-10.568,10.55-13.036z"/>
|
||||
|
||||
<path fill="#fff" d="m602.57,430.27c-3.088-5.569-5.582-6.276-5.582-6.276s-2.5,0.707-5.586,6.276c-1.951,3.521-4.7,9.355-4.556,16.139,0.142,6.711,2.234,14.213,3.185,16.641,0.956,2.428,1.62,4.713,1.62,4.713h11.083s0.66-2.285,1.614-4.713c0.956-2.428,2.632-9.93,2.779-16.641,0.143-6.783-2.609-12.617-4.557-16.139z"/>
|
||||
|
||||
<path fill="#f4e109" d="m596.59,470.93c-9.241,0-17.013-2.043-19.26-4.805-0.03,0.133-0.047,0.266-0.047,0.404,0,3.451,8.643,6.256,19.307,6.256,10.662,0,19.308-2.805,19.308-6.256,0-0.139-0.019-0.271-0.048-0.404-2.251,2.762-10.023,4.805-19.26,4.805z"/>
|
||||
|
||||
<path fill="#e87817" d="m596.59,468.84c-9.572,0-17.604-2.131-19.827-5.008-0.063,0.21-0.099,0.432-0.099,0.646,0,3.564,8.917,6.457,19.926,6.457,11.001,0,19.924-2.893,19.924-6.457,0-0.215-0.036-0.437-0.099-0.646-2.226,2.877-10.255,5.008-19.825,5.008z"/>
|
||||
|
||||
<path fill="#f4e109" d="m596.59,466.98c-9.833,0-18.088-2.182-20.402-5.132-0.021,0.121-0.037,0.248-0.037,0.369,0,3.654,9.148,6.62,20.439,6.62,11.283,0,20.435-2.966,20.435-6.62,0-0.121-0.013-0.248-0.033-0.369-2.314,2.95-10.573,5.132-20.402,5.132z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g fill="#000">
|
||||
|
||||
<path d="m577.27,430.13c0.364,0.851-0.835,2.477-2.762,3.303s-3.931,0.573-4.296-0.277l-0.109,0.557c0.364,0.851,2.463,1.506,4.75,0.524,2.288-0.98,3.261-2.951,2.896-3.803l-0.479-0.304z"/>
|
||||
|
||||
<path d="m581.32,433.08c0.569,1.327-1.313,4.192-4.564,5.585-3.25,1.395-6.623,0.784-7.192-0.545l-0.042,0.734c0.568,1.328,4.137,2.432,7.703,0.902,3.565-1.528,5.226-4.873,4.656-6.202l-0.561-0.474z"/>
|
||||
|
||||
<path d="m584.63,436.13c0.718,1.674-1.6,5.621-5.78,7.415-4.183,1.793-8.64,0.749-9.357-0.924l0.065,0.862c0.718,1.674,5.294,3.135,9.784,1.21,4.489-1.925,6.586-6.248,5.867-7.921l-0.579-0.642z"/>
|
||||
|
||||
<path d="M586.84,446.47c-1.25,1.38-3.02,2.7-5.18,3.62-5.01,2.15-10.19,1.29-11.04-0.68l0.16,0.68c0.85,1.98,6.07,3.49,11.38,1.22,1.98-0.85,3.6-2.09,4.78-3.37-0.03-0.5-0.09-0.98-0.1-1.47z"/>
|
||||
|
||||
<path d="m616.56,430.13c-0.364,0.851,0.834,2.477,2.762,3.303,1.927,0.826,3.931,0.573,4.295-0.277l0.11,0.557c-0.364,0.851-2.463,1.506-4.751,0.524-2.287-0.98-3.26-2.951-2.895-3.803l0.479-0.304z"/>
|
||||
|
||||
<path d="m612.52,433.08c-0.569,1.327,1.313,4.192,4.563,5.585,3.251,1.395,6.623,0.784,7.193-0.545l0.041,0.734c-0.568,1.328-4.137,2.432-7.702,0.902-3.565-1.528-5.226-4.873-4.656-6.202l0.561-0.474z"/>
|
||||
|
||||
<path d="m609.21,436.13c-0.718,1.674,1.6,5.621,5.78,7.415,4.182,1.793,8.64,0.749,9.357-0.924l-0.066,0.862c-0.717,1.674-5.293,3.135-9.783,1.21-4.489-1.925-6.586-6.248-5.867-7.921l0.579-0.642z"/>
|
||||
|
||||
<path d="M607.12,446.59c-0.01,0.5-0.06,1-0.09,1.5,1.17,1.24,2.71,2.4,4.63,3.22,5.3,2.27,10.56,0.76,11.4-1.22l0.16-0.68c-0.85,1.97-6.02,2.83-11.03,0.68-2.09-0.89-3.82-2.17-5.07-3.5z"/>
|
||||
|
||||
<path d="m600.83,427.49c0,0.926-1.742,1.948-3.839,1.948s-3.839-1.022-3.839-1.948l-0.32,0.469c0,0.926,1.67,2.354,4.159,2.354s4.159-1.429,4.159-2.354l-0.32-0.469z"/>
|
||||
|
||||
<path d="m603.39,431.79c0,1.445-2.858,3.336-6.396,3.336s-6.396-1.891-6.396-3.336l-0.328,0.658c0,1.445,2.844,3.865,6.724,3.865s6.724-2.42,6.724-3.865l-0.328-0.658z"/>
|
||||
|
||||
<path d="m605.23,435.9c0,1.821-3.686,4.536-8.235,4.536s-8.235-2.715-8.235-4.536l-0.279,0.818c0,1.821,3.63,4.968,8.515,4.968s8.515-3.146,8.515-4.968l-0.29-0.82z"/>
|
||||
|
||||
<path d="m607,443.28c0,2.152-4.238,5.593-10.007,5.593s-10.007-3.44-10.007-5.593l0.143-0.714c0,2.152,4.412,4.994,9.864,4.994s9.864-2.842,9.864-4.994l0.16,0.71z"/>
|
||||
|
||||
</g>
|
||||
|
||||
</g>
|
||||
<g id="lion" stroke="#000" stroke-width="0.476" stroke-linejoin="round" stroke-linecap="round">
|
||||
|
||||
<path fill="#f4e109" d="m578.1,509.26c6.627,6.627-0.549,11.553-4.858,8.988,0.618-1.141,1.062-3.734,1.062-3.734s4.872,0.986,3.796-5.254z"/>
|
||||
|
||||
<g fill="#fff" stroke-width="1.429">
|
||||
|
||||
<path d="m445.45,607.5s-4.234,8.459-7.507,10.099c-0.995,0.498-1.821,0.36-1.821,0.36s4.128,3.283,6.071,4.015c1.943,0.726,5.344,2.427,6.436,2.061,1.095-0.362,4.86-2.188,7.652-3.037,2.795-0.849,4.738-0.728,5.952,0.122,1.216,0.854,3.28,3.768,2.552,4.982-0.729,1.213-2.309,2.064-2.309,2.064s0.242,1.094-0.608,2.063c-0.849,0.972-2.914,1.334-2.914,1.334l-0.972,2.554c-0.484,1.335-2.064,1.455-2.064,1.455s-0.122,3.16-1.215,4.735c-1.093,1.583-2.793,1.945-4.857,1.703-2.065-0.242-5.345-1.818-6.316-1.942-0.971-0.121-2.672-1.094-3.157-2.309-0.488-1.214-2.065-2.915-3.766-4.009-1.701-1.092-11.173-5.1-14.453-5.584-3.279-0.489-13.117-3.158-13.117-3.158s8.381-6.803,9.109-12.756c0.729-5.948,2.309-9.105,9.717-7.404,7.406,1.7,17.587,2.652,17.587,2.652z"/>
|
||||
|
||||
<path d="m513.73,538.17s2.722-2.971,6.152-4.2c6.747-2.421,9.257-0.081,9.257-0.081s0.136-2.521,5.48-4.223c5.344-1.703,8.259-0.727,8.259-0.727s0.972-3.403,3.888-4.617c2.914-1.215,5.829-1.945,6.315-3.885,0.484-1.943-0.73-5.83,0.241-8.018,0.973-2.188,3.771-4.163,6.406-2.825,2.277,1.155,2.127-0.121,3.826,0.362,1.699,0.49,2.458,1.367,2.458,1.367s1.398-1.182,3.828-0.451c2.427,0.725,3.399,1.547,3.399,1.547s2.429,1.457,2.188,3.156c-0.246,1.704-0.974,4.132-3.887,4.617-2.915,0.485-5.584-0.243-7.53,0.727-1.942,0.976-4.372,4.616-5.102,6.56-0.728,1.945-5.828,15.304-13.115,20.647-7.288,5.342-18.219,16.518-21.133,17.245-2.916,0.731-7.287,1.7-7.287,1.7s1.214-16.518-0.729-21.375c-1.944-4.855-2.914-7.526-2.914-7.526z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g fill="url(#grad)" stroke-width="0.667">
|
||||
|
||||
<path d="m375.46,542.44s6.964-6.071,13.295,1.129c6.426,7.311,2.914,15.123-0.366,18.218-3.278,3.098-7.604,6.334-13.298,5.285,1.856-2.311,1.032-3.699,0.785-4.513-0.547-1.786,1.408-1.575,3.362-4.122,1.434-1.861,2.518-5.51,1.625-8.188-1.856-5.57-5.403-7.809-5.403-7.809z"/>
|
||||
|
||||
<path d="m302.22,567.26c5.333-4.255,8.208,3.433,15.121,0.731,3.417-1.335,6.922-8.2,11.295-8.932,4.373-0.727,9.525,2.734,12.752,0.551,5.647-3.826,0.911-13.302,13.117-18.765,8.628-3.862,18.4-3.101,22.956,1.639,4.554,4.733,5.647,11.476,4.19,14.211-1.458,2.731-5.466,6.011-5.466,6.011l-6.377,1.092s-2.186-4.738-6.739-5.465c-4.554-0.729-6.922,2.369-9.109,5.83-2.62,4.148-9.11,9.475-19.312,7.469-3.77-0.739-7.834,4.918-17.672,2.187-4.889-1.357-7.986-6.626-14.756-6.559z"/>
|
||||
|
||||
<path d="m307.31,581.61c4.309-3.92,8.75,0.238,10.765,0.221,3.659-0.029,6.921-2.55,8.015-5.827,1.092-3.281,4.19-6.194,8.015-6.558,3.826-0.367,10.02,0.543,12.752-3.101,2.734-3.642,7.652-14.209,14.028-15.485,6.377-1.272,9.11,0.729,9.838,3.826,0.729,3.1-0.912,9.84-0.912,9.84l-2.549,5.463-8.564,2.918s-0.18,4.371-4.007,5.827c-3.825,1.46-12.752-0.543-17.854,0.729-5.1,1.275-4.653,3.521-11.295,4.737-11.548,2.116-12.173-5.009-18.232-2.59z"/>
|
||||
|
||||
<path d="m303.13,600.59c5.109-6.156,10.974,2.27,17.296-1.594,3.454-2.11,4.564-10.246,8.754-11.705,4.191-1.457,9.473,1.277,12.388-2.55,2.915-3.826,0.547-9.841,6.558-12.572,6.014-2.729,10.932-0.728,10.932-0.728s5.283-1.821,8.199-1.459c2.914,0.365,9.656,4.012,9.656,4.012s1.456,8.377-4.01,13.297c-5.465,4.921-10.565,7.471-15.303,5.83-3.05-1.055-4.917-1.641-7.285,0-2.37,1.641-2.37,4.373-6.924,4.373s-8.198-2.914-10.93-1.094c-2.733,1.819-4.158,5.658-8.928,6.377-12.231,1.844-13.419-5.218-20.403-2.187z"/>
|
||||
|
||||
<path d="m411.96,622.09s-3.158,2.917-5.102,3.767c-1.944,0.851-6.804,2.913-6.804,2.913s4.374,0.489,4.982,1.823c0.606,1.338-1.104,4.906-5.83,2.308,1.976,4.849,9.664,2.724,11.779,1.823,1.767-0.752,4.858-1.336,5.588-0.364,0.728,0.971-0.266,4.265-5.344,3.033,8.016,4.294,11.66-0.729,14.089-2.185,2.43-1.459,4.615-2.551,5.345-1.459,0.729,1.094,1.207,4.313-4.617,4.373,4.886,1.564,8.016-0.485,9.596-2.064,1.577-1.577,2.269-2.009,3.728-2.496,1.457-0.486,6.594-2.361,7.444-5.032,0.852-2.673,0.729-4.616-0.485-6.194-1.215-1.58-2.672-1.945-3.644-1.824-0.972,0.123-1.578,0.73-1.578,1.462,0,0.726,0.848,3.034-1.579,3.884-2.429,0.851-4.98,1.7-7.41,0.973-2.429-0.729-5.586-1.46-7.894-1.336-2.307,0.12-3.886,1.821-5.83,0.849-1.941-0.975-6.434-4.254-6.434-4.254z"/>
|
||||
|
||||
<path d="m547.98,521.16c1.943-1.212,4.13-2.668,7.044-0.483,2.916,2.187,5.155,6.253,8.258,8.987,7.825,6.896,5.394,11.146,1.7,16.516,2.193-9.182-4.856-11.494-4.126-7.041,0.563,3.434,3.069,5.672-1.946,15.06,0.391-3.2,0.244-5.831-0.484-7.531-0.729-1.698-2.672-1.942-2.672,0.245,0,2.182,3.234,10.336-6.073,17.971,7.12-10.635-0.428-14.188-1.944-9.957-2.623,7.322-0.748,14.635-11.173,16.03,8.362-6.896,4.658-12.913,1.215-9.47-4.158,4.158-1.54,11.074-9.961,16.03,4.636-7.661-0.893-7.363-3.155-6.073-1.702,0.971-8.504,5.341-8.504,5.341s0.972-4.37,1.217-8.256c0.24-3.886,1.456-4.614,1.456-4.614s6.315-0.728,8.258-4.617c1.944-3.887,3.158-9.231,7.531-11.172,4.372-1.945,12.631-3.645,14.817-7.771,2.186-4.13,1.701-6.076-0.242-8.988-1.944-2.919-4.131-4.854-3.888-6.559,0.243-1.703,1.186-2.718,2.672-3.648z"/>
|
||||
|
||||
<path d="m543.61,529.36c-2.599-2.209-8.987,0.305-8.987,0.305s4.978-1.125,6.618,1.246c0.909,1.307,0.092,2.912-0.273,2.912,0,0-0.001,0.004-0.003,0.004-0.602-0.506-1.366-0.822-2.213-0.822-1.911,0-3.46,1.55-3.46,3.461,0,1.864,1.467,3.346,3.295,3.593,2.319,0.313,4.523-0.357,5.842-1.771,2.023-2.171,2.825-5.83-0.819-8.928z"/>
|
||||
|
||||
<path d="m527.3,533.01c-2.945-0.79-8.198,1.273-8.198,1.273s5.968-0.32,7.378,1.457c1.167,1.472,0.584,2.716,0.376,3.006-0.519-0.248-1.098-0.393-1.712-0.393-2.181,0-3.948,1.766-3.948,3.949,0,2.179,1.768,3.947,3.948,3.947,0.183,0,1.249-0.166,1.609-0.344,0.58-0.287,1.353-0.709,1.829-1.141,1.935-1.756,2.5-3.363,2.726-6.291,0.14-1.814-1.063-4.67-4.008-5.463z"/>
|
||||
|
||||
<path d="m442.89,610.35s-0.639,3.826-5.102,5.559c-2.21,0.857-4.357,0.055-5.591-0.609,0.699-0.781,1.127-1.812,1.127-2.945,0-2.449-1.982-4.434-4.432-4.434-2.239,0-4.089,1.661-4.389,3.817-0.093,0.667-0.15,1.32,0.017,2.058,0.495,2.16,2.297,4.455,5.524,5.301,3.826,1,8.157-0.08,10.475-2.825,2.46-2.917,2.371-5.922,2.371-5.922z"/>
|
||||
|
||||
<path d="m502.07,460.92c19.043-1.048,17.543,12.202,9.716,15.062,0,0-3.765-0.242-8.016,0.123-4.801,0.409-5.969-5.17-0.094-5.92,5.737-0.733,9.562-5.813-1.606-9.265z"/>
|
||||
|
||||
<path d="m437.46,479.14c7.594-9.643,15.844,0.732,22.349-3.158,3.758-2.248,7.529-7.775,13.36-7.047,5.83,0.729,9.23,1.943,12.145,0.242,2.914-1.697,7.775-6.557,14.333-4.613,3.462,1.029,4.989,3.785,5.28,5.225,0.417,2.055,0.306,6.435,0.306,6.435l-10.201,1.7c-3.888,1.701-7.774,4.375-7.774,4.375s-4.129-5.831-7.773-6.075c-3.644-0.241-9.472,0.244-12.146,2.429-2.672,2.188-6.779,6.01-11.174,4.616-4.798-1.522-8.798-8.334-18.705-4.129z"/>
|
||||
|
||||
<path d="m428.23,491.77c11.636-9.71,11.386,3.228,21.761-6.71,4.08-3.907,8.063-1.188,13.46-4.463,5.13-3.113,9.715-9.23,16.031-7.287,6.316,1.939,9.961,7.529,9.961,7.529s-8.017,5.586-9.717,8.984c-1.701,3.402-10.033-1.645-14.575-1.214-5.101,0.489-7.685,3.819-12.389,6.075-14.333,6.877-13.583-5.686-24.532-2.914z"/>
|
||||
|
||||
<path d="m415.36,520.92c9.572,5.393,15.879,2.891,17.973,0,2.939-4.056,3.144-9.014,7.287-10.928,4.055-1.873,8.56,0.189,10.689-2.432,3.157-3.885-0.703-16.087,6.072-20.648,12.631-8.5,24.29,0.488,24.29,0.488s-5.708,5.828-6.558,13.848c-0.718,6.766,2.187,10.442,2.187,10.442s-3.479,6.896-13.118,5.59c-11.751-1.593-13.053-0.324-18.751,4.907-6.874,6.313-20.595,9.499-30.071-1.267z"/>
|
||||
|
||||
<path d="m405.88,539.38c8.234-5.445,10.359,1.742,19.433,1.214,4.143-0.241,7.289-2.185,9.959-6.798,2.672-4.617,4.616-7.047,9.959-7.286,5.344-0.245,9.716-0.977,11.902-4.378,2.187-3.398-1.699-12.875,3.887-19.187,5.588-6.317,14.333-4.376,14.333-4.376s-1.194,5.555,0.122,9.594c1.7,5.223,4.493,5.469,4.493,5.469s-5.068,2.232-6.314,8.621c-0.96,4.917,0.97,9.355,0.97,9.355s-6.316,6.559-16.76,5.102c-10.445-1.458-11.417-1.945-15.061,0.728-3.644,2.671-6.719,6.966-15.059,7.286-12.005,0.461-11.505-7.977-21.864-5.344z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g stroke-width="1.429" fill="#fff">
|
||||
|
||||
<path d="m387.91,572.9c-9.666,7.408-12.791,1.471-20.041-1.459-1.994-0.806-5.344-0.242-5.344-0.242l-1.699-3.888s-0.85-2.671,0.606-3.886c1.458-1.217,5.952-0.85,6.802,0.363,0.851,1.217,3.399,5.104,4.857,6.014,1.458,0.906,4.858,1.883,7.409,1.032,2.55-0.853,8.017-5.222,13.846-9.351,5.83-4.131,10.808-6.317,16.031-6.196,5.223,0.12,9.961,2.184,13.967,2.429,4.01,0.246,8.988-1.822,12.997-4.127,4.007-2.311,15.008-4.386,24.776-12.025,3.811-2.98,12.631-12.995,14.938-15.787,2.309-2.798,12.631,1.943,14.576,3.4,1.944,1.458,10.444,0.484,10.444,0.484s4.979-2.063,6.679-2.309c1.701-0.242,2.186,0.729,2.186,0.729s6.195,8.627,8.138,15.428c1.946,6.797,1.946,21.252,1.095,26.473-0.851,5.227-1.823,14.456-8.624,18.828-6.803,4.371-28.056,14.087-38.986,16.517-10.932,2.429-17.611,6.317-22.592,6.071-4.98-0.24-16.762-3.396-21.01-3.884-4.25-0.487-8.501-0.606-8.501-0.606s-0.487,6.68-2.309,10.808c-1.822,4.127-8.859,10.615-14.204,12.071-5.342,1.459-13.245,2.016-13.729,2.621-0.488,0.61-0.73,2.432,0,3.766,0.727,1.34,4.613,8.383,5.951,9.355,1.552,1.129,5.886,2.124,7.771,1.821,1.815-0.292,4.25,0,5.102,1.335,0.85,1.336,0.851,4.372,0.364,4.979-0.486,0.61-1.578,1.096-1.578,1.096s-0.244,2.188-1.215,2.796c-0.972,0.604-2.673,1.092-2.673,1.092s-0.122,0.847-1.215,1.696c-1.093,0.853-1.785,0.725-1.785,0.725s-1.369,0.237-2.464,0.978c-1.805,1.221-7.742-0.242-10.082-2.43-1.821-1.701-2.188-5.342-2.671-6.559-0.486-1.212-2.066-2.79-3.402-3.765-1.336-0.973-21.618-25.263-21.618-27.811,0-2.552,2.31-9.476,5.346-10.809,3.034-1.338,8.621-3.159,9.351-5.103,0.729-1.942-1.848-11.221,0.242-17.734,2.977-9.277,4.29-9.465,12.268-14.936z"/>
|
||||
|
||||
<path d="m363.62,573.09c4.938,1.414,1.375,8.039-3.461,5.828-2.339-1.069-4.281-4.279-3.279-6.833,1.001-2.546,2.825-3.277,4.098-3.005,1.275,0.271,3.644,1.824,3.644,1.824l-3.735,1.91c-0.762,1.458-0.364,2.732,1.094,3.464,1.305,0.651,3.077-0.711,1.639-3.188z"/>
|
||||
|
||||
<path d="m474.02,563.19s13.489,1.655,17.775,5.622c4.885,4.523,3.845,8.465,3.845,8.465s7.59-0.119,10.687,3.039c2.536,2.587,4.07,4.248,4.07,4.248l9.534,2.672c3.28,2.673,4.676,5.223,4.676,5.223s2.246-0.119,6.132,1.336c3.887,1.459,6.559,2.916,8.744,2.55,2.187-0.362,6.802-1.577,7.774-3.767,0.973-2.186-0.607-7.526,1.82-9.957,2.43-2.428,3.887-3.034,5.587-2.549,1.703,0.484,3.279,4.492,3.279,4.492l4.253,2.917c0.713,1.906,0,4.005,0,4.005s0.363,0.731,0.971,2.794c0.607,2.063-0.304,2.978-0.304,2.978s0.061,2.488-1.274,3.461c-1.338,0.973-3.158,1.216-3.158,1.216s0.561,2.091-0.168,3.425c-0.729,1.333-1.429,1.785-2.5,2.262-0.831,0.368-2.434,0.141-3.405,1.116-0.973,0.967-1.94,3.396-5.101,2.913-3.159-0.486-4.131-2.428-8.26-3.279-4.128-0.849-18.581-2.065-27.083-3.648-8.502-1.574-15.302-1.574-23.563-1.695-8.26-0.121-20.282-1.459-20.769-5.224-0.486-3.764,2.552-7.53,2.186-9.472-0.364-1.944-2.307-8.016-2.307-10.203s-1.568-10.701,0.849-13.848c1.216-1.579,4.008-1.579,5.71-1.092z"/>
|
||||
|
||||
<path d="m364.89,565.07c-0.399,3.365-3.587,1.303-2.824-0.64,0.581-1.479,3.279-1.641,4.646-1.003,1.366,0.639,3.644,3.464,3.825,4.372,0.183,0.911,0.51-3.675-1.183-5.555-1.641-1.825-5.193-2.825-8.018-1.367-2.445,1.261-3.279,3.188-2.915,5.919,0.313,2.352,2.565,3.931,4.646,4.192,3.548,0.448,6.298-3.177,1.823-5.918z"/>
|
||||
|
||||
<path d="m375.09,567.07c5.21-2.135,0.96-7.26-1.458-6.922-2.263,0.316-3.593,2.115-3.828,3.643-0.24,1.564,0.296,3.826,1.185,4.826,0.684,0.771,2.445,1.321,2.445,1.321s-1.904-2.741-0.714-4.597c1.142-1.78,3.58-2.093,2.37,1.729z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<path fill="#fff" stroke="none" d="m463.89,559c8.617,3.146,9.46,3.168,15.442,4.337,3.271,0.64,2.942,4.616,0.585,7.446s-0.472,10.842-2.357,13.199-6.579,5.318-6.579,5.318c-4.263-3.434-8.504-4.848-8.976-11.918-0.47-7.08,1.89-18.39,1.89-18.39z"/>
|
||||
|
||||
<g fill="#f4e109">
|
||||
|
||||
<path d="m399.81,658.08s-2.623-1.095-3.349,0.82c-0.728,1.916,0.488,2.89,0.488,2.89s4.104,0.651,1.55,5.729c6.804-3.952,1.311-9.439,1.311-9.439z"/>
|
||||
|
||||
<path d="m403.26,655.94s-2.242-0.728-2.632,1.055c-0.389,1.783,0.772,3.008,0.772,3.008s2.961,0.75,1.796,4.503c5.353-4.504,0.064-8.566,0.064-8.566z"/>
|
||||
|
||||
<path d="m409.39,649.88s-0.831-0.381-1.438,0.833c-0.606,1.218-0.005,2.833-0.005,2.833s3.294,0.391,3.339,4.549c4.642-6.596-1.896-8.215-1.896-8.215z"/>
|
||||
|
||||
<path d="m454.1,634.6s-2.087-0.613-2.574,2.422c-0.403,2.519,1.482,2.802,1.482,2.802s3.485-1.263,3.643,2.913c5.405-4.739-2.551-8.137-2.551-8.137z"/>
|
||||
|
||||
<path d="m457.37,630.71s-1.765-0.27-1.817,2.491c-0.043,2.289,1.474,2.295,1.474,2.295s3.4-1.688,4.208,2.357c4.254-4.857-3.865-7.143-3.865-7.143z"/>
|
||||
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 40 KiB |
@@ -1,17 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M13 2L3 14h8l-1 8 10-12h-8l1-8z"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 880" fill="black">
|
||||
<path d="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z" />
|
||||
<path d="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 320 B After Width: | Height: | Size: 303 B |
|
Before Width: | Height: | Size: 39 KiB |
@@ -5,7 +5,7 @@
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0c14",
|
||||
"theme_color": "#e85d3c",
|
||||
"theme_color": "#ff6600",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
@@ -38,66 +38,11 @@
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "https://blossom.ditto.pub/47fcb3bdc414ced245fbba53b0456d9bfdf112d711ecf5cc628361a47002392a.png",
|
||||
"sizes": "1080x1920",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "https://blossom.ditto.pub/e387a4c4566545c650477cee66e638131eb874a90761cf0a371e5abf1e2c7af2.png",
|
||||
"sizes": "1080x1920",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "https://blossom.ditto.pub/73585c9547868da215a91f8a13543251a20967a74f6e8329231544add50e3dee.png",
|
||||
"sizes": "1080x1920",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "https://blossom.ditto.pub/4216d18d5854444c64b482dbbac9e077a453f85a9d72d91bc6adf89fd1f42c36.png",
|
||||
"sizes": "1080x1920",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "https://blossom.ditto.pub/a7521e652d625c57cf66fd97ea92c66b8559bcedcb805aaf375bc047415625a3.png",
|
||||
"sizes": "1080x1920",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "https://blossom.ditto.pub/daf008f146391f0172b89595500f640b84eac4e146b2e081db80791819443fa0.png",
|
||||
"sizes": "1080x1920",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "https://blossom.ditto.pub/22c144a792e32559a838c8f69fbb4ae22264f47ff3d04341e656748d264064bc.png",
|
||||
"sizes": "1080x1920",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "https://blossom.ditto.pub/fa609e4a03e984063fbc6c474bd8a73bb088666b7c0e5561f993264f372c90ef.png",
|
||||
"sizes": "1080x1920",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
],
|
||||
"related_applications": [
|
||||
{
|
||||
"platform": "play",
|
||||
"url": "https://play.google.com/store/apps/details?id=pub.ditto.app",
|
||||
"id": "pub.ditto.app"
|
||||
},
|
||||
{
|
||||
"platform": "itunes",
|
||||
"url": "https://apps.apple.com/us/app/ditto-fun-social-media/id6761851821",
|
||||
"id": "6761851821"
|
||||
"url": "https://play.google.com/store/apps/details?id=spot.agora.app",
|
||||
"id": "spot.agora.app"
|
||||
}
|
||||
],
|
||||
"prefer_related_applications": false
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -56,8 +56,42 @@ self.addEventListener('notificationclick', (event) => {
|
||||
});
|
||||
|
||||
// --- Activate immediately ---
|
||||
//
|
||||
// On activate:
|
||||
// 1. Wipe every Cache Storage entry. A previous version of Agora deployed
|
||||
// a precaching service worker (Workbox-style) that's still serving stale
|
||||
// HTML/JS to returning users on this origin. Clearing caches means future
|
||||
// requests bypass anything the old SW left behind.
|
||||
// 2. Take control of all open clients via clients.claim().
|
||||
// 3. Force each controlled tab to navigate to its own URL. clients.claim()
|
||||
// only changes which SW handles future fetches — it does not re-render
|
||||
// pages that already finished loading. Without the explicit navigate,
|
||||
// the user is stuck on the old rendered bundle until they manually
|
||||
// close and reopen the tab. Since this SW has no fetch handler, the
|
||||
// navigation falls through to the network and gets the new build.
|
||||
//
|
||||
// This SW has no 'fetch' handler, so it never repopulates a cache — push
|
||||
// notifications are the only thing it intercepts.
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
await self.clients.claim();
|
||||
|
||||
// Soft-reload every open same-origin tab so it picks up the fresh
|
||||
// index.html + hashed bundle from the network. WindowClient.navigate()
|
||||
// is same-origin-only by spec, which is exactly what we want.
|
||||
const windowClients = await self.clients.matchAll({ type: 'window' });
|
||||
await Promise.all(
|
||||
windowClients.map((client) =>
|
||||
'navigate' in client
|
||||
? client.navigate(client.url).catch(() => {})
|
||||
: Promise.resolve(),
|
||||
),
|
||||
);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,58 +1,33 @@
|
||||
// Reads the saved theme from localStorage and applies it to <html> and the
|
||||
// preloader background before first paint. Runs as a blocking <script> so
|
||||
// there's no flash of the wrong theme.
|
||||
//
|
||||
// Agora's colors are hardcoded in src/index.css via :root {} and .dark {}
|
||||
// blocks. There is no custom-theme branch; the only thing this script
|
||||
// does is set the right class on <html> and paint the preloader with the
|
||||
// matching background + primary color so the page doesn't flash white
|
||||
// in dark mode (or vice versa) before the React bundle boots.
|
||||
//
|
||||
// The colors below MUST stay in sync with the values in src/index.css.
|
||||
(function () {
|
||||
// Builtin themes — must match builtinThemes in src/themes.ts
|
||||
var builtins = {
|
||||
dark: { bg: 'hsl(0 0% 10%)', primary: 'hsl(15 90% 52%)' },
|
||||
light: { bg: 'hsl(0 0% 100%)', primary: 'hsl(15 90% 48%)' }
|
||||
dark: { bg: 'hsl(0 0% 10%)', primary: 'hsl(24 100% 50%)' },
|
||||
light: { bg: 'hsl(0 0% 100%)', primary: 'hsl(24 100% 50%)' }
|
||||
};
|
||||
|
||||
var theme = 'dark';
|
||||
var colors = builtins.dark;
|
||||
var cfg;
|
||||
var theme = 'system';
|
||||
try {
|
||||
cfg = JSON.parse(localStorage.getItem('nostr:app-config') || '{}');
|
||||
if (cfg.theme) theme = cfg.theme;
|
||||
var cfg = JSON.parse(localStorage.getItem('nostr:app-config') || '{}');
|
||||
if (cfg.theme === 'dark' || cfg.theme === 'light' || cfg.theme === 'system') {
|
||||
theme = cfg.theme;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Resolve "system" to light or dark based on OS preference
|
||||
if (theme === 'system') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
if (theme === 'custom') {
|
||||
// Custom theme: read colors from customTheme.colors (ThemeConfig format)
|
||||
try {
|
||||
var ct = cfg && cfg.customTheme;
|
||||
if (ct && ct.colors) {
|
||||
var bg = ct.colors.background;
|
||||
var pr = ct.colors.primary;
|
||||
if (bg && pr) {
|
||||
colors = { bg: 'hsl(' + bg + ')', primary: 'hsl(' + pr + ')' };
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (theme === 'light' || theme === 'dark') {
|
||||
// Check for configured theme overrides (ThemesConfig in cfg.themes)
|
||||
try {
|
||||
var themes = cfg && cfg.themes;
|
||||
if (themes && themes[theme] && themes[theme].colors) {
|
||||
var tc = themes[theme].colors;
|
||||
if (tc.background && tc.primary) {
|
||||
colors = { bg: 'hsl(' + tc.background + ')', primary: 'hsl(' + tc.primary + ')' };
|
||||
} else {
|
||||
colors = builtins[theme];
|
||||
}
|
||||
} else {
|
||||
colors = builtins[theme];
|
||||
}
|
||||
} catch (e) {
|
||||
colors = builtins[theme];
|
||||
}
|
||||
} else {
|
||||
colors = builtins.dark;
|
||||
}
|
||||
var colors = builtins[theme] || builtins.dark;
|
||||
|
||||
document.documentElement.className = theme;
|
||||
document.body.style.background = colors.bg;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Build a heavily-simplified land-polygon dataset for the hero globe.
|
||||
//
|
||||
// Input: Natural Earth 110m countries TopoJSON
|
||||
// (https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json)
|
||||
//
|
||||
// Output: src/lib/landPolygons.ts — an array of rings (each ring is a flat
|
||||
// array [lng0, lat0, lng1, lat1, ...]) representing landmasses.
|
||||
//
|
||||
// Run with: node scripts/build-land-polygons.mjs
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INPUT = process.argv[2] ?? '/tmp/opencode/countries-110m.json';
|
||||
const OUTPUT = path.join(REPO_ROOT, 'src/lib/landPolygons.ts');
|
||||
|
||||
const topo = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
|
||||
const layer = topo.objects.countries;
|
||||
const transform = topo.transform;
|
||||
|
||||
/** Decode a topojson arc into absolute [lng, lat] pairs. */
|
||||
function decodeArc(arc) {
|
||||
const out = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (const [dx, dy] of arc) {
|
||||
x += dx;
|
||||
y += dy;
|
||||
out.push([
|
||||
x * transform.scale[0] + transform.translate[0],
|
||||
y * transform.scale[1] + transform.translate[1],
|
||||
]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const arcs = topo.arcs.map(decodeArc);
|
||||
|
||||
/** Resolve a topojson arc index (negative means reversed) into points. */
|
||||
function resolveArc(i) {
|
||||
if (i < 0) {
|
||||
const arc = arcs[~i];
|
||||
return arc.slice().reverse();
|
||||
}
|
||||
return arcs[i];
|
||||
}
|
||||
|
||||
/** Build a ring from an array of arc indices. */
|
||||
function buildRing(arcIndices) {
|
||||
const ring = [];
|
||||
for (let i = 0; i < arcIndices.length; i++) {
|
||||
const seg = resolveArc(arcIndices[i]);
|
||||
// Skip the duplicated joining point between consecutive arcs.
|
||||
if (i === 0) ring.push(...seg);
|
||||
else ring.push(...seg.slice(1));
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
const rings = [];
|
||||
for (const feature of layer.geometries) {
|
||||
if (feature.type === 'Polygon') {
|
||||
for (const arcIndices of feature.arcs) {
|
||||
rings.push(buildRing(arcIndices));
|
||||
}
|
||||
} else if (feature.type === 'MultiPolygon') {
|
||||
for (const polygon of feature.arcs) {
|
||||
for (const arcIndices of polygon) {
|
||||
rings.push(buildRing(arcIndices));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the full Natural Earth 110m resolution. We skip Douglas-Peucker
|
||||
// entirely so coastlines look organic at hero scale rather than blocky.
|
||||
// We still quantize to 0.1° (well below the rendered pixel size on a
|
||||
// ~600 px globe) which is a free ~30 % byte saving with no visible loss.
|
||||
const MIN_VERTS = 3;
|
||||
|
||||
const simplifiedRings = [];
|
||||
for (const ring of rings) {
|
||||
if (ring.length < MIN_VERTS) continue;
|
||||
const flat = [];
|
||||
for (const [lng, lat] of ring) {
|
||||
flat.push(Math.round(lng * 10) / 10, Math.round(lat * 10) / 10);
|
||||
}
|
||||
simplifiedRings.push(flat);
|
||||
}
|
||||
|
||||
const totalCoords = simplifiedRings.reduce((sum, r) => sum + r.length / 2, 0);
|
||||
|
||||
const banner = `/**
|
||||
* Simplified land polygons for the hero globe.
|
||||
*
|
||||
* Generated from Natural Earth 110m country boundaries via
|
||||
* \`scripts/build-land-polygons.mjs\`. Each entry is a flat \`[lng, lat, lng,
|
||||
* lat, ...]\` ring. We keep the data inline (rather than fetching a TopoJSON
|
||||
* blob at runtime) so the hero renders instantly, with no network jitter and
|
||||
* no extra runtime dependency.
|
||||
*
|
||||
* Do not edit by hand — re-run the script to regenerate.
|
||||
*/
|
||||
`;
|
||||
|
||||
const body = `export const LAND_RINGS: readonly (readonly number[])[] = [\n${
|
||||
simplifiedRings.map((r) => ` [${r.join(',')}],`).join('\n')
|
||||
}\n];\n`;
|
||||
|
||||
fs.writeFileSync(OUTPUT, banner + body);
|
||||
|
||||
console.log(
|
||||
`Wrote ${OUTPUT}`,
|
||||
`\n rings: ${simplifiedRings.length}`,
|
||||
`\n vertices: ${totalCoords}`,
|
||||
`\n bytes: ${fs.statSync(OUTPUT).size}`,
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { hkdf } from '@noble/hashes/hkdf.js';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { entropyToMnemonic, mnemonicToSeedSync } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { bech32m, hex } from '@scure/base';
|
||||
import * as btc from '@scure/btc-signer';
|
||||
|
||||
// Test vector 1: all-zero nsec
|
||||
const nsec1 = new Uint8Array(32);
|
||||
|
||||
// Test vector 2: nsec from a deterministic source — all 0x01s
|
||||
const nsec2 = new Uint8Array(32).fill(0x01);
|
||||
|
||||
// Test vector 3: an actual realistic nsec
|
||||
const nsec3 = hex.decode('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa');
|
||||
|
||||
function deriveAll(nsec, label) {
|
||||
const entropy = hkdf(sha256, nsec, undefined, 'agora/v1', 32);
|
||||
const mnemonic = entropyToMnemonic(entropy, wordlist);
|
||||
const seed = mnemonicToSeedSync(mnemonic);
|
||||
const root = HDKey.fromMasterSeed(seed);
|
||||
const account = root.derive("m/86'/0'/0'");
|
||||
const receive0 = account.deriveChild(0).deriveChild(0);
|
||||
const xonly = receive0.publicKey.slice(1, 33);
|
||||
const { address } = btc.p2tr(xonly, undefined, btc.NETWORK);
|
||||
|
||||
const spendNode = root.derive("m/352'/0'/0'/0'/0");
|
||||
const scanNode = root.derive("m/352'/0'/0'/1'/0");
|
||||
const spendPub = spendNode.publicKey;
|
||||
const scanPub = scanNode.publicKey;
|
||||
const payload = new Uint8Array(66);
|
||||
payload.set(scanPub, 0);
|
||||
payload.set(spendPub, 33);
|
||||
const words = [0, ...bech32m.toWords(payload)];
|
||||
const spAddress = bech32m.encode('sp', words, 1023);
|
||||
|
||||
console.log(`\n--- ${label} ---`);
|
||||
console.log(`nsec hex: ${hex.encode(nsec)}`);
|
||||
console.log(`entropy: ${hex.encode(entropy)}`);
|
||||
console.log(`mnemonic: ${mnemonic}`);
|
||||
console.log(`seed: ${hex.encode(seed)}`);
|
||||
console.log(`xpub: ${account.publicExtendedKey}`);
|
||||
console.log(`addr 0/0: ${address}`);
|
||||
console.log(`sp addr: ${spAddress}`);
|
||||
}
|
||||
|
||||
deriveAll(nsec1, 'all-zero nsec');
|
||||
deriveAll(nsec2, 'all-0x01 nsec');
|
||||
deriveAll(nsec3, 'realistic nsec');
|
||||
@@ -25,7 +25,7 @@
|
||||
* node scripts/extract-release-notes.mjs <version> [--summary] [--changelog <path>]
|
||||
*
|
||||
* --summary Print only the summary paragraph (no headings, no bullets).
|
||||
* Falls back to "Ditto vX.Y.Z" if the section has no summary.
|
||||
* Falls back to "Agora vX.Y.Z" if the section has no summary.
|
||||
* --changelog Path to the changelog file. Defaults to CHANGELOG.md.
|
||||
*
|
||||
* Exits 0 with the extracted text on stdout. Exits non-zero if the version is
|
||||
@@ -128,7 +128,7 @@ if (!section) {
|
||||
|
||||
if (summary) {
|
||||
const text = extractSummary(section);
|
||||
stdout.write(text ?? `Ditto v${version}`);
|
||||
stdout.write(text ?? `Agora v${version}`);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
const body = trimBlankEdges(section).join('\n');
|
||||
@@ -136,6 +136,6 @@ if (summary) {
|
||||
stdout.write(body);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
stdout.write(`Ditto v${version}\n`);
|
||||
stdout.write(`Agora v${version}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Generate src/lib/subdivisionCodes.ts — the authoritative list of ISO 3166-2
|
||||
// subdivision codes, extracted from the `iso-3166` package.
|
||||
//
|
||||
// We ship only the code strings (~42 KB) instead of importing the full
|
||||
// `iso-3166` dataset (~244 KB of objects with names, parents, and tree
|
||||
// structure) into the critical-path bundle. The only thing the runtime needs
|
||||
// these for is validating that a `CC-XX` code is a real subdivision
|
||||
// (see src/lib/countries.ts `isValidSubdivisionCode`).
|
||||
//
|
||||
// Run with: node scripts/gen-subdivision-codes.mjs
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { iso31662 } from 'iso-3166';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const OUTPUT = path.join(REPO_ROOT, 'src/lib/subdivisionCodes.ts');
|
||||
|
||||
const codes = [...new Set(iso31662.map((s) => s.code))].sort();
|
||||
|
||||
const header = `// AUTO-GENERATED — do not edit by hand.
|
||||
//
|
||||
// The authoritative list of ISO 3166-2 subdivision codes, extracted from the
|
||||
// \`iso-3166\` package at build time. We ship only the code strings (~42 KB)
|
||||
// instead of importing the full \`iso-3166\` dataset (~244 KB of objects with
|
||||
// names, parents, and tree structure) into the critical-path bundle, since
|
||||
// the only thing the runtime needs these for is validating that a \`CC-XX\`
|
||||
// code is a real subdivision.
|
||||
//
|
||||
// Regenerate with: node scripts/gen-subdivision-codes.mjs
|
||||
|
||||
`;
|
||||
|
||||
const body = `export const SUBDIVISION_CODES: readonly string[] = ${JSON.stringify(codes)};\n`;
|
||||
|
||||
fs.writeFileSync(OUTPUT, header + body);
|
||||
console.log(`Wrote ${path.relative(REPO_ROOT, OUTPUT)} (${codes.length} codes)`);
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
# Regenerate the web tab favicon assets from public/logo.svg.
|
||||
#
|
||||
# Why this script exists:
|
||||
# The 16x16 favicon edge antialiasing must survive small-size rendering.
|
||||
# Saving as palette/indexed PNG or 8bpp ICO bakes the editor's background
|
||||
# into the antialiased edge pixels, producing the "ugly outline" halo
|
||||
# you see in browser tab strips. We render at high resolution, downsample
|
||||
# with Lanczos, and force full 32-bit RGBA output.
|
||||
#
|
||||
# Outputs (overwrites in place):
|
||||
# public/favicon-16.png — 16x16 RGBA
|
||||
# public/favicon-32.png — 32x32 RGBA
|
||||
# public/favicon.ico — multi-size ICO, every frame 32bpp
|
||||
# public/apple-touch-icon.png — 180x180 RGBA
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Pick an SVG renderer (inkscape preferred, rsvg-convert fallback)
|
||||
if command -v inkscape &> /dev/null; then
|
||||
SVG_RENDERER="inkscape"
|
||||
elif command -v rsvg-convert &> /dev/null; then
|
||||
SVG_RENDERER="rsvg"
|
||||
else
|
||||
echo -e "${YELLOW}Error: install inkscape or rsvg-convert.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ImageMagick: v7 uses `magick`, v6 uses `convert`.
|
||||
if command -v magick &> /dev/null; then
|
||||
MAGICK="magick"
|
||||
elif command -v convert &> /dev/null; then
|
||||
MAGICK="convert"
|
||||
else
|
||||
echo -e "${YELLOW}Error: install ImageMagick.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_SVG="public/logo.svg"
|
||||
if [ ! -f "$SOURCE_SVG" ]; then
|
||||
echo -e "${YELLOW}Error: $SOURCE_SVG not found.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Agora brand orange. Keep in sync with the preloader / theme.
|
||||
LOGO_COLOR="#FF6600"
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
COLORED_SVG="$TMPDIR/logo_colored.svg"
|
||||
RAW_PNG="$TMPDIR/raw.png"
|
||||
MASTER_PNG="$TMPDIR/master.png"
|
||||
|
||||
# Recolor the SVG's black fill to the brand color, on transparent background.
|
||||
sed 's/fill="black"/fill="'"$LOGO_COLOR"'"/g' "$SOURCE_SVG" > "$COLORED_SVG"
|
||||
|
||||
# The SVG's viewBox is 720x880 (taller than wide). Render at its native
|
||||
# aspect ratio first so we don't squish the logo horizontally.
|
||||
MASTER_BOX=512 # final square canvas size
|
||||
MASTER_H=$MASTER_BOX # render the longer side at full size
|
||||
MASTER_W=$(( MASTER_BOX * 720 / 880 )) # preserve 720:880 aspect
|
||||
|
||||
echo "Rendering ${MASTER_W}x${MASTER_H} from $SOURCE_SVG (preserving 720:880 aspect)..."
|
||||
if [ "$SVG_RENDERER" = "inkscape" ]; then
|
||||
inkscape --export-type=png --export-filename="$RAW_PNG" \
|
||||
-w "$MASTER_W" -h "$MASTER_H" --export-background-opacity=0 \
|
||||
"$COLORED_SVG" 2>/dev/null
|
||||
else
|
||||
rsvg-convert -w "$MASTER_W" -h "$MASTER_H" -b none "$COLORED_SVG" -o "$RAW_PNG"
|
||||
fi
|
||||
|
||||
# Centre the rendered logo on a transparent square canvas so downstream
|
||||
# square targets (favicons, apple-touch-icon) don't restretch it.
|
||||
$MAGICK -size "${MASTER_BOX}x${MASTER_BOX}" "xc:none" \
|
||||
"$RAW_PNG" -gravity center -compose over -composite \
|
||||
"$MASTER_PNG"
|
||||
|
||||
# Downsample to a target size with a quality filter, forcing RGBA output.
|
||||
# `png:color-type=6` is PNG RGBA (8 bits per channel, with alpha).
|
||||
# `-strip` removes metadata to keep files small.
|
||||
render_png() {
|
||||
local size=$1
|
||||
local dest=$2
|
||||
$MAGICK "$MASTER_PNG" \
|
||||
-filter Lanczos \
|
||||
-resize "${size}x${size}" \
|
||||
-background none -alpha on \
|
||||
-define png:color-type=6 \
|
||||
-strip \
|
||||
"$dest"
|
||||
echo -e " ${GREEN}✓${NC} $dest ($(file -b "$dest" | head -c 80))"
|
||||
}
|
||||
|
||||
echo "Generating PNG variants..."
|
||||
render_png 16 public/favicon-16.png
|
||||
render_png 32 public/favicon-32.png
|
||||
render_png 180 public/apple-touch-icon.png
|
||||
|
||||
# Multi-size ICO. Building from the high-res master with auto-resize keeps
|
||||
# every frame 32bpp RGBA. Verified with `file public/favicon.ico`.
|
||||
echo "Generating multi-size favicon.ico (16, 32, 48)..."
|
||||
$MAGICK "$MASTER_PNG" \
|
||||
-filter Lanczos \
|
||||
-background none -alpha on \
|
||||
-define icon:auto-resize=16,32,48 \
|
||||
public/favicon.ico
|
||||
echo -e " ${GREEN}✓${NC} public/favicon.ico ($(file -b public/favicon.ico | head -c 120))"
|
||||
|
||||
echo -e "\n${GREEN}Favicons regenerated.${NC}"
|
||||
@@ -42,14 +42,17 @@ if [ ! -f "$SOURCE_SVG" ]; then
|
||||
fi
|
||||
|
||||
# Brand colors
|
||||
BG_COLOR="#7c52e0" # Ditto purple
|
||||
BG_COLOR="#e9673f" # Agora orange (hsl(14 79% 58%))
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
LOGO_WHITE_SVG="$TMPDIR/logo_white.svg"
|
||||
LOGO_WHITE="$TMPDIR/logo_white.png"
|
||||
|
||||
# Recolor the SVG fill to white before rasterizing.
|
||||
sed 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
|
||||
# Recolor the SVG fill to white before rasterizing. logo.svg declares the
|
||||
# glyph with fill="black", so recolor both the attribute form and any hex.
|
||||
sed -e 's/fill="black"/fill="#ffffff"/g' \
|
||||
-e 's/#000000/#ffffff/g' \
|
||||
-e 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
|
||||
|
||||
echo "Rendering white SVG at 512x512..."
|
||||
|
||||
@@ -82,23 +85,27 @@ make_foreground 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foregrou
|
||||
|
||||
# ── Legacy launcher icons (ic_launcher.png and ic_launcher_round.png) ──
|
||||
# These are used on pre-API-26 devices and as fallback on some launchers.
|
||||
# They must have the logo composited onto the purple background — NOT just
|
||||
# a solid color fill.
|
||||
# Both are the white logo composited onto an orange circle (brand mark).
|
||||
|
||||
echo "Generating legacy launcher icons (ic_launcher.png and ic_launcher_round.png)..."
|
||||
|
||||
# make_legacy_square: logo on flat purple square background
|
||||
# make_legacy_square: white logo on an orange circle (transparent corners)
|
||||
make_legacy_square() {
|
||||
local size=$1
|
||||
local content_size=$(echo "$size * 60 / 100" | bc)
|
||||
local dest=$2
|
||||
local mask="$TMPDIR/circle_mask_sq_${size}.png"
|
||||
$MAGICK -size "${size}x${size}" "xc:none" \
|
||||
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
|
||||
"$mask"
|
||||
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
|
||||
"$mask" -compose dst-in -composite \
|
||||
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
|
||||
-gravity center -compose over -composite \
|
||||
"$dest"
|
||||
}
|
||||
|
||||
# make_legacy_round: logo on circular purple background (alpha-masked circle)
|
||||
# make_legacy_round: white logo on circular orange background (alpha-masked circle)
|
||||
make_legacy_round() {
|
||||
local size=$1
|
||||
local content_size=$(echo "$size * 60 / 100" | bc)
|
||||
@@ -108,7 +115,7 @@ make_legacy_round() {
|
||||
$MAGICK -size "${size}x${size}" "xc:none" \
|
||||
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
|
||||
"$mask"
|
||||
# Fill purple, apply circle mask, composite logo
|
||||
# Fill orange, apply circle mask, composite logo
|
||||
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
|
||||
"$mask" -compose dst-in -composite \
|
||||
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
|
||||
@@ -134,11 +141,11 @@ mkdir -p android/app/src/main/res/values
|
||||
cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#7c52e0</color>
|
||||
<color name="ic_launcher_background">#e9673f</color>
|
||||
</resources>
|
||||
EOF
|
||||
|
||||
# ── iOS App Icon (1024x1024, white logo on purple background) ──
|
||||
# ── iOS App Icon (1024x1024, white logo on orange background) ──
|
||||
|
||||
echo "Generating iOS app icon..."
|
||||
|
||||
@@ -146,7 +153,7 @@ IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
|
||||
|
||||
if [ -d "$IOS_ICON_DIR" ]; then
|
||||
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
|
||||
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
|
||||
# Logo at ~60% of canvas, centered on orange background (matches Android style)
|
||||
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
|
||||
\( "$LOGO_WHITE" -resize "614x614" \) \
|
||||
-gravity center -compose over -composite \
|
||||
@@ -160,7 +167,7 @@ fi
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
echo -e "\n${GREEN}App icons generated successfully!${NC}"
|
||||
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
|
||||
echo -e "Icon: white Agora logo on ${GREEN}${BG_COLOR}${NC} (Agora orange)"
|
||||
echo -e "Generated:"
|
||||
echo -e " Android:"
|
||||
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
|
||||
|
||||
@@ -6,8 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { InferSeoMetaPlugin } from "@unhead/addons";
|
||||
import { createHead, UnheadProvider } from "@unhead/react/client";
|
||||
import { AppProvider } from "@/components/AppProvider";
|
||||
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
|
||||
import { InitialSyncGate } from "@/components/InitialSyncGate";
|
||||
import { InitialSyncRunner } from "@/components/InitialSyncRunner";
|
||||
import { NativeNotifications } from "@/components/NativeNotifications";
|
||||
import NostrProvider from "@/components/NostrProvider";
|
||||
import { NostrSync } from "@/components/NostrSync";
|
||||
@@ -19,10 +18,9 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { SparkWalletProvider } from "@/contexts/SparkWalletContext";
|
||||
import { OnboardingProvider } from "@/contexts/OnboardingProvider";
|
||||
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import { PROTOCOL_MODE } from "@samthomson/nostr-messaging/core";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
const head = createHead({
|
||||
@@ -44,9 +42,8 @@ const hardcodedConfig: AppConfig = {
|
||||
appName: "Agora",
|
||||
appId: "agora",
|
||||
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
|
||||
homePage: "feed",
|
||||
homePage: "campaigns",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
useAppRelays: true,
|
||||
useUserRelays: false,
|
||||
@@ -60,48 +57,46 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeReactions: false,
|
||||
feedIncludeZaps: false,
|
||||
feedIncludeZaps: true,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showHighlights: true,
|
||||
feedIncludeHighlights: false,
|
||||
feedIncludeHighlights: true,
|
||||
showEvents: true,
|
||||
feedIncludeEvents: true,
|
||||
showVines: true,
|
||||
showVines: false,
|
||||
showPolls: true,
|
||||
showTreasures: true,
|
||||
showTreasureGeocaches: true,
|
||||
showTreasureFoundLogs: true,
|
||||
showColors: true,
|
||||
showTreasures: false,
|
||||
showTreasureGeocaches: false,
|
||||
showTreasureFoundLogs: false,
|
||||
showColors: false,
|
||||
showPeopleLists: true,
|
||||
feedIncludeVines: true,
|
||||
feedIncludeVines: false,
|
||||
feedIncludePolls: true,
|
||||
feedIncludeTreasureGeocaches: true,
|
||||
feedIncludeTreasureFoundLogs: true,
|
||||
feedIncludeColors: true,
|
||||
feedIncludeTreasureGeocaches: false,
|
||||
feedIncludeTreasureFoundLogs: false,
|
||||
feedIncludeColors: false,
|
||||
feedIncludePeopleLists: true,
|
||||
showDecks: true,
|
||||
feedIncludeDecks: true,
|
||||
showWebxdc: true,
|
||||
feedIncludeWebxdc: true,
|
||||
showDecks: false,
|
||||
feedIncludeDecks: false,
|
||||
showPhotos: true,
|
||||
feedIncludePhotos: true,
|
||||
showVideos: true,
|
||||
feedIncludeNormalVideos: true,
|
||||
feedIncludeShortVideos: true,
|
||||
feedIncludeVoiceMessages: true,
|
||||
showEmojiPacks: true,
|
||||
feedIncludeEmojiPacks: true,
|
||||
showCustomEmojis: true,
|
||||
showUserStatuses: true,
|
||||
showMusic: true,
|
||||
feedIncludeMusicTracks: true,
|
||||
feedIncludeMusicPlaylists: true,
|
||||
showPodcasts: true,
|
||||
feedIncludePodcastEpisodes: true,
|
||||
feedIncludePodcastTrailers: true,
|
||||
showDevelopment: true,
|
||||
feedIncludeDevelopment: true,
|
||||
showEmojiPacks: false,
|
||||
feedIncludeEmojiPacks: false,
|
||||
showCustomEmojis: false,
|
||||
showUserStatuses: false,
|
||||
showMusic: false,
|
||||
feedIncludeMusicTracks: false,
|
||||
feedIncludeMusicPlaylists: false,
|
||||
showPodcasts: false,
|
||||
feedIncludePodcastEpisodes: false,
|
||||
feedIncludePodcastTrailers: false,
|
||||
showDevelopment: false,
|
||||
feedIncludeDevelopment: false,
|
||||
showCommunities: true,
|
||||
feedIncludeCommunities: true,
|
||||
showBadges: true,
|
||||
@@ -112,24 +107,22 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeBadgeAwards: true,
|
||||
feedIncludeVanish: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeBirdex: true,
|
||||
feedIncludeConstellations: true,
|
||||
showBirdstar: false,
|
||||
feedIncludeBirdDetections: false,
|
||||
feedIncludeBirdex: false,
|
||||
feedIncludeConstellations: false,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
"wallet",
|
||||
"verified",
|
||||
"actions",
|
||||
"polls",
|
||||
"world",
|
||||
"badges",
|
||||
"feed",
|
||||
"notifications",
|
||||
"messages",
|
||||
"communities",
|
||||
"world",
|
||||
"wallet",
|
||||
"agent",
|
||||
"messages",
|
||||
"profile",
|
||||
"notifications",
|
||||
"search",
|
||||
"settings",
|
||||
],
|
||||
nip85StatsPubkey:
|
||||
@@ -150,21 +143,26 @@ const hardcodedConfig: AppConfig = {
|
||||
savedFeeds: [],
|
||||
autoplayVideos: false,
|
||||
imageQuality: 'compressed',
|
||||
imageProxy: 'https://wsrv.nl',
|
||||
lowBandwidthMode: false,
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
esploraBaseUrl: 'https://mempool.space/api',
|
||||
esploraApis: [
|
||||
'https://mempool.emzy.de/api',
|
||||
'https://mempool.space/api',
|
||||
'https://blockstream.info/api',
|
||||
],
|
||||
blockbookBaseUrl: 'https://btc.trezor.io',
|
||||
bip352IndexerUrl: 'https://silentpayments.dev/blindbit/mainnet',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'hot-posts' },
|
||||
{ id: 'ai-chat' },
|
||||
],
|
||||
messaging: {
|
||||
enabled: true,
|
||||
relayMode: 'hybrid',
|
||||
protocolMode: PROTOCOL_MODE.NIP17_ONLY,
|
||||
renderInlineMedia: true,
|
||||
soundEnabled: false,
|
||||
devMode: false,
|
||||
},
|
||||
aiBaseURL: 'https://ai.shakespeare.diy/v1',
|
||||
aiApiKey: '',
|
||||
aiModel: 'google/gemma-4-26b',
|
||||
aiSystemPrompt: '',
|
||||
translateWorkerUrl: import.meta.env.VITE_TRANSLATE_WORKER_URL || '',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -207,18 +205,15 @@ export function App() {
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<InitialSyncRunner />
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<SparkWalletProvider>
|
||||
<DMProviderWrapper>
|
||||
<TooltipProvider>
|
||||
<InitialSyncGate>
|
||||
<OnboardingProvider>
|
||||
<TooltipProvider>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
</TooltipProvider>
|
||||
</DMProviderWrapper>
|
||||
</SparkWalletProvider>
|
||||
</TooltipProvider>
|
||||
</OnboardingProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
</NostrLoginProvider>
|
||||
|
||||
@@ -1,136 +1,64 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
|
||||
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
|
||||
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { BrowserRouter, Link, Navigate, Outlet, Route, Routes } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { TopNav } from "./components/TopNav";
|
||||
import { OnboardingGate } from "./components/OnboardingGate";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { getExtraKindDef } from "./lib/extraKinds";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { openUrl } from "@/lib/downloadFile";
|
||||
|
||||
// Critical-path pages: eagerly loaded (landing + fallback)
|
||||
import Index from "./pages/Index";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import MessagesPage from "./pages/Messages";
|
||||
|
||||
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
|
||||
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
|
||||
|
||||
// Lazy-loaded emoji pack dialog
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
|
||||
// when an naddr resolves to kind 33863.)
|
||||
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
|
||||
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
|
||||
const AllCampaignsPage = lazy(() => import("./pages/AllCampaignsPage").then(m => ({ default: m.AllCampaignsPage })));
|
||||
const CampaignListDetailPage = lazy(() => import("./pages/CampaignListDetailPage").then(m => ({ default: m.CampaignListDetailPage })));
|
||||
|
||||
// All other pages: code-split via React.lazy
|
||||
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
|
||||
const CreateActionPage = lazy(() => import("./pages/CreateActionPage").then(m => ({ default: m.CreateActionPage })));
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
|
||||
const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage").then(m => ({ default: m.AppearanceSettingsPage })));
|
||||
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
|
||||
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
|
||||
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
|
||||
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
|
||||
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
|
||||
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
|
||||
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
|
||||
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
|
||||
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
|
||||
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
|
||||
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
|
||||
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
|
||||
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
|
||||
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
|
||||
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
|
||||
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
|
||||
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
|
||||
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
|
||||
const MessagingSettingsPage = lazy(() => import("./pages/MessagingSettingsPage").then(m => ({ default: m.MessagingSettingsPage })));
|
||||
const MusicPage = lazy(() => import("./pages/MusicPage").then(m => ({ default: m.MusicPage })));
|
||||
const MyDashboardPage = lazy(() => import("./pages/MyDashboardPage").then(m => ({ default: m.MyDashboardPage })));
|
||||
const AboutPage = lazy(() => import("./pages/AboutPage").then(m => ({ default: m.AboutPage })));
|
||||
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
|
||||
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
|
||||
const LanguageSettingsPage = lazy(() => import("./pages/LanguageSettingsPage").then(m => ({ default: m.LanguageSettingsPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
|
||||
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
|
||||
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
|
||||
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
|
||||
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
|
||||
const EventDashboardPage = lazy(() => import("./pages/EventDashboardPage").then(m => ({ default: m.EventDashboardPage })));
|
||||
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
|
||||
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
|
||||
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
|
||||
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
|
||||
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
|
||||
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
|
||||
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
|
||||
const VerifiedPage = lazy(() => import("./pages/VerifiedPage").then(m => ({ default: m.VerifiedPage })));
|
||||
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
|
||||
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
const WalletMigrateV1Page = lazy(() => import("./pages/WalletMigrateV1Page").then(m => ({ default: m.WalletMigrateV1Page })));
|
||||
const WalletDoubleTweakFixPage = lazy(() => import("./pages/WalletDoubleTweakFixPage").then(m => ({ default: m.WalletDoubleTweakFixPage })));
|
||||
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const LegacyWalletRecoveryPage = lazy(() => import("./pages/LegacyWalletRecoveryPage").then(m => ({ default: m.LegacyWalletRecoveryPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
const colorsDef = getExtraKindDef("colors")!;
|
||||
const packsDef = getExtraKindDef("packs")!;
|
||||
const articlesDef = getExtraKindDef("articles")!;
|
||||
const decksDef = getExtraKindDef("decks")!;
|
||||
const emojisDef = getExtraKindDef("emojis")!;
|
||||
const developmentDef = getExtraKindDef("development")!;
|
||||
const highlightsDef = getExtraKindDef("highlights")!;
|
||||
|
||||
/** Polls feed page with a FAB that opens the compose modal (poll mode via + menu). */
|
||||
function PollsFeedPage() {
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<KindFeedPage
|
||||
kind={pollsDef.kind}
|
||||
title={pollsDef.label}
|
||||
icon={sidebarItemIcon("polls", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
{composeOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
|
||||
function EmojiFeedPage() {
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<KindFeedPage
|
||||
kind={emojisDef.kind}
|
||||
title={emojisDef.label}
|
||||
icon={sidebarItemIcon("emojis", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
{composeOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
|
||||
function ProfileRedirect() {
|
||||
const { user, metadata } = useCurrentUser();
|
||||
@@ -139,170 +67,158 @@ function ProfileRedirect() {
|
||||
return <Navigate to={profileUrl} replace />;
|
||||
}
|
||||
|
||||
function PageSkeleton() {
|
||||
// Shown briefly while a route's lazy chunk is being fetched. A skeleton
|
||||
// tuned to one page's shape (`max-w-6xl` with hero + paragraph blocks)
|
||||
// ends up wrong-shaped on every other page — narrow settings pages,
|
||||
// small wallet screens, etc. A neutral centered spinner is honest about
|
||||
// "loading" without misleading the eye with content-shaped boxes that
|
||||
// never appear.
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteFooter() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<footer className="bg-background mt-auto pt-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openUrl("https://gitlab.com/soapbox-pub/agora")}
|
||||
className="hover:text-foreground motion-safe:transition-colors"
|
||||
>
|
||||
{t('nav.sourceCode')}
|
||||
</button>
|
||||
<nav className="flex items-center gap-5">
|
||||
<Link to="/about" className="hover:text-foreground motion-safe:transition-colors">{t('nav.about')}</Link>
|
||||
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">{t('nav.privacy')}</Link>
|
||||
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">{t('nav.safety')}</Link>
|
||||
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">{t('nav.changelog')}</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent app shell. GoFundMe-style top-nav-only chrome wrapping the route
|
||||
* outlet. The width of the center column is decided by the layout variant
|
||||
* picked in the route tree below — narrow (default, `max-w-3xl`) for
|
||||
* form/prose-style pages, wide (full width) for landing / dashboard / detail
|
||||
* pages that render their own internal layout.
|
||||
*/
|
||||
function FundraiserLayout({ narrow }: { narrow: boolean }) {
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col bg-background">
|
||||
<TopNav />
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<div
|
||||
className={cn("flex-1 min-w-0 w-full mx-auto", narrow && "max-w-3xl")}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Suspense>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<AudioPlayerProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<MinimizedAudioBar />
|
||||
<AudioNavigationGuard />
|
||||
<DeepLinkHandler />
|
||||
<ScrollToTop />
|
||||
<BrowserRouter>
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<ScrollToTop />
|
||||
<OnboardingGate>
|
||||
<Routes>
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
{/* Narrow layout — `max-w-3xl` center column. The default for
|
||||
form/prose-style pages. */}
|
||||
<Route element={<FundraiserLayout narrow />}>
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/my-dashboard" element={<MyDashboardPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/profile" element={<ProfileRedirect />} />
|
||||
<Route path="/t/:tag" element={<HashtagPage />} />
|
||||
<Route path="/g/:geohash" element={<GeotagPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="/settings/language" element={<LanguageSettingsPage />} />
|
||||
<Route path="/settings/profile" element={<ProfileSettings />} />
|
||||
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
|
||||
<Route path="/settings/notifications" element={<NotificationSettings />} />
|
||||
<Route path="/settings/advanced" element={<AdvancedSettingsPage />} />
|
||||
<Route path="/settings/network" element={<NetworkSettingsPage />} />
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/wallet/legacy" element={<LegacyWalletRecoveryPage />} />
|
||||
{/* Old nested paths kept as redirects so any existing links / muscle
|
||||
memory still land on the right page. `/wallet/settings` was an
|
||||
intermediate hub that has been replaced by an overflow menu on
|
||||
`/wallet`, so it redirects to the wallet home. `/wallet/backup`
|
||||
is now an in-page dialog opened from that menu, so it also
|
||||
redirects home. */}
|
||||
<Route path="/wallet/settings" element={<Navigate to="/wallet" replace />} />
|
||||
<Route path="/wallet/backup" element={<Navigate to="/wallet" replace />} />
|
||||
<Route path="/wallet/settings/backup" element={<Navigate to="/wallet" replace />} />
|
||||
<Route path="/wallet/settings/legacy" element={<Navigate to="/wallet/legacy" replace />} />
|
||||
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
|
||||
<Route path="/wallet/migrate-v1" element={<WalletMigrateV1Page />} />
|
||||
<Route path="/wallet/double-tweak-fix" element={<WalletDoubleTweakFixPage />} />
|
||||
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
|
||||
{/* Legacy /help routes redirect to /about so existing links keep
|
||||
working. The About page and the two guides themselves live
|
||||
under the wide layout below. */}
|
||||
<Route path="/help" element={<Navigate to="/about" replace />} />
|
||||
<Route path="/help/donors" element={<Navigate to="/about/donors" replace />} />
|
||||
<Route path="/help/activists" element={<Navigate to="/about/activists" replace />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
<Route path="/organizers" element={<OrganizersPage />} />
|
||||
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
|
||||
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
|
||||
</Route>
|
||||
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/trends" element={<TrendsPage />} />
|
||||
<Route path="/profile" element={<ProfileRedirect />} />
|
||||
<Route path="/t/:tag" element={<HashtagPage />} />
|
||||
<Route path="/g/:geohash" element={<GeotagPage />} />
|
||||
<Route path="/feed/:domain" element={<DomainFeedPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="/settings/profile" element={<ProfileSettings />} />
|
||||
<Route path="/settings/feed" element={<ContentSettingsPage />} />
|
||||
<Route path="/settings/content" element={<ContentPage />} />
|
||||
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
|
||||
<Route
|
||||
path="/settings/notifications"
|
||||
element={<NotificationSettings />}
|
||||
/>
|
||||
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
|
||||
<Route
|
||||
path="/settings/advanced"
|
||||
element={<AdvancedSettingsPage />}
|
||||
/>
|
||||
<Route path="/settings/magic" element={<MagicSettingsPage />} />
|
||||
<Route path="/settings/network" element={<NetworkSettingsPage />} />
|
||||
<Route path="/lists" element={<UserListsPage />} />
|
||||
<Route path="/events" element={<EventsFeedPage />} />
|
||||
<Route path="/photos" element={<PhotosFeedPage />} />
|
||||
<Route path="/videos" element={<VideosFeedPage />} />
|
||||
{/* /streams redirects to /videos for backward compatibility */}
|
||||
<Route
|
||||
path="/streams"
|
||||
element={<Navigate to="/videos" replace />}
|
||||
/>
|
||||
<Route path="/vines" element={<VinesFeedPage />} />
|
||||
<Route path="/music" element={<MusicPage />} />
|
||||
<Route path="/podcasts" element={<PodcastsFeedPage />} />
|
||||
<Route path="/polls" element={<PollsFeedPage />} />
|
||||
<Route path="/treasures" element={<TreasuresPage />} />
|
||||
<Route
|
||||
path="/colors"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={colorsDef.kind}
|
||||
title={colorsDef.label}
|
||||
icon={sidebarItemIcon("colors", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packs"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={packsDef.kind}
|
||||
title={packsDef.label}
|
||||
icon={sidebarItemIcon("packs", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/webxdc" element={<WebxdcFeedPage />} />
|
||||
<Route path="/articles/new" element={<ArticleEditorPage />} />
|
||||
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
|
||||
<Route
|
||||
path="/articles"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={articlesDef.kind}
|
||||
title={articlesDef.label}
|
||||
icon={sidebarItemIcon("articles", "size-5")}
|
||||
fabHref="/articles/new"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/highlights"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={highlightsDef.kind}
|
||||
title={highlightsDef.label}
|
||||
icon={sidebarItemIcon("highlights", "size-5")}
|
||||
showFAB={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/decks"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={decksDef.kind}
|
||||
title={decksDef.label}
|
||||
icon={sidebarItemIcon("decks", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/emojis" element={<EmojiFeedPage />} />
|
||||
<Route
|
||||
path="/development"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={[
|
||||
developmentDef.kind,
|
||||
...(developmentDef.extraFeedKinds ?? []),
|
||||
]}
|
||||
title={developmentDef.label}
|
||||
icon={sidebarItemIcon("development", "size-5")}
|
||||
showFAB={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/verified" element={<VerifiedPage />} />
|
||||
<Route path="/world" element={<WorldPage />} />
|
||||
<Route path="/badges" element={<BadgesPage />} />
|
||||
<Route path="/books" element={<BooksPage />} />
|
||||
<Route path="/archive" element={<ArchivePage />} />
|
||||
<Route path="/bluesky" element={<BlueskyPage />} />
|
||||
<Route path="/wikipedia" element={<WikipediaPage />} />
|
||||
<Route path="/communities" element={<CommunitiesPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
<Route path="/r/*" element={<RelayPage />} />
|
||||
<Route
|
||||
path="/settings/lists"
|
||||
element={<Navigate to="/lists" replace />}
|
||||
/>
|
||||
<Route path="/i/*" element={<ExternalContentPage />} />
|
||||
<Route path="/actions" element={<ActionsPage />} />
|
||||
<Route path="/organizers" element={<OrganizersPage />} />
|
||||
|
||||
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
|
||||
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||
<Route path="/:nip19" element={<NIP19Page />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
{/* Wide layout — no max-width on the center column. Used by landing /
|
||||
list / detail pages that render their own internal width
|
||||
constraints. */}
|
||||
<Route element={<FundraiserLayout narrow={false} />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/campaigns" element={<AllCampaignsPage />} />
|
||||
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
|
||||
<Route path="/campaigns/lists/:slug" element={<CampaignListDetailPage />} />
|
||||
{/* Legacy URL: the all-campaigns directory lived at
|
||||
`/campaigns/all` for a while. Keep it as a redirect so
|
||||
external links and bookmarks still resolve. */}
|
||||
<Route path="/campaigns/all" element={<Navigate to="/campaigns" replace />} />
|
||||
<Route path="/groups" element={<CommunitiesPage />} />
|
||||
<Route path="/groups/new" element={<CreateCommunityPage />} />
|
||||
<Route path="/events/new" element={<CreateEventPage />} />
|
||||
<Route path="/pledges" element={<ActionsPage />} />
|
||||
<Route path="/pledges/new" element={<CreateActionPage />} />
|
||||
<Route path="/dashboard" element={<EventDashboardPage />} />
|
||||
<Route path="/i/*" element={<ExternalContentPage />} />
|
||||
{/* About page + Donor / Activist guides. Full-bleed landing-style
|
||||
layouts that render their own internal max-widths. */}
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/about/donors" element={<DonorGuidePage />} />
|
||||
<Route path="/about/activists" element={<ActivistGuidePage />} />
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1.
|
||||
Goes through the wide layout because the dispatch may resolve to
|
||||
a profile, campaign, action, or community page — all of which
|
||||
render their own internal layout. PostDetailPage / ListDetailPage
|
||||
also work edge-to-edge. */}
|
||||
<Route path="/:nip19" element={<NIP19Page />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AudioPlayerProvider>
|
||||
</OnboardingGate>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
export default AppRouter;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useProfileBadges } from '@/hooks/useProfileBadges';
|
||||
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface AcceptBadgeButtonProps {
|
||||
interface AcceptBadgeButtonProps {
|
||||
/** The kind 8 badge award event. */
|
||||
awardEvent: NostrEvent;
|
||||
/** Prominent pill style (large, rounded-full, colored). Otherwise compact outline variant. */
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Camera, Clock, DollarSign, Info, Megaphone, Palette } from 'lucide-react';
|
||||
|
||||
import { parseAction, type Action } from '@/hooks/useActions';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { countryCodeToFlag, getGeoDisplayName } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ACTION_ICONS = {
|
||||
photo: Camera,
|
||||
art: Palette,
|
||||
info: Info,
|
||||
action: Megaphone,
|
||||
} as const;
|
||||
|
||||
function actionNaddr(action: Action): string {
|
||||
return nip19.naddrEncode({
|
||||
kind: 36639,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
}
|
||||
|
||||
export function ActionContent({ event, compact = true }: { event: NostrEvent; compact?: boolean }) {
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const action = parseAction(event);
|
||||
if (!action) return null;
|
||||
|
||||
const Icon = ACTION_ICONS[action.type];
|
||||
const now = Date.now() / 1000;
|
||||
const startTime = action.startTime ?? action.createdAt;
|
||||
const isUpcoming = startTime > now;
|
||||
const isExpired = !!action.deadline && action.deadline <= now;
|
||||
const coverImage = action.image ?? DEFAULT_COVER_IMAGE;
|
||||
const href = `/${actionNaddr(action)}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
'mt-2 block overflow-hidden rounded-xl border border-border bg-card transition-colors hover:bg-muted/30',
|
||||
isExpired && 'opacity-75',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={cn('relative overflow-hidden bg-muted', compact ? 'h-36' : 'h-56')}>
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={action.title}
|
||||
className={cn('h-full w-full object-cover transition-transform duration-300 hover:scale-[1.02]', isExpired && 'grayscale')}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
|
||||
{action.countryCode && (
|
||||
<CountryFlag
|
||||
code={action.countryCode}
|
||||
emoji={countryCodeToFlag(action.countryCode)}
|
||||
label={getGeoDisplayName(action.countryCode)}
|
||||
className="absolute left-3 top-3 text-2xl drop-shadow-md"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute bottom-3 left-3 right-3 flex items-center justify-between gap-2 text-white">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-black/45 px-2.5 py-1 text-xs font-semibold backdrop-blur-sm">
|
||||
<Icon className="size-3.5" />
|
||||
Pledge
|
||||
</span>
|
||||
{isExpired ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
|
||||
<Clock className="size-3" /> Expired
|
||||
</span>
|
||||
) : isUpcoming ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
|
||||
<Clock className="size-3" /> Starts {format(startTime * 1000, 'MMM d')}
|
||||
</span>
|
||||
) : action.deadline ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
|
||||
<Clock className="size-3" /> Due {format(action.deadline * 1000, 'MMM d')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||
<h3 className="line-clamp-2 text-base font-bold leading-tight">{action.title}</h3>
|
||||
</div>
|
||||
{action.description.trim() && (
|
||||
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<DollarSign className="size-4 shrink-0 text-primary" />
|
||||
<span className="font-semibold">
|
||||
{btcPrice ? satsToUSDWhole(action.bounty, btcPrice) : `${formatSats(action.bounty)} sats`}
|
||||
</span>
|
||||
{btcPrice && <span className="text-xs text-muted-foreground">~{formatSats(action.bounty)} sats</span>}
|
||||
{action.countryCode && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{getGeoDisplayName(action.countryCode)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Check,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ModerationMenuItems } from '@/components/moderation';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { getPledgeCoord } from '@/lib/pledges';
|
||||
import type { Action } from '@/hooks/useActions';
|
||||
|
||||
/**
|
||||
* Per-card kebab menu for pledges. Surfaces:
|
||||
* • Delete (owner only) — NIP-09 with both `e` and `a` tags so
|
||||
* relays that ignore a-tag-only deletions still drop the event.
|
||||
* • Copy link — naddr1 URL on the current share origin.
|
||||
* • Moderation actions (mods only) — hide / feature, under a
|
||||
* separator that only renders when the viewer is a moderator.
|
||||
*
|
||||
* Lives outside `ActionsPage` so both the page and the reusable
|
||||
* `PledgesDiscoverySection` can pin it to the card's `topRight` slot
|
||||
* without duplicating the logic.
|
||||
*/
|
||||
export function ActionShareMenu({
|
||||
action,
|
||||
displayTitle,
|
||||
}: {
|
||||
action: Action;
|
||||
displayTitle: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
const shareOrigin = useShareOrigin();
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isOwner = user?.pubkey === action.pubkey;
|
||||
// Moderator gate is identical to the one in `ModerationMenuItems`,
|
||||
// duplicated here so we can decide whether to render the trailing
|
||||
// separator that introduces the moderator section.
|
||||
// `ModerationMenuItems` returns `null` for non-mods, so without
|
||||
// this check we'd render an orphaned separator at the bottom of
|
||||
// the dropdown.
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 36639,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
|
||||
const actionUrl = `${shareOrigin}/${naddr}`;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(actionUrl);
|
||||
setCopied(true);
|
||||
toast({ title: t('pledges.card.linkCopied') });
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link:', error);
|
||||
toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!user || !isOwner) return;
|
||||
|
||||
const confirmed = window.confirm(t('pledges.card.confirmDelete'));
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't
|
||||
// honour a-tag-only deletions for addressable events.
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
content: t('pledges.card.deletedContent'),
|
||||
tags: [
|
||||
['e', action.event.id],
|
||||
['a', getPledgeCoord(action)],
|
||||
],
|
||||
});
|
||||
// Extract any organization `A` tag the pledge was associated with so
|
||||
// the org's activity shelf and community feeds refresh too.
|
||||
const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1];
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-actions'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-action'] }),
|
||||
...(orgATag
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['organization-activity', orgATag],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['community-actions', orgATag],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
return (
|
||||
root === 'community-activity-feed' &&
|
||||
typeof aTagsKey === 'string' &&
|
||||
aTagsKey.split(',').includes(orgATag)
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
toast({ title: t('pledges.card.deleted') });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete pledge:', error);
|
||||
toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('pledges.card.actionsAriaLabel')}
|
||||
className="h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
{isOwner && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('pledges.card.deletePledge')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleCopyLink}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 mr-2 text-primary" />
|
||||
) : (
|
||||
<LinkIcon className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('pledges.card.copyLink')}
|
||||
</DropdownMenuItem>
|
||||
{/* Moderator actions appear under a separator when the viewer
|
||||
is a Team Soapbox moderator. `ModerationMenuItems` returns
|
||||
null for non-mods, so we gate the trailing separator on
|
||||
the same `isMod` check to avoid an orphan separator at
|
||||
the bottom of non-mod dropdowns. */}
|
||||
{isMod && <DropdownMenuSeparator />}
|
||||
<ModerationMenuItems
|
||||
coord={getPledgeCoord(action)}
|
||||
entityTitle={displayTitle}
|
||||
surface="pledge"
|
||||
axes={['hide', 'featured']}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,802 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { UserPlus, Loader2, X, Search, Crown, Users, PartyPopper } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { ImageUploadField } from '@/components/ImageUploadField';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
|
||||
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { useSearchPeopleLists, type PeopleListSearchResult } from '@/hooks/useSearchPeopleLists';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
import { parseAuthorEvent } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
BADGE_DEFINITION_KIND,
|
||||
BADGE_AWARD_KIND,
|
||||
EMPTY_MODERATION,
|
||||
type CommunityMember,
|
||||
type CommunityMembership,
|
||||
type CommunityModeration,
|
||||
type ParsedCommunity,
|
||||
} from '@/lib/communityUtils';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type MemberRole = 'moderator' | 'member';
|
||||
|
||||
interface PendingMember {
|
||||
profile: SearchProfile;
|
||||
role: MemberRole;
|
||||
}
|
||||
|
||||
interface BadgeRef {
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
interface CommunityMembersCacheValue {
|
||||
membership: CommunityMembership;
|
||||
moderation: CommunityModeration;
|
||||
rankMap: Map<string, CommunityMember>;
|
||||
}
|
||||
|
||||
interface AddMemberDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The raw community definition event. */
|
||||
communityEvent: NostrEvent;
|
||||
/** Parsed community data. */
|
||||
community: ParsedCommunity;
|
||||
/** Whether the current user is the founder (can add moderators). */
|
||||
isFounder: boolean;
|
||||
/** Existing active members and moderators, excluded from duplicate adds. */
|
||||
existingMemberPubkeys: string[];
|
||||
}
|
||||
|
||||
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
|
||||
if (!aTag) return undefined;
|
||||
const [kind, pubkey, ...identifierParts] = aTag.split(':');
|
||||
const identifier = identifierParts.join(':');
|
||||
if (kind !== String(BADGE_DEFINITION_KIND) || !pubkey || !identifier) return undefined;
|
||||
return { pubkey, identifier };
|
||||
}
|
||||
|
||||
function isHexPubkey(value: string): boolean {
|
||||
return /^[0-9a-f]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
function makeFallbackProfile(pubkey: string): SearchProfile {
|
||||
return {
|
||||
pubkey,
|
||||
metadata: {},
|
||||
event: {
|
||||
id: '',
|
||||
pubkey,
|
||||
created_at: 0,
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: '{}',
|
||||
sig: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function profileFromEvent(event: NostrEvent): SearchProfile {
|
||||
const parsed = parseAuthorEvent(event);
|
||||
return { pubkey: event.pubkey, metadata: parsed.metadata ?? {}, event };
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AddMemberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communityEvent,
|
||||
community,
|
||||
isFounder,
|
||||
existingMemberPubkeys,
|
||||
}: AddMemberDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Form state
|
||||
const [pendingMembers, setPendingMembers] = useState<PendingMember[]>([]);
|
||||
const [badgeImageUrl, setBadgeImageUrl] = useState('');
|
||||
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
|
||||
const dialogContentRef = useCallback((node: HTMLElement | null) => {
|
||||
setPortalContainer(node ?? undefined);
|
||||
}, []);
|
||||
|
||||
// Mutations
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Does this community already have a member badge definition?
|
||||
const existingBadgeATag = community.memberBadgeATag;
|
||||
const hasBadge = !!existingBadgeATag;
|
||||
const existingBadgeRef = useMemo(() => parseBadgeATag(existingBadgeATag), [existingBadgeATag]);
|
||||
const existingBadgeRefs = useMemo(() => existingBadgeRef ? [existingBadgeRef] : [], [existingBadgeRef]);
|
||||
const { badgeMap, isLoading: isBadgeLoading, isError: isBadgeError } = useBadgeDefinitions(existingBadgeRefs);
|
||||
const existingBadge = existingBadgeATag ? badgeMap.get(existingBadgeATag) : undefined;
|
||||
|
||||
// Are there any pending members with the "member" role?
|
||||
const hasPendingMembers = pendingMembers.some((m) => m.role === 'member');
|
||||
// Will we need to create a badge? (members added + no badge exists yet)
|
||||
const needsBadgeCreation = hasPendingMembers && !hasBadge;
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setPendingMembers([]);
|
||||
setBadgeImageUrl('');
|
||||
setIsBadgeImageUploading(false);
|
||||
setIsPublishing(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
}, [onOpenChange, resetForm]);
|
||||
|
||||
// ── People management ─────────────────────────────────────────────────────
|
||||
|
||||
const addPerson = useCallback((profile: SearchProfile) => {
|
||||
if (!user) return;
|
||||
if (profile.pubkey === community.founderPubkey) {
|
||||
toast({ title: 'Already the founder' });
|
||||
return;
|
||||
}
|
||||
if (existingMemberPubkeys.includes(profile.pubkey)) {
|
||||
toast({ title: 'Already in the community' });
|
||||
return;
|
||||
}
|
||||
if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) {
|
||||
toast({ title: 'Already added' });
|
||||
return;
|
||||
}
|
||||
setPendingMembers((prev) => [...prev, { profile, role: 'member' }]);
|
||||
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
|
||||
|
||||
const addPeople = useCallback((profiles: SearchProfile[], sourceTitle?: string) => {
|
||||
if (!user) return;
|
||||
|
||||
const excluded = new Set([
|
||||
community.founderPubkey,
|
||||
...existingMemberPubkeys,
|
||||
...pendingMembers.map((m) => m.profile.pubkey),
|
||||
]);
|
||||
const nextProfiles: SearchProfile[] = [];
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (excluded.has(profile.pubkey)) continue;
|
||||
excluded.add(profile.pubkey);
|
||||
nextProfiles.push(profile);
|
||||
}
|
||||
|
||||
if (nextProfiles.length === 0) {
|
||||
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingMembers((prev) => [...prev, ...nextProfiles.map((profile) => ({ profile, role: 'member' as const }))]);
|
||||
if (sourceTitle) {
|
||||
toast({ title: `Added ${nextProfiles.length} from ${sourceTitle}` });
|
||||
}
|
||||
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
|
||||
|
||||
const removePerson = useCallback((pubkey: string) => {
|
||||
setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey));
|
||||
}, []);
|
||||
|
||||
const setRole = useCallback((pubkey: string, role: MemberRole) => {
|
||||
if (!isFounder) return; // Only founder can appoint moderators
|
||||
setPendingMembers((prev) => prev.map((m) =>
|
||||
m.profile.pubkey === pubkey
|
||||
? { ...m, role }
|
||||
: m,
|
||||
));
|
||||
}, [isFounder]);
|
||||
|
||||
const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map<string, NostrEvent>) => {
|
||||
queryClient.setQueryData<CommunityMembersCacheValue>(['community-members', community.aTag], (prev) => {
|
||||
const moderation = prev?.moderation ?? EMPTY_MODERATION;
|
||||
const rankMap = new Map(prev?.rankMap ?? []);
|
||||
const membershipByPubkey = new Map(
|
||||
(prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const),
|
||||
);
|
||||
|
||||
const seedRankZero = (pubkey: string) => {
|
||||
if (moderation.bannedPubkeys.has(pubkey)) return;
|
||||
const member: CommunityMember = { pubkey, rank: 0 };
|
||||
if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member);
|
||||
if (!rankMap.has(pubkey)) rankMap.set(pubkey, member);
|
||||
};
|
||||
|
||||
seedRankZero(community.founderPubkey);
|
||||
community.moderatorPubkeys.forEach(seedRankZero);
|
||||
|
||||
for (const pending of members) {
|
||||
if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue;
|
||||
|
||||
const nextMember: CommunityMember = pending.role === 'moderator'
|
||||
? { pubkey: pending.profile.pubkey, rank: 0 }
|
||||
: {
|
||||
pubkey: pending.profile.pubkey,
|
||||
rank: 1,
|
||||
awardEvent: awardEvents.get(pending.profile.pubkey),
|
||||
awardedBy: user?.pubkey,
|
||||
};
|
||||
|
||||
const current = membershipByPubkey.get(nextMember.pubkey);
|
||||
if (!current || nextMember.rank < current.rank) {
|
||||
membershipByPubkey.set(nextMember.pubkey, nextMember);
|
||||
}
|
||||
|
||||
const currentRank = rankMap.get(nextMember.pubkey);
|
||||
if (!currentRank || nextMember.rank < currentRank.rank) {
|
||||
rankMap.set(nextMember.pubkey, nextMember);
|
||||
}
|
||||
}
|
||||
|
||||
const membership: CommunityMembership = {
|
||||
members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank),
|
||||
};
|
||||
|
||||
return { membership, moderation, rankMap };
|
||||
});
|
||||
}, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]);
|
||||
|
||||
// ── Publish ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!user || pendingMembers.length === 0) return;
|
||||
if (isBadgeImageUploading) {
|
||||
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
|
||||
return;
|
||||
}
|
||||
if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) {
|
||||
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (needsBadgeCreation && !isFounder) {
|
||||
toast({ title: 'Member badge is missing', description: 'Only the founder can initialize community membership.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const newModerators = pendingMembers.filter((m) => m.role === 'moderator');
|
||||
const newMembers = pendingMembers.filter((m) => m.role === 'member');
|
||||
|
||||
let badgeATag = existingBadgeATag;
|
||||
|
||||
// Step 1: Create badge definition if needed
|
||||
if (newMembers.length > 0 && !hasBadge) {
|
||||
const badgeDTag = `${community.dTag}-member`;
|
||||
const existingBadge = await nostr.query([{
|
||||
kinds: [BADGE_DEFINITION_KIND],
|
||||
authors: [user.pubkey],
|
||||
'#d': [badgeDTag],
|
||||
limit: 1,
|
||||
}]);
|
||||
|
||||
if (existingBadge.length > 0) {
|
||||
toast({
|
||||
title: 'Member badge ID already in use',
|
||||
description: 'This community needs a member badge, but that badge identifier already exists on your account.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsPublishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeTags: string[][] = [
|
||||
['d', badgeDTag],
|
||||
['name', 'Member'],
|
||||
['description', `Member of ${community.name}`],
|
||||
];
|
||||
const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim());
|
||||
if (sanitizedBadgeImage) {
|
||||
badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']);
|
||||
}
|
||||
badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]);
|
||||
|
||||
const badgeEvent = await publishEvent({
|
||||
kind: BADGE_DEFINITION_KIND,
|
||||
content: '',
|
||||
tags: badgeTags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
|
||||
badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
|
||||
}
|
||||
|
||||
// Step 2: Republish community definition if needed
|
||||
// Needed when: adding moderators (new p tags) OR badge was just created (new a tag)
|
||||
const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge);
|
||||
|
||||
if (needsCommunityUpdate) {
|
||||
// Fetch fresh community event to avoid stale overwrites
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: [communityEvent.pubkey],
|
||||
'#d': [community.dTag],
|
||||
});
|
||||
|
||||
const baseTags = prev?.tags ?? communityEvent.tags;
|
||||
const updatedTags = [...baseTags];
|
||||
|
||||
// Add new moderator p tags
|
||||
for (const mod of newModerators) {
|
||||
// Don't add if already exists
|
||||
const exists = updatedTags.some(
|
||||
([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator',
|
||||
);
|
||||
if (!exists) {
|
||||
updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']);
|
||||
}
|
||||
}
|
||||
|
||||
// Add badge a tag if badge was just created
|
||||
if (badgeATag && !hasBadge) {
|
||||
updatedTags.push(['a', badgeATag, '', 'member']);
|
||||
}
|
||||
|
||||
const updatedEvent = await publishEvent({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
content: prev?.content ?? '',
|
||||
tags: updatedTags,
|
||||
prev: prev ?? undefined,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
|
||||
|
||||
queryClient.setQueryData(
|
||||
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
|
||||
updatedEvent,
|
||||
);
|
||||
queryClient.setQueryData(['event', updatedEvent.id], updatedEvent);
|
||||
}
|
||||
|
||||
// Step 3: Publish badge awards for each member
|
||||
const memberAwardEvents = new Map<string, NostrEvent>();
|
||||
if (newMembers.length > 0 && badgeATag) {
|
||||
for (const member of newMembers) {
|
||||
const awardEvent = await publishEvent({
|
||||
kind: BADGE_AWARD_KIND,
|
||||
content: '',
|
||||
tags: [
|
||||
['a', badgeATag],
|
||||
['p', member.profile.pubkey],
|
||||
['alt', `Badge award: Member in ${community.name}`],
|
||||
],
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
memberAwardEvents.set(member.profile.pubkey, awardEvent);
|
||||
}
|
||||
}
|
||||
|
||||
applyOptimisticMembership(pendingMembers, memberAwardEvents);
|
||||
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag], refetchType: 'inactive' });
|
||||
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
|
||||
if (!hasBadge && newMembers.length > 0) {
|
||||
queryClient.invalidateQueries({ queryKey: ['badge-feed'] });
|
||||
}
|
||||
|
||||
const addedCount = pendingMembers.length;
|
||||
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to add members',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
}, [
|
||||
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
|
||||
badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, isBadgeImageUploading,
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
|
||||
<PortalContainerProvider value={portalContainer}>
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="size-5 text-primary" />
|
||||
Add Members
|
||||
</DialogTitle>
|
||||
<DialogDescription>Add to community</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
{/* People search */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Search</Label>
|
||||
<PersonSearch
|
||||
onAdd={addPerson}
|
||||
onAddMany={addPeople}
|
||||
excludePubkeys={[
|
||||
community.founderPubkey,
|
||||
...existingMemberPubkeys,
|
||||
...pendingMembers.map((m) => m.profile.pubkey),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pending members list */}
|
||||
{pendingMembers.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
People to add
|
||||
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{pendingMembers.map((pm) => (
|
||||
<PendingMemberChip
|
||||
key={pm.profile.pubkey}
|
||||
pending={pm}
|
||||
onRemove={removePerson}
|
||||
onRoleChange={isFounder ? setRole : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPendingMembers && (
|
||||
<div className="space-y-2">
|
||||
<Label>Member badge</Label>
|
||||
{hasBadge ? (
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/30 p-3">
|
||||
{isBadgeError ? (
|
||||
<p className="text-sm text-destructive">Failed to load the current member badge.</p>
|
||||
) : isBadgeLoading ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-12 animate-pulse rounded-lg bg-muted" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-44 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : existingBadge ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<BadgeThumbnail badge={existingBadge} size={48} className="shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{existingBadge.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{existingBadge.description || 'Selected members will receive this badge.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">The configured member badge could not be found.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ImageUploadField
|
||||
id="member-badge-image"
|
||||
label={<>Create Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
|
||||
value={badgeImageUrl}
|
||||
onChange={setBadgeImageUrl}
|
||||
onUploadingChange={setIsBadgeImageUploading}
|
||||
uploadToastTitle="Badge image uploaded"
|
||||
previewAlt="Badge preview"
|
||||
objectFit="contain"
|
||||
dropAreaClassName="min-h-24"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="size-4 animate-spin" /> Adding...</>
|
||||
) : (
|
||||
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PortalContainerProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-Components ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Inline type-ahead person search. */
|
||||
function PersonSearch({
|
||||
onAdd,
|
||||
onAddMany,
|
||||
excludePubkeys,
|
||||
}: {
|
||||
onAdd: (profile: SearchProfile) => void;
|
||||
onAddMany: (profiles: SearchProfile[], sourceTitle?: string) => void;
|
||||
excludePubkeys: string[];
|
||||
}) {
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
const [query, setQuery] = useState('');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [isAddingPack, setIsAddingPack] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: profiles, isFetching } = useSearchProfiles(query);
|
||||
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query);
|
||||
|
||||
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
|
||||
const filteredProfiles = useMemo(
|
||||
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
|
||||
[profiles, excludeSet],
|
||||
);
|
||||
const filteredPeopleLists = useMemo(
|
||||
() => (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase()))),
|
||||
[peopleLists, excludeSet],
|
||||
);
|
||||
const hasResults = filteredProfiles.length > 0 || filteredPeopleLists.length > 0;
|
||||
const isSearching = isFetching || isFetchingPeopleLists || isAddingPack;
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim().length > 0 && hasResults) {
|
||||
setDropdownOpen(true);
|
||||
} else if (query.trim().length === 0) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}, [hasResults, query]);
|
||||
|
||||
const handleSelect = useCallback((profile: SearchProfile) => {
|
||||
onAdd(profile);
|
||||
setQuery('');
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.focus();
|
||||
}, [onAdd]);
|
||||
|
||||
const handleSelectPeopleList = useCallback(async (pack: PeopleListSearchResult) => {
|
||||
const eligiblePubkeys = Array.from(new Set(
|
||||
pack.pubkeys
|
||||
.map((pubkey) => pubkey.toLowerCase())
|
||||
.filter((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey)),
|
||||
));
|
||||
|
||||
if (eligiblePubkeys.length === 0) {
|
||||
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (eligiblePubkeys.length > 20 && !window.confirm(`Add ${eligiblePubkeys.length} people from ${pack.title}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingPack(true);
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [0], authors: eligiblePubkeys, limit: eligiblePubkeys.length }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
|
||||
const latestByPubkey = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const existing = latestByPubkey.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) latestByPubkey.set(event.pubkey, event);
|
||||
}
|
||||
|
||||
const profilesToAdd = eligiblePubkeys.map((pubkey) => {
|
||||
const event = latestByPubkey.get(pubkey);
|
||||
return event ? profileFromEvent(event) : makeFallbackProfile(pubkey);
|
||||
});
|
||||
|
||||
onAddMany(profilesToAdd, pack.title);
|
||||
setQuery('');
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.focus();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to load follow pack members',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsAddingPack(false);
|
||||
}
|
||||
}, [excludeSet, nostr, onAddMany, toast]);
|
||||
|
||||
return (
|
||||
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
|
||||
{isSearching && query.trim() && (
|
||||
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (query.trim().length > 0 && hasResults) {
|
||||
setDropdownOpen(true);
|
||||
}
|
||||
}}
|
||||
placeholder="Search people..."
|
||||
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
|
||||
>
|
||||
{hasResults ? (
|
||||
<div className="max-h-[200px] overflow-y-auto py-1">
|
||||
{filteredProfiles.map((profile) => (
|
||||
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
|
||||
))}
|
||||
{filteredPeopleLists.map((pack) => (
|
||||
<PeopleListSearchResultItem key={`${pack.event.kind}:${pack.event.pubkey}:${pack.event.tags.find(([name]) => name === 'd')?.[1] ?? pack.event.id}`} pack={pack} onClick={handleSelectPeopleList} />
|
||||
))}
|
||||
</div>
|
||||
) : query.trim().length >= 2 && !isSearching ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
No people or follow packs found
|
||||
</div>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** A follow pack / follow set search result row. */
|
||||
function PeopleListSearchResultItem({ pack, onClick }: { pack: PeopleListSearchResult; onClick: (pack: PeopleListSearchResult) => void }) {
|
||||
return (
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
onClick={() => onClick(pack)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center">
|
||||
<PartyPopper className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{pack.title}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
Follow pack · {pack.pubkeys.length} people
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A pending member chip with role toggle and remove button. */
|
||||
function PendingMemberChip({
|
||||
pending,
|
||||
onRemove,
|
||||
onRoleChange,
|
||||
}: {
|
||||
pending: PendingMember;
|
||||
onRemove: (pubkey: string) => void;
|
||||
onRoleChange?: (pubkey: string, role: MemberRole) => void;
|
||||
}) {
|
||||
const { profile, role } = pending;
|
||||
const { metadata, pubkey } = profile;
|
||||
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-secondary/30 border border-border/50">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 text-sm truncate">
|
||||
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
|
||||
</span>
|
||||
|
||||
{onRoleChange ? (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={role}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'member' || value === 'moderator') onRoleChange(pubkey, value);
|
||||
}}
|
||||
className="shrink-0 rounded-full bg-muted p-0.5"
|
||||
aria-label={`Role for ${displayName}`}
|
||||
>
|
||||
<ToggleGroupItem value="member" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground" aria-label="Member">
|
||||
<Users className="size-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Member</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="moderator" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-amber-500 data-[state=on]:text-white" aria-label="Moderator">
|
||||
<Crown className="size-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Moderator</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
) : (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Users className="size-3" />
|
||||
Member
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(pubkey)}
|
||||
className="shrink-0 size-6 rounded-full hover:bg-destructive/10 flex items-center justify-center transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A profile search result row. */
|
||||
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
|
||||
const { metadata, pubkey } = profile;
|
||||
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
onClick={() => onClick(profile)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Avatar className="size-8 shrink-0">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">
|
||||
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
|
||||
</span>
|
||||
{metadata.nip05 && (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export function AddMembersDialog({ open, onOpenChange, listId, listPubkeys }: Ad
|
||||
<div ref={listRef} className="overflow-y-auto flex-1">
|
||||
{!query.trim() ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
<UserPlus className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<UserPlus className="size-8 mx-auto mb-2" />
|
||||
Search for people to add to this list.
|
||||
</div>
|
||||
) : isLoading && !searchResults ? (
|
||||
|
||||
@@ -143,7 +143,7 @@ export function AddToListDialog({ pubkey, displayName, open, onOpenChange }: Add
|
||||
</div>
|
||||
) : !hasAny ? (
|
||||
<div className="py-8 px-4 text-center text-sm text-muted-foreground">
|
||||
<List className="size-8 mx-auto mb-2 text-muted-foreground/40" />
|
||||
<List className="size-8 mx-auto mb-2 text-muted-foreground" />
|
||||
No lists or packs yet. Create one below.
|
||||
</div>
|
||||
) : (
|
||||
|
||||