Compare commits
810 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00509f979a | |||
| a7550f3e49 | |||
| 65e9bd72a1 | |||
| a0c3e34e14 | |||
| f3b95157dc | |||
| bd8e0b5c5c | |||
| 4d827e01f4 | |||
| 4c32b93f5e | |||
| 45dae078ac | |||
| 25ef304e42 | |||
| 590e592cf0 | |||
| 7dc1afc5a1 | |||
| a42522dda2 | |||
| 9a5d3e56fe | |||
| fe43906cf1 | |||
| 0d1d782437 | |||
| 7f93dcb3af | |||
| 3b9eef908f | |||
| 948e6b70b6 | |||
| afe2bf1c28 | |||
| ae3daef072 | |||
| da6cab8784 | |||
| 2722ee1dcd | |||
| 475843cd27 | |||
| fd97b76fbb | |||
| 587d7eb5ba | |||
| 5c6b9b3baf | |||
| f6947aca9b | |||
| 4df6197a9a | |||
| 559a52f46f | |||
| 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 | |||
| 2fc7a9ac41 | |||
| e6ea96d69f | |||
| 740fc1c63c | |||
| 0b4a88a83e | |||
| 4138e12d5e | |||
| 2ede59d2db | |||
| 4cceaf652d | |||
| 474ec6cc99 | |||
| eed5c2fc5a | |||
| 20088b3d2b | |||
| 6ae0736576 | |||
| 6453aa71fc | |||
| 4d405996f9 | |||
| c798e2a53e | |||
| 0022b86299 | |||
| abc37151ad | |||
| 814d1909f6 | |||
| f5bb8afaec | |||
| ba0a144afd | |||
| d044218c6a | |||
| 06872186a8 | |||
| b8773c47d7 | |||
| 9184e6e09f | |||
| 5fa1dd1594 | |||
| 9cb8eea636 | |||
| dbed5bb7af | |||
| 11e33c36c0 | |||
| 9a1a530156 | |||
| d02527b751 | |||
| 9129cb8301 | |||
| 61e46e1479 | |||
| bb2846ea17 | |||
| c8d0c8fbd9 | |||
| c49cf68b78 | |||
| 10f8d3c2c2 | |||
| 53e7122302 | |||
| 5013d3d8c3 | |||
| 5cf1157636 | |||
| 1d5320eb33 | |||
| 1873823b4c | |||
| 8c8c7f3bad | |||
| c1f3cc172d | |||
| 0c1e36d20a | |||
| 16704b415d | |||
| ceb3b2df69 | |||
| 6af71ad5f4 | |||
| ea4295cb89 | |||
| 6b72d20af8 | |||
| 773e3830f5 | |||
| 5e99ac817b | |||
| 61308656ac | |||
| fd2a049d93 | |||
| 35d1c34ed8 | |||
| 136ca99f25 | |||
| 2d3b636bfa | |||
| 0bd6bd8baa | |||
| b6eebe497d | |||
| 7126ee1329 | |||
| c707a6ff97 | |||
| f968149a72 | |||
| a9ea21e3d4 | |||
| aca019ff69 | |||
| 0fadf3b23a | |||
| dfd4fa6be7 | |||
| 97d81f2295 | |||
| 91d50c2d83 | |||
| c85f65a99a | |||
| f525f9c393 | |||
| 2adc0a763b | |||
| d939934b7b | |||
| e12716722a | |||
| ac901ac096 | |||
| e54d7c8155 | |||
| d84f2b790f | |||
| a2dbc169b2 | |||
| 845c270f60 | |||
| ed6ac39015 | |||
| ba4b95972f | |||
| 440e00fb47 | |||
| 0a41cee6bf | |||
| aa96c0089c | |||
| da4116a1d1 | |||
| 54bf5efa1f | |||
| 9c590f4560 | |||
| 589a5f159e | |||
| ae11c91674 | |||
| 72989349e8 | |||
| 1283b56be9 | |||
| 5c2c35130f | |||
| da9f88d181 | |||
| 59929e9c4d | |||
| 55f8d946f9 | |||
| e3127e8555 | |||
| 478f53177e | |||
| 4b9fe24b25 | |||
| 9810f813a8 | |||
| 9090ecfa2b | |||
| 6c58b087ae | |||
| 3555cbcf99 | |||
| 9a08c6e488 | |||
| b1c49c06a3 | |||
| 9b81175e85 | |||
| 29fa317689 | |||
| 7be92b8eec | |||
| 3caad76477 | |||
| 65788705c3 | |||
| 4a4ed9bc2d | |||
| 70efa971eb | |||
| a6bfd2cb68 | |||
| df38cfdbca | |||
| 234d3a21a3 | |||
| 64a4643503 | |||
| c7e0234896 | |||
| c69aee40a2 | |||
| 908c5b248c | |||
| 546b1aff9b | |||
| 9aecefff40 | |||
| a9ff5c43f0 | |||
| d34a155922 | |||
| c2c5b5c3be | |||
| 5e729f74cd | |||
| 9e6ed02ce1 | |||
| 973acd7e9b | |||
| d2cf678491 | |||
| 9c74ddcaa9 | |||
| b63d2ba343 | |||
| 0497aa33c9 | |||
| 0d1fe7bbca | |||
| b895c6c696 | |||
| 259d33b71b | |||
| b5675802f4 | |||
| be1d6f1ece | |||
| 14e5a82b1e | |||
| 2a4cf19488 | |||
| 7031b09f45 | |||
| bc2131ed52 | |||
| af21eee389 | |||
| 20d7aa199d | |||
| 16222d0145 | |||
| 64ca61bb32 | |||
| 135666c956 | |||
| 46cdbe08eb | |||
| 62cc2611ea | |||
| 65b6c2afb6 | |||
| d80f8ad70c | |||
| 7ba94d72e4 | |||
| 1597e7540b | |||
| abe12fdefa | |||
| b02a3b604e | |||
| 305af8ad93 | |||
| c03705d6d6 | |||
| 189411ff77 | |||
| ebfa8fc6d2 | |||
| b639bd7a58 | |||
| 74a2522af1 | |||
| ea53a1b0dd | |||
| 5d99337cd2 | |||
| e2ec2892ab | |||
| af67e7f812 | |||
| 9a34fa0102 | |||
| 7eb70f3a61 | |||
| b2634d2fcb | |||
| 981e4f0726 | |||
| 476e99ab81 | |||
| 9483fbc99a | |||
| bf540fb5c1 | |||
| 0cae729335 | |||
| 3b48359aa7 | |||
| c5d5165f84 | |||
| 94f531cdd4 | |||
| 222f641123 | |||
| 30b10fd435 | |||
| 19cc0d13c9 | |||
| 8016ecb32d | |||
| 43b2ac91b6 | |||
| 0078ba90cb | |||
| dc168bc978 | |||
| 44c3888ac1 | |||
| 7918ee3662 | |||
| 98644047eb | |||
| 423d53ea58 | |||
| 460926fa99 | |||
| cf2f466772 | |||
| 71fe5aaa3a | |||
| 9813a226ec | |||
| 8eb31223a5 | |||
| 00412385c8 | |||
| 5a79c7cbe0 | |||
| fcfcb381a8 | |||
| 3f3d99e25a | |||
| c957041cf3 | |||
| 6684efd146 | |||
| b75d8dc16b | |||
| 4a4e6e4398 | |||
| 9054decb16 | |||
| 3f8d6a6c56 | |||
| 3708730c7d | |||
| 40c3e1d025 | |||
| 6242940985 | |||
| 88fd6a74d8 | |||
| fe800401ad | |||
| 2c0e32a039 | |||
| f9f9a8b0d2 | |||
| 480e0aa97f | |||
| e66ab53562 | |||
| da0bffdac2 | |||
| 7afbfb4307 | |||
| ee5d3415ac | |||
| e9def50a85 | |||
| e883309791 | |||
| bd68a32708 | |||
| 7675d010c2 | |||
| a2f088f86a | |||
| de9a7b0c39 | |||
| c25d772bca | |||
| 75f1b14551 | |||
| 9d914a430c | |||
| aa8541298e | |||
| 4fdbb4d960 | |||
| cb48434f96 | |||
| f4f8e49627 | |||
| 2602182bb7 | |||
| ca39448605 | |||
| 841d10c39c | |||
| f12e2a72da | |||
| dec3d04ca5 | |||
| ca581e37c2 | |||
| 8353f125ff | |||
| dd00cbff24 | |||
| c98b738290 | |||
| f2a8cd75b9 | |||
| 0ba6bacaf5 | |||
| 3f02fb83f9 | |||
| cd2afb8300 | |||
| 9120cff708 | |||
| 482dca78ec | |||
| 10fc3bf0a7 | |||
| d3462f42dc | |||
| 357d108c7e | |||
| 755f3b9fb0 | |||
| 7aaf9f1cad | |||
| 9be0c22b03 | |||
| a55233fdb1 | |||
| 50e9aee290 | |||
| 97aacd96aa | |||
| 30adbdc947 | |||
| 6b52926da1 | |||
| e14c727568 | |||
| 7d69f48bf6 | |||
| 1d9cd2cd3f | |||
| 8ab7be43dd | |||
| 0c6479f17e | |||
| 4eaec7fead | |||
| 4d4d8a43e0 | |||
| 63143db9db | |||
| 08be5e9985 | |||
| 8405d42902 | |||
| 03dcc37083 | |||
| 6b9aeddb06 | |||
| 23e845ebc1 | |||
| 5a80df05f5 | |||
| cc3a5b3415 | |||
| 9a48d039db | |||
| fdacb2029a | |||
| 1eb126bdf8 | |||
| ca260497cc | |||
| 846c4f794a | |||
| 3a9f41892f | |||
| b6b5a46f4f | |||
| 8b0eb97abb | |||
| 5463206d84 | |||
| ed637bc9df | |||
| f465cb7347 | |||
| 49a5461fbe | |||
| 75f6283d9b | |||
| a144193cb4 | |||
| 2ec57ad027 | |||
| ff412bbb29 | |||
| 12d299a7ec | |||
| 8fe0751a67 | |||
| 1b940b262c | |||
| 6ea1d0da2b | |||
| 4dd487e0b2 | |||
| 82f97aa1e2 | |||
| 1be0b3f101 | |||
| 1afafb7abd | |||
| afce15d2d4 | |||
| 360a8c88e3 | |||
| feca8bc357 | |||
| 5080970366 | |||
| be4a741a73 | |||
| 589fb8ebba | |||
| 0156a82629 | |||
| 8497d87238 | |||
| 787e0f6902 | |||
| 6ac7bdf826 | |||
| cbfd4a1f60 | |||
| 2a2ebd6a46 | |||
| ef7af83e5d | |||
| b5b7424472 | |||
| 3805bf39a5 | |||
| 008f3979e1 | |||
| 01980918bc | |||
| ca63c21080 | |||
| 0d637a55b1 | |||
| bddfe4b838 | |||
| 664a555fbd | |||
| 4d00ba9542 | |||
| ea99fdf288 | |||
| 56650efe74 | |||
| ef64668fac | |||
| ce4550cae5 | |||
| d951aab997 | |||
| 3dac492e31 | |||
| 907370e270 | |||
| 1eeaf4c10e | |||
| c5140bf118 | |||
| f0f54d76c5 | |||
| 819d0a88f1 | |||
| 08e61eea89 | |||
| 273469eda8 | |||
| 97a219aa8c | |||
| 5dafdf85f7 | |||
| 7830269ea1 | |||
| 118b0c11ab | |||
| 4ad0a9cfb4 | |||
| 3e5840b9a2 | |||
| c23af72da7 | |||
| bfee3dfdf1 | |||
| b29f7ec4d5 | |||
| a42e5f085e | |||
| cc655891d5 | |||
| 708c25d938 | |||
| a7cd13228b | |||
| 618655e921 | |||
| 436324fe8f | |||
| d0a11e266f | |||
| 5bf99176bb | |||
| 9c20102dad | |||
| 8b311bde81 | |||
| b4e42778fa | |||
| 986adeb901 | |||
| 1ce9beeaf5 | |||
| e704399c3d | |||
| d1ae988024 | |||
| 27736c7047 | |||
| 6f68153306 | |||
| 3260350377 | |||
| 03d174e5cc | |||
| 243ce98dd4 | |||
| f14316f024 | |||
| 399a3586b2 | |||
| 3bba781f49 | |||
| 91fe272bea | |||
| 0618a1ca13 | |||
| 3fe1256381 | |||
| 1bce67d21d | |||
| 00fa9cad57 | |||
| 9b9abaa855 | |||
| 06b53dbc82 | |||
| bf6788c141 | |||
| 363e39d72c | |||
| e2ce575b25 | |||
| 36373400f8 | |||
| e12d8eebdd | |||
| 91de4f80d8 | |||
| c2af41c7f2 | |||
| 6d9e750251 | |||
| 12c19ac4c2 | |||
| 7768588dbd | |||
| 0f759de671 | |||
| 53b0281dc8 | |||
| f85d345821 | |||
| ff44d9022c | |||
| cb9d183d7d | |||
| a3874a77f4 | |||
| 4264fb4aba | |||
| f2c479ea3a | |||
| 7fa856224e | |||
| 379c21c458 | |||
| 3b576685b7 | |||
| c95287e5a4 | |||
| 54a49f1ece | |||
| a2f2d9ff89 | |||
| cb26238729 | |||
| 800e0bbe47 | |||
| f4ae344b30 | |||
| 61d3c261fe | |||
| ae32b62552 | |||
| 781aa2579b | |||
| 6999da3e45 | |||
| 2c5528774f | |||
| 78db2568e0 | |||
| 64db8b2ce0 | |||
| 8f6361f6fc | |||
| 85894b98f5 | |||
| 3b052d3eb6 | |||
| ecb61d44a5 | |||
| 5789b34b5a | |||
| 5810c86e07 | |||
| 3312621f1d | |||
| ccf1e0f137 | |||
| 2ba987f532 | |||
| f677c131c0 | |||
| 650a45729e | |||
| ab2f574ff3 | |||
| bf59fd6dc2 | |||
| 6bca0922f1 | |||
| 482c99281c | |||
| 72a25d09aa | |||
| bb2bd15a71 | |||
| baf77c95aa | |||
| de8a39f78a | |||
| 4f6f6beff3 | |||
| d224035d28 | |||
| e914109b4b | |||
| 1e694a6cf8 | |||
| 8e6bd29be0 | |||
| ab1f95f2df | |||
| fe11513a6f | |||
| 52e42fcd6e | |||
| 3aa08ba93e | |||
| 9837c23a96 | |||
| 2b9dd6ed6a | |||
| 8ccc2c4a7a | |||
| b4b94698b4 | |||
| 7fa751492b | |||
| 496dfd48e0 | |||
| bae285dd8f | |||
| d628619eca | |||
| edc4163852 | |||
| 08cc77dbdc | |||
| 4764202a44 | |||
| 3d951cdaea | |||
| aadd2908e2 | |||
| 5cb731e557 | |||
| 5660a1cb1b | |||
| aa618edc43 | |||
| c49afc7add | |||
| 64bac10758 | |||
| e74cd1efbb | |||
| 773592f9dd | |||
| 995088842a | |||
| 4abc45a849 | |||
| 5ce2d3d8b4 | |||
| 4391743695 | |||
| a145f92bcb | |||
| 2c853ff02a | |||
| c8d46b3611 | |||
| a75fef039d | |||
| cf6fcc353c |
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: capacitor-compat
|
||||
description: Browser-API gotchas inside Capacitor's WKWebView (iOS) and Android WebView — which common web APIs silently fail, the downloadTextFile/openUrl helpers that bridge web and native, platform detection, and the installed Capacitor plugins. Load when writing code that interacts with file downloads, external URLs, or platform-specific behavior.
|
||||
---
|
||||
|
||||
# Capacitor Compatibility
|
||||
|
||||
Ditto runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
|
||||
|
||||
## What Doesn't Work in WKWebView (iOS)
|
||||
|
||||
- **`<a download>` file downloads** — programmatically creating an anchor with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
|
||||
- **`<a target="_blank">` new tabs** — programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
|
||||
- **`window.open()`** — may be blocked or behave unexpectedly without user-gesture context.
|
||||
|
||||
For a deeper list of Apple Lockdown Mode restrictions that also affect WKWebView, load the **`lockdown-mode`** skill.
|
||||
|
||||
## File Downloads and URL Opening
|
||||
|
||||
`src/lib/downloadFile.ts` provides two utilities that handle the web/native split automatically. **Always use these** instead of manually constructing anchors.
|
||||
|
||||
### `downloadTextFile(filename, content)`
|
||||
|
||||
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
|
||||
|
||||
```typescript
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
|
||||
await downloadTextFile('backup.txt', fileContents);
|
||||
```
|
||||
|
||||
### `openUrl(url)`
|
||||
|
||||
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
|
||||
|
||||
```typescript
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
await openUrl('https://example.com/image.jpg');
|
||||
```
|
||||
|
||||
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. The utilities above work correctly on all platforms; manual anchors silently fail on iOS.
|
||||
|
||||
## Detecting Native Platforms
|
||||
|
||||
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
|
||||
|
||||
```typescript
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// iOS or Android
|
||||
} else {
|
||||
// Web browser
|
||||
}
|
||||
```
|
||||
|
||||
Reserve platform forks for cases where behavior genuinely differs (share sheets, secure storage, haptics). Most UI code should stay platform-agnostic.
|
||||
|
||||
## Installed Capacitor Plugins
|
||||
|
||||
- `@capacitor/app` — app lifecycle events (deep links, back button)
|
||||
- `@capacitor/core` — core runtime and platform detection
|
||||
- `@capacitor/filesystem` — read/write files on the native filesystem
|
||||
- `@capacitor/haptics` — native haptics
|
||||
- `@capacitor/keyboard` — keyboard control (hide accessory bar, etc.)
|
||||
- `@capacitor/local-notifications` — schedule local push notifications
|
||||
- `@capacitor/share` — native share sheet
|
||||
- `@capacitor/status-bar` — control the native status-bar style
|
||||
- `@capgo/capacitor-autofill-save-password` — iOS keychain autofill for nsec
|
||||
- `capacitor-secure-storage-plugin` — OS-level secure storage (iOS Keychain / Android KeyStore)
|
||||
|
||||
After adding or removing plugins, run `npm run cap:sync` to update the native projects.
|
||||
@@ -0,0 +1,350 @@
|
||||
---
|
||||
name: ci-cd-publishing
|
||||
description: Ditto's release and publishing pipeline — cutting a version tag, Zapstore APK publishing with NIP-46 bunker auth, nsite web deploys via nsyte, and Google Play AAB uploads via fastlane supply. Includes GitLab CI variable setup and credential rotation.
|
||||
---
|
||||
|
||||
# CI/CD Pipeline and Publishing
|
||||
|
||||
Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the web app to nsite on every default-branch push, and build + publish Android binaries to Zapstore and Google Play on every tag. Load this skill when setting up CI credentials, rotating a signing key, diagnosing a failed publish, or adding a new publishing target.
|
||||
|
||||
## Pipeline Overview
|
||||
|
||||
| Stage | Runs on | Job |
|
||||
|-----------|---------------------------|-----------------------------------------|
|
||||
| `test` | every commit (not tags) | `npm run test` |
|
||||
| `deploy` | default branch only | `deploy-nsite` (Vite build → nsyte) |
|
||||
| `build` | tags only | `build-apk` (signed APK + AAB) + `build-ipa` (signed IPA on the Mac runner) |
|
||||
| `release` | tags only | GitLab Release with APK / AAB / IPA links |
|
||||
| `publish` | tags only | `publish-zapstore` + `publish-google-play` + `publish-app-store` |
|
||||
|
||||
## Creating a Release
|
||||
|
||||
Releases are triggered by pushing a version tag:
|
||||
|
||||
```bash
|
||||
npm run release
|
||||
```
|
||||
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` jobs.
|
||||
|
||||
For the full versioning / changelog / native-build workflow, load the **`release`** skill.
|
||||
|
||||
## Zapstore Publishing
|
||||
|
||||
The `publish-zapstore` CI job uploads signed APKs to [Zapstore](https://zapstore.dev/) using the [`zsp`](https://github.com/zapstore/zsp) CLI and NIP-46 bunker signing via Amber.
|
||||
|
||||
**Configuration files:**
|
||||
|
||||
- `zapstore.yaml` — app metadata for Zapstore (name, tags, icon, supported NIPs)
|
||||
- `.gitlab-ci.yml` — the `publish-zapstore` job definition
|
||||
|
||||
**GitLab CI/CD variables** (Settings → CI/CD → Variables):
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `ZAPSTORE_BUNKER_URL` | NIP-46 bunker URL (`bunker://<pubkey>?relay=...`). No `secret` param needed after initial auth. | Yes | No | Yes |
|
||||
| `ZAPSTORE_CLIENT_KEY` | Hex private key used as the NIP-46 client identity for bunker communication | Yes | Yes | Yes |
|
||||
| `ANDROID_KEYSTORE_BASE64` | Base64-encoded Android signing keystore | Yes | Yes | Yes |
|
||||
| `KEYSTORE_PASSWORD` | Android keystore password | Yes | Yes | Yes |
|
||||
| `KEY_PASSWORD` | Android key password | Yes | Yes | Yes |
|
||||
|
||||
### How NIP-46 bunker auth works in CI
|
||||
|
||||
NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and a **client key** (the CI runner's identity). The bunker authorizes specific client pubkeys — once authorized, the client can request signatures without re-approval.
|
||||
|
||||
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
Run the NIP-46 client-initiated auth script:
|
||||
|
||||
```bash
|
||||
node scripts/nip46-auth.mjs
|
||||
```
|
||||
|
||||
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script outputs the `bunker://` URI and client key hex, and writes the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
|
||||
|
||||
Options:
|
||||
- `--relay <url>` — relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
|
||||
- `--name <name>` — app name shown to the signer (default: `Ditto`)
|
||||
- `--timeout <sec>` — how long to wait for approval (default: 300)
|
||||
|
||||
After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs. If the client key is rotated, run the script again and update the GitLab variables.
|
||||
|
||||
## nsite Publishing
|
||||
|
||||
The `deploy-nsite` CI job deploys the Vite build to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The job uploads `dist/` to Blossom servers and publishes site manifest events to Nostr relays.
|
||||
|
||||
nsyte uses a NIP-46 bunker credential called **nbunksec** — a bech32-encoded string bundling the bunker pubkey, client secret key, and relay info into a single self-contained token. It's passed to nsyte via `--sec`.
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. Install nsyte locally:
|
||||
```bash
|
||||
curl -fsSL https://nsyte.run/get/install.sh | bash
|
||||
```
|
||||
2. Generate the CI credential:
|
||||
```bash
|
||||
nsyte ci
|
||||
```
|
||||
This guides you through connecting a NIP-46 bunker (e.g. Amber) and outputs an `nbunksec1...` string. The credential is shown only once.
|
||||
3. Add the `nbunksec1...` value as `NSITE_NBUNKSEC` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**.
|
||||
|
||||
### Configured relays and servers
|
||||
|
||||
Relays the deploy job publishes to:
|
||||
|
||||
- `wss://relay.ditto.pub`
|
||||
- `wss://relay.nsite.lol`
|
||||
- `wss://relay.dreamith.to`
|
||||
- `wss://relay.primal.net`
|
||||
|
||||
Blossom servers:
|
||||
|
||||
- `https://blossom.primal.net`
|
||||
- `https://blossom.ditto.pub`
|
||||
- `https://blossom.dreamith.to`
|
||||
|
||||
The `--use-fallback-relays` and `--use-fallback-servers` flags include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
|
||||
|
||||
### Credential rotation
|
||||
|
||||
To rotate the nsite credential:
|
||||
|
||||
1. Revoke the old bunker connection in your signer app.
|
||||
2. Run `nsyte ci` again to generate a new `nbunksec1...` string.
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings.
|
||||
|
||||
## Google Play Publishing
|
||||
|
||||
The `publish-google-play` CI job uploads Android AABs to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). It runs after a successful AAB build and uploads directly to the production track.
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON. The CI job decodes with `base64 -d` before passing to `fastlane supply`. | Yes | Yes | No |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. Create or reuse a project in [Google Cloud Console](https://console.cloud.google.com/projectcreate).
|
||||
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project.
|
||||
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it.
|
||||
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`.
|
||||
5. **Base64-encode** the key file:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
base64 -w0 service-account.json
|
||||
|
||||
# macOS
|
||||
base64 -i service-account.json | tr -d '\n'
|
||||
```
|
||||
|
||||
6. Add the base64-encoded value as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**. **Do not paste the raw JSON** — the CI script expects base64 and will fail to decode a raw value.
|
||||
|
||||
### Key points
|
||||
|
||||
- The job uploads the signed **AAB** (not APK) — Google Play requires App Bundles.
|
||||
- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users.
|
||||
- Metadata, screenshots, and store-listing description are managed in the Play Console (the job uses `--skip_upload_metadata`, `--skip_upload_images`, `--skip_upload_screenshots`).
|
||||
- **Changelogs ("What's new in this version")** are uploaded from `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`, generated at CI time from the release summary paragraph in `CHANGELOG.md`. See "Release notes pipeline" below.
|
||||
- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`).
|
||||
|
||||
## App Store Publishing
|
||||
|
||||
Ditto's iOS pipeline is split across two jobs:
|
||||
|
||||
- **`build-ipa`** (stage `build`, `tags: [macos]`) runs on the self-hosted Mac runner. Decodes the App Store Connect API key, fetches the encrypted distribution cert + provisioning profile via fastlane match, builds the web assets, runs `cap sync ios`, stamps the marketing version into `project.pbxproj`, then `fastlane build_ipa` produces a signed App Store IPA at `artifacts/Ditto.ipa`. The IPA is uploaded to the GitLab Generic Packages registry as `Ditto-${CI_COMMIT_TAG}.ipa` (mirrors how `build-apk` publishes the APK and AAB) and exposed as a CI artifact for downstream jobs.
|
||||
- **`publish-app-store`** (stage `publish`, `tags: [macos]`) also runs on the self-hosted Mac runner. Consumes the IPA artifact via `needs: [build-ipa]` and the release-notes artifact via `needs: [release-notes]`. Decodes the API key, copies the release-notes summary into `ios/fastlane/metadata/en-US/release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + push the prebuilt IPA + auto-submit for App Store review. **macOS is required** even though the IPA is already signed: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode. A Linux container ran into `No such file or directory @ dir_chdir0` from `JavaTransporterExecutor#execute` because `Helper.itms_path` resolved to a missing Xcode path.
|
||||
|
||||
The Mac runner is therefore used for both iOS jobs. For runner administration (operating the Mac, restarting the agent, viewing logs, rotating signing certs), load the **`mac-runner`** skill.
|
||||
|
||||
**Configuration files:**
|
||||
|
||||
- `ios/fastlane/Fastfile` — exposes four lanes:
|
||||
- `build_ipa` — setup_ci → match (readonly, with API key) → increment_build_number → build_app. Used by CI's `build-ipa`.
|
||||
- `submit_release` — reads `IPA_PATH` env var, calls deliver against the prebuilt IPA. Used by CI's `publish-app-store`.
|
||||
- `release` — combines build_ipa + submit_release; convenience for local one-shot runs.
|
||||
- `submit_only` — debug lane that skips build/upload and only runs deliver against an already-uploaded build (set `BUILD_NUMBER` + `VERSION` env vars). See the `mac-runner` skill.
|
||||
- `ios/fastlane/Appfile` — bundle identifier and team ID
|
||||
- `ios/fastlane/Matchfile` — points at the shared `soapbox-pub/certificates` repo
|
||||
- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it with the release summary paragraph from `CHANGELOG.md` per release
|
||||
- `.gitlab-ci.yml` — `build-ipa` and `publish-app-store` both run on the Mac runner (`tags: [macos]`)
|
||||
|
||||
**Code signing storage**: a private GitLab repo `soapbox-pub/certificates` holds encrypted distribution certs and provisioning profiles, managed by [fastlane match](https://docs.fastlane.tools/actions/match/). Match handles cert/profile lifecycle: one passphrase decrypts everything; the same repo can hold signing material for multiple Soapbox iOS apps under team `GZLTTH5DLM`.
|
||||
|
||||
**App Store Connect auth**: a long-lived [App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) (`.p8` file + key ID + issuer ID) authenticates `match`, `deliver`, and `pilot`. Avoids 2FA prompts that would interrupt CI.
|
||||
|
||||
**Distribution**: `submit_for_review: true` automatically pushes the build into Apple's review queue once uploaded. `automatic_release: false` keeps a human-controlled final gate — once Apple approves, you click "Release" in the App Store Connect web UI to publish to users. To remove the manual gate, flip `automatic_release` to `true` in `ios/fastlane/Fastfile`.
|
||||
|
||||
**Release notes**: copied from the `release-notes` job's artifact `artifacts/release-notes-summary.txt` (the leading plaintext paragraph of the version's `CHANGELOG.md` section) into `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text. See "Release notes pipeline" below.
|
||||
|
||||
**IPA distribution beyond the App Store**: `build-ipa` uploads the signed IPA to the GitLab Generic Packages registry, and the `release` job links it from the GitLab Release page. The IPA is signed with the App Store distribution profile, so it isn't directly sideloadable — installation goes through Apple's review process — but having it as a stable artifact lays the groundwork for AltStore or ad-hoc distribution later (which would require a separate provisioning profile).
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `MATCH_PASSWORD` | Symmetric passphrase used by match to encrypt/decrypt certs and profiles. The single most important secret — losing it makes the cert repo unreadable. | Yes | Yes | Yes |
|
||||
| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64 of `username:deploy-token` for HTTPS clone of the certificates repo. Generated from a `read_repository`-scoped deploy token on `soapbox-pub/certificates`. | Yes | Yes | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect API key ID (10 chars). | Yes | No | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | App Store Connect issuer ID (UUID). | Yes | No | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | Base64-encoded contents of the `.p8` private key file. CI decodes with `base64 -d` into `~/.private_keys/AuthKey_<KEY_ID>.p8` and removes it in `after_script`. | Yes | Yes | Yes |
|
||||
| `FASTLANE_KEYCHAIN_PASSWORD` | Password for the ephemeral keychain `setup_ci` creates per build. Random per setup; keep stable across runs. | Yes | Yes | Yes |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. **Provision the Mac runner.** See the **`mac-runner`** skill for hardware/launchd setup, Xcode, Homebrew, fastlane, and `gitlab-runner` registration.
|
||||
|
||||
2. **Create the App Store Connect API key.** Log in to [App Store Connect](https://appstoreconnect.apple.com) → Users and Access → Integrations → App Store Connect API → Generate. Use the **App Manager** role (sufficient for `deliver`'s upload + submit-for-review). Download the `.p8` file (one-time download — Apple won't show it again). Note the **Key ID** (10-char string next to the key) and the **Issuer ID** (UUID at the top of the API page).
|
||||
|
||||
Set the three GitLab CI variables:
|
||||
```bash
|
||||
# Replace <ISSUER_ID>, <KEY_ID>, and the path to your .p8
|
||||
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/projects/$PROJECT_ID/variables" \
|
||||
--data-urlencode "key=APP_STORE_CONNECT_API_KEY_ISSUER_ID" \
|
||||
--data-urlencode "value=<ISSUER_ID>" \
|
||||
--data-urlencode "protected=true" --data-urlencode "raw=true"
|
||||
# repeat for APP_STORE_CONNECT_API_KEY_ID
|
||||
# for the .p8, base64 first:
|
||||
base64 -i AuthKey_<KEY_ID>.p8 | tr -d '\n' # paste this as APP_STORE_CONNECT_API_KEY_P8_BASE64 (masked)
|
||||
```
|
||||
|
||||
3. **Create the certificates repo.** A private GitLab repo at `soapbox-pub/certificates` holds match-encrypted certs/profiles. Create a project deploy token on it (Settings → Repository → Deploy tokens) with `read_repository` scope. Encode `username:token` as base64 → set as `MATCH_GIT_BASIC_AUTHORIZATION` (protected, masked, raw).
|
||||
|
||||
4. **Generate `MATCH_PASSWORD` and `FASTLANE_KEYCHAIN_PASSWORD`.** Both are arbitrary strong random strings — `openssl rand -base64 32 | tr -d '=+/' | head -c 32` works. Store them as protected, masked GitLab variables.
|
||||
|
||||
5. **Bootstrap match certs via a one-shot CI job** (preferred over running match locally — avoids the macOS keychain UI permission dialogs that fastlane bug [#15185](https://github.com/fastlane/fastlane/issues/15185) trips on newer macOS):
|
||||
|
||||
a. Create a temporary write-scoped GitLab variable. The deploy token is `read_repository`; for the initial cert creation match needs to push. Encode `username:write-pat` as base64 and set it as `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` (Protected, Masked, Raw).
|
||||
|
||||
b. Add a temporary `setup-match` job to `.gitlab-ci.yml` that runs on the macos runner with `setup_ci` (which creates an ephemeral keychain — bypasses the GUI permission issue):
|
||||
|
||||
```yaml
|
||||
setup-match:
|
||||
stage: publish
|
||||
tags: [macos]
|
||||
rules:
|
||||
- if: $SETUP_MATCH == "1"
|
||||
when: manual
|
||||
script:
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- mkdir -p "$HOME/.private_keys" && chmod 700 "$HOME/.private_keys"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
- cd ios
|
||||
- export MATCH_GIT_BASIC_AUTHORIZATION="$MATCH_GIT_BASIC_AUTHORIZATION_WRITE"
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
- |
|
||||
cat > Fastfile.setup <<'RUBY'
|
||||
default_platform(:ios)
|
||||
platform :ios do
|
||||
lane :setup do
|
||||
setup_ci
|
||||
api_key = {
|
||||
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
|
||||
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
|
||||
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
|
||||
duration: 1200,
|
||||
in_house: false,
|
||||
}
|
||||
match(type: "appstore", readonly: false, api_key: api_key, force_for_new_devices: true)
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
- mv fastlane/Fastfile fastlane/Fastfile.bak
|
||||
- mv Fastfile.setup fastlane/Fastfile
|
||||
- fastlane setup
|
||||
- mv fastlane/Fastfile.bak fastlane/Fastfile
|
||||
after_script:
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
```
|
||||
|
||||
c. Trigger the pipeline manually with `SETUP_MATCH=1`:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline" \
|
||||
--data-urlencode "ref=main" \
|
||||
--data-urlencode "variables[][key]=SETUP_MATCH" \
|
||||
--data-urlencode "variables[][value]=1"
|
||||
# Then play the manual setup-match job
|
||||
```
|
||||
|
||||
d. Once the job succeeds (cert + profile pushed to the certificates repo), **delete the `setup-match` job from `.gitlab-ci.yml` and the `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` variable**. They're only needed for bootstrap.
|
||||
|
||||
### Yearly cert renewal
|
||||
|
||||
Apple distribution certs expire annually. Renewal is one command per year, run on any Mac:
|
||||
|
||||
```bash
|
||||
cd ~/Projects/ditto/ios
|
||||
fastlane match nuke distribution # revokes old cert in Apple's portal, removes from match repo
|
||||
fastlane match appstore # creates new cert + profile, encrypts, commits, pushes
|
||||
```
|
||||
|
||||
CI's next tag run picks up the new files automatically (`match(... readonly: true)`).
|
||||
|
||||
### Disaster recovery (Mac dies / new developer joins)
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.com/soapbox-pub/ditto.git
|
||||
cd ditto/ios
|
||||
fastlane match appstore --readonly # decrypts existing certs/profiles using MATCH_PASSWORD
|
||||
```
|
||||
|
||||
No re-issuance of certs needed — the cert repo is the source of truth.
|
||||
|
||||
### App Store Connect API key rotation
|
||||
|
||||
App Store Connect API keys can be revoked anytime. To rotate:
|
||||
|
||||
1. App Store Connect → Users and Access → Integrations → App Store Connect API → Generate new key
|
||||
2. Download the new `.p8`, note the new key ID
|
||||
3. Update `APP_STORE_CONNECT_API_KEY_ID` and `APP_STORE_CONNECT_API_KEY_P8_BASE64` in GitLab variables
|
||||
4. (Issuer ID stays the same — it's per-team, not per-key)
|
||||
5. Revoke the old key in App Store Connect
|
||||
|
||||
### Key points
|
||||
|
||||
- `build-ipa` (Mac) produces a signed **IPA** (App Store distribution format) and uploads it to GitLab's Generic Packages registry. `publish-app-store` (also Mac) submits it to Apple via `deliver`.
|
||||
- Builds go to **App Store Connect**, automatically submit for review, but do **not** auto-release after approval. The final "Release" click is manual in the web UI.
|
||||
- Marketing version comes from the git tag (`v2.1.0` → `MARKETING_VERSION = 2.1.0`); build number comes from `CI_PIPELINE_IID`.
|
||||
- Release notes ("What's New in This Version") come from the release-notes summary paragraph (see "Release notes pipeline" below).
|
||||
- `setup_ci` (in `build-ipa`) creates an ephemeral keychain per build, so the runner never touches the login keychain — works whether or not a GUI session is logged in.
|
||||
- `publish-app-store` does no code signing, but it still needs macOS: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode.
|
||||
|
||||
## Release notes pipeline
|
||||
|
||||
Release notes for all three storefronts (App Store, Google Play, GitLab Release page) and the in-app version-update toast are derived from a single source: `CHANGELOG.md`.
|
||||
|
||||
**The `release-notes` job** (stage `build`, default `node:22` image, runs only on `v*` tags) calls `scripts/extract-release-notes.mjs` twice and publishes two artifacts:
|
||||
|
||||
- `artifacts/release-notes.md` — the full section for this version (summary paragraph + `### Added` / `### Changed` / etc. lists). Used as the GitLab Release description.
|
||||
- `artifacts/release-notes-summary.txt` — only the leading plaintext paragraph (max 500 chars by convention). Used as the App Store / Play Store "What's new" text. Falls back to `Ditto vX.Y.Z` if the section has no summary paragraph.
|
||||
|
||||
**Downstream consumers** all pull from the `release-notes` job via `needs:`:
|
||||
|
||||
| Consumer | Job | Artifact used |
|
||||
|---|---|---|
|
||||
| GitLab Release description | `release` | `release-notes.md` |
|
||||
| App Store "What's New" | `publish-app-store` | `release-notes-summary.txt` → copied to `ios/fastlane/metadata/en-US/release_notes.txt` → uploaded by `deliver` |
|
||||
| Play Store "What's new" | `publish-google-play` | `release-notes-summary.txt` → copied to `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` → uploaded by `supply` |
|
||||
| In-app toast | `src/components/VersionCheck.tsx` (runtime) | Re-parses `public/CHANGELOG.md` via `parseChangelog()` and reads `entry.summary` (with a fallback to the legacy first-bullet behavior) |
|
||||
|
||||
**The summary format** is documented in the `release` skill — a single plaintext paragraph immediately under the `## [X.Y.Z] - YYYY-MM-DD` heading, before any `### Category`. The script enforces nothing on the parser side; CI emits a warning when the summary exceeds 500 chars but does not fail the build.
|
||||
|
||||
**To preview locally** what each storefront will receive:
|
||||
|
||||
```bash
|
||||
node scripts/extract-release-notes.mjs vX.Y.Z # full GitLab Release body
|
||||
node scripts/extract-release-notes.mjs vX.Y.Z --summary # storefront blurb
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: file-uploads
|
||||
description: Upload files (images, media, attachments) from the browser to a Blossom server via the useUploadFile hook, and attach them to Nostr events with NIP-94 imeta tags.
|
||||
---
|
||||
|
||||
# File Uploads on Nostr
|
||||
|
||||
This project includes a `useUploadFile` hook that uploads files to Blossom servers and returns NIP-94-compatible tags. Use it whenever a feature needs to accept a user-provided file (avatars, banners, post attachments, etc.).
|
||||
|
||||
## The `useUploadFile` Hook
|
||||
|
||||
```tsx
|
||||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||||
|
||||
function MyComponent() {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
// Returns an array of NIP-94-compatible tags.
|
||||
// The first tag is the `url` tag; its second element is the file URL.
|
||||
const tags = await uploadFile(file);
|
||||
const url = tags[0][1];
|
||||
// ...use the url
|
||||
} catch (error) {
|
||||
// ...handle errors (show a toast, etc.)
|
||||
}
|
||||
};
|
||||
|
||||
// ...rest of component
|
||||
}
|
||||
```
|
||||
|
||||
The hook is a TanStack Query mutation, so `isPending` can drive loading UI and `mutateAsync` integrates cleanly with `async`/`await` flows.
|
||||
|
||||
## Attaching Files to Events
|
||||
|
||||
### Kind 0 (profile metadata)
|
||||
|
||||
Use the plain URL in the relevant JSON field:
|
||||
|
||||
```ts
|
||||
const tags = await uploadFile(file);
|
||||
const url = tags[0][1];
|
||||
|
||||
createEvent({
|
||||
kind: 0,
|
||||
content: JSON.stringify({ ...existingMetadata, picture: url }),
|
||||
});
|
||||
```
|
||||
|
||||
### Kind 1 (text notes) and other content events
|
||||
|
||||
Append the URL to `content`, and add one `imeta` tag per file. `imeta` carries the NIP-94 metadata (mime type, dimensions, blurhash, etc.) that the uploader returned:
|
||||
|
||||
```ts
|
||||
const tags = await uploadFile(file); // e.g. [["url", "https://..."], ["m", "image/png"], ["dim", "1024x768"], ...]
|
||||
const url = tags[0][1];
|
||||
|
||||
// Flatten the NIP-94 tags into a single imeta tag value.
|
||||
const imeta = tags.map(([name, value]) => `${name} ${value}`);
|
||||
|
||||
createEvent({
|
||||
kind: 1,
|
||||
content: `Check this out ${url}`,
|
||||
tags: [["imeta", ...imeta]],
|
||||
});
|
||||
```
|
||||
|
||||
Repeat the pattern (one `imeta` tag per file) for multiple attachments.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- **Avatar / banner pickers:** wrap an `<input type="file" accept="image/*">` and call `uploadFile` on change; on success, update the relevant profile field and publish a kind 0 event.
|
||||
- **Post composers:** call `uploadFile` for each selected file before publishing the note, then build `imeta` tags alongside `content`.
|
||||
- **Progress UI:** use `isPending` from the mutation to disable the submit button and show a spinner or skeleton.
|
||||
- **Error handling:** wrap `uploadFile` in `try/catch` and surface failures via `useToast` — network and Blossom-server errors are common and should never break the UI.
|
||||
|
||||
## Constraints
|
||||
|
||||
- The hook requires a logged-in user (Blossom auth is signed by the user's signer). Guard uploads behind `useCurrentUser`.
|
||||
- Don't store or display raw `File` objects after upload — always use the returned URL.
|
||||
- Large files may take time; prefer `mutateAsync` over `mutate` so the caller can `await` completion before publishing an event that references the URL.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: git-workflow
|
||||
description: Ditto's git conventions — validating changes before committing, writing commit messages that match project style, and attributing regressions with a Regression-of trailer so the release changelog skill can filter them from the "Fixed" section.
|
||||
---
|
||||
|
||||
# Git Workflow
|
||||
|
||||
Ditto expects every completed task to end with a git commit. This skill covers the pre-commit validation loop, commit-message conventions, and the `Regression-of:` trailer used by the release skill to filter intra-release regressions from the changelog.
|
||||
|
||||
## Pre-commit Validation
|
||||
|
||||
**Your task is not finished until the code type-checks and builds without errors.** In priority order:
|
||||
|
||||
1. **Type Checking** (required) — `tsc --noEmit`
|
||||
2. **Building/Compilation** (required) — `vite build`
|
||||
3. **Linting** (recommended; fix anything critical) — `eslint`
|
||||
4. **Tests** (if available) — `vitest run`
|
||||
5. **Git commit** (required)
|
||||
|
||||
The full `npm run test` script runs all of these in sequence; running it is equivalent to steps 1–4.
|
||||
|
||||
## Using Git
|
||||
|
||||
Use `git status` and `git diff` to review changes, and `git log` to learn the project's commit-message conventions before writing a new one. If you make a mistake, `git checkout` restores files.
|
||||
|
||||
When your changes are complete and validated, create a commit with a message that focuses on **why** the change was made (not just **what**). Summaries should fit on one line; a body is warranted for non-trivial changes.
|
||||
|
||||
**Always commit when you are finished making changes. Non-negotiable — every completed task ends with a commit. Don't leave uncommitted changes.**
|
||||
|
||||
## Contributing Guide
|
||||
|
||||
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
|
||||
|
||||
## Attributing Regressions
|
||||
|
||||
When a commit fixes a bug that was introduced by an identifiable prior commit, add a `Regression-of:` trailer at the bottom of the commit message body referencing the offending commit's short SHA:
|
||||
|
||||
```
|
||||
Fix missing background on expanded emoji picker in feeds
|
||||
|
||||
The compose box overhaul accidentally dropped the bg-background class
|
||||
when refactoring the picker out of QuickReactMenu.
|
||||
|
||||
Regression-of: 3aa08ba9
|
||||
```
|
||||
|
||||
This is a standard Git trailer (compatible with `git interpret-trailers`) that records the cause-and-effect link directly in history. It is consumed by the `release` skill to detect intra-release regressions and exclude them from the changelog's "Fixed" section, and it makes future debugging and post-mortems substantially faster.
|
||||
|
||||
### When to add it
|
||||
|
||||
- The commit fixes a bug (not a new feature, refactor, or doc change).
|
||||
- The introducing commit is identifiable with reasonable effort.
|
||||
|
||||
### When to skip it
|
||||
|
||||
- The bug is pre-existing with no clear single origin.
|
||||
- The behavior was always wrong (no regression).
|
||||
- The introducing commit cannot be determined after a brief search.
|
||||
|
||||
### Finding the introducing commit
|
||||
|
||||
- `git log -S '<removed-or-changed-string>'` — find commits that touched a specific string.
|
||||
- `git log --oneline -- path/to/file` — list all commits touching a file.
|
||||
- `git blame -L <start>,<end> -- path/to/file` — find who last changed specific lines.
|
||||
|
||||
This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely.
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: mac-runner
|
||||
description: Operate the self-hosted GitLab Runner on the Mac that builds Ditto's iOS IPA. Covers SSH access, restarting the runner, viewing logs, updating Xcode, debugging fastlane locally, and rotating match certificates.
|
||||
---
|
||||
|
||||
# Mac Runner Operations
|
||||
|
||||
Ditto's iOS pipeline runs two CI jobs on a self-hosted GitLab Runner on a MacBook in the rack: `build-ipa` (signs and builds the IPA via Xcode + fastlane match) and `publish-app-store` (uploads the IPA via `fastlane deliver`, which shells out to Apple's iTMSTransporter — that tool only ships inside Xcode, so this job can't run on Linux). This skill covers operating the Mac.
|
||||
|
||||
This skill covers operating the runner: SSH access, restarting after crashes or Xcode updates, watching logs, debugging fastlane locally, and rotating the match certificates. For initial provisioning, App Store Connect API key creation, and GitLab CI variable setup, load the **`ci-cd-publishing`** skill.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Command |
|
||||
|---|---|
|
||||
| SSH in | `ssh alex@alexs-air.lan` |
|
||||
| Runner status | `gitlab-runner status` |
|
||||
| Restart runner | `gitlab-runner restart` (after `eval "$(/opt/homebrew/bin/brew shellenv)"`) |
|
||||
| Stdout log | `tail -f ~/gitlab-runner.out.log` |
|
||||
| Stderr log | `tail -f ~/gitlab-runner.err.log` |
|
||||
| Runner config | `~/.gitlab-runner/config.toml` |
|
||||
| LaunchAgent plist | `~/Library/LaunchAgents/gitlab-runner.plist` |
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Host**: `alexs-air.lan` (Apple Silicon MacBook, macOS 26+, Xcode 26+)
|
||||
- **User**: `alex` (the runner runs in user-mode so it can access keychain and Xcode UI tooling)
|
||||
- **Tooling**: Homebrew (`/opt/homebrew`), `gitlab-runner`, `node@22`, `ruby@3.3`, fastlane installed as a user gem under `~/.gem/ruby/3.3.0/`
|
||||
- **Service**: launchd LaunchAgent at `~/Library/LaunchAgents/gitlab-runner.plist`. `KeepAlive=true` (auto-restart on crash) and `RunAtLoad=true` (starts on login). The agent loads when `alex` logs in via auto-login at boot.
|
||||
- **Tags**: `macos`, `ios`, `xcode` — both `build-ipa` and `publish-app-store` in `.gitlab-ci.yml` target this runner. `publish-app-store` doesn't sign anything, but it still needs Xcode's bundled iTMSTransporter to push the IPA to App Store Connect.
|
||||
- **Shell setup**: `~/.bash_profile` sources brew shellenv and prepends `~/.gem/ruby/3.3.0/bin` and `/opt/homebrew/opt/ruby@3.3/bin` to `PATH` so `bash --login` (the runner's executor) finds fastlane + ruby 3.3.
|
||||
|
||||
### Why Ruby 3.3, not the brewed 4.0
|
||||
|
||||
Brewed `fastlane` (current version) ships running on Ruby 4.0 from `brew install ruby`. Ruby 4.0's OpenSSL bindings hit fastlane bug [#20553](https://github.com/fastlane/fastlane/issues/20553) — `OpenSSL::PKey::EC.new(pem)` raises "invalid curve name" for `prime256v1` keys, which breaks every App Store Connect API key signing operation. Ruby 3.3.x doesn't have this bug. So we install fastlane via `gem install fastlane --user-install` on `ruby@3.3` instead of `brew install fastlane`.
|
||||
|
||||
### Why IPv6 is disabled on Wi-Fi
|
||||
|
||||
`networksetup -setv6off Wi-Fi` is set because Ruby's net/http on this machine attempted IPv6 to `rubygems.org` first and timed out (~30 s per request). Disabling IPv6 on the Wi-Fi interface forces IPv4 immediately. To re-enable: `sudo networksetup -setv6automatic Wi-Fi`.
|
||||
|
||||
## Verifying the runner is healthy
|
||||
|
||||
From any machine:
|
||||
|
||||
```bash
|
||||
curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/runners/53111580" \
|
||||
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['status'], d['online'])"
|
||||
```
|
||||
|
||||
Expected: `online True`. If `offline` or `not_connected`, SSH in and check:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
gitlab-runner status
|
||||
ps aux | grep gitlab-runner
|
||||
tail -50 ~/gitlab-runner.err.log
|
||||
```
|
||||
|
||||
## Restarting the runner
|
||||
|
||||
After a Mac reboot, the runner should start automatically via the LaunchAgent. To restart manually:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
gitlab-runner restart
|
||||
```
|
||||
|
||||
If `gitlab-runner restart` reports "service not installed", reinstall:
|
||||
|
||||
```bash
|
||||
gitlab-runner install
|
||||
gitlab-runner start
|
||||
```
|
||||
|
||||
This rewrites the LaunchAgent plist.
|
||||
|
||||
## Watching a CI job run live
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan 'tail -f ~/gitlab-runner.out.log'
|
||||
```
|
||||
|
||||
The runner streams build output to stdout. The same output appears in the GitLab job UI.
|
||||
|
||||
## Updating Xcode
|
||||
|
||||
After a major Xcode update:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
sudo xcodebuild -license accept # accept the new license non-interactively
|
||||
xcode-select --install # ensure command-line tools are present
|
||||
xcodebuild -version # confirm version
|
||||
```
|
||||
|
||||
Then trigger a no-op tag rebuild (e.g. cut a patch release) to verify the runner still works.
|
||||
|
||||
## Debugging fastlane locally
|
||||
|
||||
If `build-ipa` fails in CI, reproduce on the Mac. The env vars below mirror what CI sets up:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
cd ~/Projects/ditto
|
||||
git pull origin main
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
|
||||
# Match what CI provides
|
||||
export CI_COMMIT_TAG=v2.x.y
|
||||
export CI_PIPELINE_IID=99999
|
||||
export MATCH_PASSWORD='<from GitLab CI variables>'
|
||||
export MATCH_GIT_BASIC_AUTHORIZATION='<base64 of ci-readonly:gldt-...>'
|
||||
export APP_STORE_CONNECT_API_KEY_ID=<key-id>
|
||||
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<issuer-id>
|
||||
export ASC_KEY_PATH=~/.private_keys/AuthKey_<key-id>.p8
|
||||
|
||||
# Build web assets and sync to Capacitor iOS project (CI does this in before_script)
|
||||
npm ci
|
||||
npx vite build -l error
|
||||
cp dist/index.html dist/404.html
|
||||
npx cap sync ios
|
||||
node scripts/patch-cap-config.mjs
|
||||
|
||||
# Stamp marketing version (CI does this in script)
|
||||
VERSION="${CI_COMMIT_TAG#v}"
|
||||
sed -i '' "s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g" ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Run the build lane
|
||||
cd ios
|
||||
fastlane build_ipa
|
||||
```
|
||||
|
||||
This produces the IPA at `../artifacts/Ditto.ipa` exactly like CI. Add `--verbose` for detailed output.
|
||||
|
||||
To also test the submission step end-to-end (this calls Apple, so be ready to "Remove from Review" in App Store Connect afterward):
|
||||
|
||||
```bash
|
||||
export IPA_PATH="$HOME/Projects/ditto/artifacts/Ditto.ipa"
|
||||
fastlane submit_release
|
||||
```
|
||||
|
||||
Or, to debug *just* the submission against an already-uploaded build without rebuilding, use the `submit_only` lane (see "Debugging App Store submission with the `submit_only` lane" below).
|
||||
|
||||
## Rotating match certificates (yearly)
|
||||
|
||||
Apple distribution certs expire one year after issuance. To renew:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
cd ~/Projects/ditto/ios
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
|
||||
# Set Apple credentials (API key path)
|
||||
export MATCH_PASSWORD='<from GitLab CI variables>'
|
||||
|
||||
# Revoke the expiring cert in Apple's portal and remove from the match repo
|
||||
fastlane match nuke distribution
|
||||
|
||||
# Issue a new cert, generate a new App Store profile, encrypt, commit, push
|
||||
fastlane match appstore \
|
||||
--api_key_path ~/.private_keys/AuthKey_<KEY_ID>.p8 \
|
||||
--api_key_id <KEY_ID> \
|
||||
--api_issuer_id <ISSUER_ID>
|
||||
```
|
||||
|
||||
CI's next tag run picks up the new files via `match(... readonly: true)`. No GitLab variables to update.
|
||||
|
||||
## Debugging App Store submission with the `submit_only` lane
|
||||
|
||||
The `Fastfile` exposes a second lane, `submit_only`, that skips build/archive/upload and just runs `deliver` against an already-uploaded build. Useful when the binary is fine but the metadata/submission step is failing — iterate in ~30 seconds instead of waiting for a full ~6-minute CI build.
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
export PATH="$HOME/.gem/ruby/3.3.0/bin:/opt/homebrew/opt/ruby@3.3/bin:$PATH"
|
||||
cd ~/Projects/ditto/ios
|
||||
|
||||
# Make sure the .p8 is on disk; CI's after_script wipes it after each job
|
||||
scp $LAPTOP:/path/to/AuthKey_<KEY_ID>.p8 ~/.private_keys/
|
||||
|
||||
export ASC_KEY_PATH=$HOME/.private_keys/AuthKey_<KEY_ID>.p8
|
||||
export APP_STORE_CONNECT_API_KEY_ID=<KEY_ID>
|
||||
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<ISSUER_ID>
|
||||
export BUILD_NUMBER=<existing-build-number-on-ASC>
|
||||
export VERSION=<marketing-version, e.g. 2.14.3>
|
||||
|
||||
fastlane submit_only
|
||||
```
|
||||
|
||||
The lane expects the version to exist in App Store Connect with a `VALID` build attached. It uploads metadata (`./fastlane/metadata/en-US/release_notes.txt`) and calls `submit_for_review`. If Apple rejects, fix the Fastfile, re-run — no rebuild needed.
|
||||
|
||||
If Apple has already accepted the submission for that version, you'll need to "Remove from Review" in App Store Connect (only available while state is `WAITING_FOR_REVIEW`, not `IN_REVIEW`) before re-running, or bump the build number.
|
||||
|
||||
## Inspecting App Store Connect state directly
|
||||
|
||||
When fastlane's error messages aren't enough, query Apple's API directly. There's no installed CLI — use the JWT signing recipe Apple documents. A working Ruby snippet lives in this skill's troubleshooting history; the short version:
|
||||
|
||||
```ruby
|
||||
require "json"; require "openssl"; require "net/http"; require "base64"
|
||||
key_pem = File.read(ENV["ASC_KEY_PATH"])
|
||||
ec = OpenSSL::PKey::EC.new(key_pem)
|
||||
header = { alg: "ES256", kid: ENV["APP_STORE_CONNECT_API_KEY_ID"], typ: "JWT" }
|
||||
payload = { iss: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], iat: Time.now.to_i, exp: Time.now.to_i + 1200, aud: "appstoreconnect-v1" }
|
||||
def b64(s); Base64.urlsafe_encode64(s, padding: false); end
|
||||
si = b64(JSON.generate(header)) + "." + b64(JSON.generate(payload))
|
||||
sig_der = ec.sign(OpenSSL::Digest::SHA256.new, si)
|
||||
asn = OpenSSL::ASN1.decode(sig_der)
|
||||
r = asn.value[0].value.to_s(2); s = asn.value[1].value.to_s(2)
|
||||
r = ("\x00".b * (32 - r.bytesize)) + r if r.bytesize < 32
|
||||
s = ("\x00".b * (32 - s.bytesize)) + s if s.bytesize < 32
|
||||
jwt = si + "." + b64(r + s)
|
||||
# Now: GET https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=pub.ditto.app
|
||||
# with header Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
Useful endpoints:
|
||||
- `GET /v1/apps?filter[bundleId]=pub.ditto.app` → app id
|
||||
- `GET /v1/apps/<id>/appStoreVersions` → version list with `appStoreState`
|
||||
- `GET /v1/apps/<id>/builds?sort=-uploadedDate` → recent builds and processing state
|
||||
- `GET /v1/appStoreVersions/<id>/appStoreVersionLocalizations` → release notes (`whatsNew`)
|
||||
|
||||
## What can go wrong
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Runner shows offline in GitLab | Mac rebooted, auto-login disabled, or LaunchAgent unloaded | SSH in, `gitlab-runner status`, `gitlab-runner restart` |
|
||||
| Build fails: "unable to find Xcode" | Xcode auto-updated and changed path, or command-line tools missing | `xcode-select --install`, `sudo xcodebuild -license accept` |
|
||||
| Build fails: "no signing certificate found" | match cert expired, was revoked manually, or `MATCH_PASSWORD` mismatched | Run yearly rotation procedure above |
|
||||
| Build fails: keychain locked / "User interaction is not allowed" | `setup_ci` failed to create the temporary keychain | Verify `FASTLANE_KEYCHAIN_PASSWORD` is set in GitLab CI variables |
|
||||
| Build fails: ASC API key invalid | Key was revoked or rotated | Generate a new key and update `APP_STORE_CONNECT_API_KEY_*` variables |
|
||||
| "Build already exists" from `deliver` | Previous tag's IPA had the same `CFBundleVersion`; fastlane's `increment_build_number` didn't bump because the value already matched `CI_PIPELINE_IID` | Push a new tag (each new tag has a new pipeline ID) |
|
||||
| Apple precheck rejects metadata | Encryption export compliance, IDFA, content rights flags don't match `Fastfile` | Update `submission_information` in `ios/fastlane/Fastfile` |
|
||||
| `OpenSSL::PKey::PKeyError: invalid curve name` | fastlane is running on brewed Ruby 4.0, which has a broken OpenSSL EC parser ([fastlane#20553](https://github.com/fastlane/fastlane/issues/20553)) | Use `ruby@3.3` from brew and install fastlane as a user gem (`gem install fastlane --user-install`); ensure `~/.bash_profile` puts `~/.gem/ruby/3.3.0/bin` on PATH ahead of `/opt/homebrew/bin` |
|
||||
| `gem install` / `bundle install` hangs for >30s per request | Ruby's net/http tries IPv6 to rubygems.org and times out on this network | `sudo networksetup -setv6off Wi-Fi` (per-interface, persistent until reboot) |
|
||||
| `Unresolved conflict between options: 'api_key_path' and 'api_key'` | `app_store_connect_api_key` action sets `APP_STORE_CONNECT_API_KEY_PATH` env var (path to `.p8`), match's same-named env var expects a JSON descriptor | Build the API key hash inline in the Fastfile (don't call `app_store_connect_api_key`); read `.p8` from a non-conflicting var like `ASC_KEY_PATH` |
|
||||
| `[match] Could not find the newly generated certificate installed` when running match interactively on macOS 26+ | [fastlane#15185](https://github.com/fastlane/fastlane/issues/15185) — the new-cert verification step trips on partition list and keychain trust | Run cert generation **in CI** via the bootstrap procedure in the `ci-cd-publishing` skill (uses `setup_ci`'s ephemeral keychain). Don't run `fastlane match appstore` interactively. |
|
||||
| iOS build fails: `No "iOS Development" signing certificate matching team ID` | The Xcode project uses `CODE_SIGN_STYLE=Automatic`; xcodebuild tries to find a Development cert even for Release builds | Override via `xcargs: "CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER='match AppStore <bundle-id>' DEVELOPMENT_TEAM=<team>"` in the Fastfile (already configured) |
|
||||
| `vite.config.ts: Unexpected token 'c', "concurrent"... is not valid JSON` | GitLab Runner sets `CONFIG_FILE=/Users/alex/.gitlab-runner/config.toml` in the job environment, which collides with vite's `process.env.CONFIG_FILE ?? "./ditto.json"` lookup | Already fixed: use `DITTO_CONFIG_FILE` for the override env var |
|
||||
| `whatsNew is missing` from `submit_for_review` | `metadata_path: "./metadata"` resolves relative to fastlane's cwd (`ios/`), not its config dir (`ios/fastlane/`); fastlane silently uploads zero locales | Use `metadata_path: "./fastlane/metadata"` (already configured) |
|
||||
| `appStoreVersions ... is not in valid state` | Apple won't accept submission because the version is past `PREPARE_FOR_SUBMISSION` (already submitted, in review, or shipped) | "Remove from Review" in App Store Connect if `WAITING_FOR_REVIEW`, or cut a new version |
|
||||
| `An attribute value is not acceptable for the current resource state. - contentRightsDeclaration` | Apple rejects PATCH on locked App-level fields when `submission_information` includes `content_rights_*` | Drop `content_rights_*` from `submission_information` in the Fastfile (already configured) |
|
||||
|
||||
## When the Mac dies
|
||||
|
||||
1. Get a replacement Mac. Install Xcode from the App Store.
|
||||
2. Run the **`ci-cd-publishing`** skill's "Initial setup" — but skip the App Store Connect API key step (you already have it). Re-register the runner with the same `macos` tag.
|
||||
3. Restore signing identity: `cd ditto/ios && fastlane match appstore --readonly` decrypts the existing certs/profiles using `MATCH_PASSWORD`.
|
||||
4. No reissuance, no revocation, no GitLab variable updates needed. The certificates repo is the source of truth.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: nip19-routing
|
||||
description: Implement or populate the root-level NIP-19 router (/:nip19) that handles npub, nprofile, note, nevent, and naddr identifiers. Covers decoding, secure filter construction, and type-specific rendering for profiles, notes, events, and addressable events.
|
||||
---
|
||||
|
||||
# NIP-19 Identifier Routing
|
||||
|
||||
NIP-19 defines the bech32-encoded identifiers used throughout Nostr (`npub1...`, `note1...`, `naddr1...`, etc.). This project routes all of them through a single root-level page at `/:nip19`, implemented by `src/pages/NIP19Page.tsx`.
|
||||
|
||||
Use this skill when the user wants to populate the `NIP19Page` sections with real views, add a new identifier type, or build links that point into the Nostr routing system.
|
||||
|
||||
## Identifier Reference
|
||||
|
||||
| Prefix | Payload | Use when… |
|
||||
|--------------|------------------------------------------------------------------|--------------------------------------------------------------|
|
||||
| `npub1` | 32-byte public key | Simple user reference |
|
||||
| `nprofile1` | Public key + optional relay hints + petname | User reference with relay context |
|
||||
| `note1` | 32-byte event ID (kind:1 text notes only, per NIP-10) | Referencing a short text note/thread |
|
||||
| `nevent1` | Event ID + optional relay hints + author pubkey + kind | Any event kind, or notes where you need relay/author context |
|
||||
| `naddr1` | `kind` + `pubkey` + `identifier` (`d` tag) + optional relay hints | Addressable events (kind 30000-39999): articles, products |
|
||||
| `nsec1` | Private key | **Never display or route** — treat as a 404 |
|
||||
| `nrelay1` | Relay URL | Deprecated |
|
||||
|
||||
### `note1` vs `nevent1`
|
||||
|
||||
- `note1` carries only an event ID, and is canonically tied to kind:1 text notes.
|
||||
- `nevent1` can reference **any** kind and can carry relay hints + author pubkey. Prefer `nevent1` for non-kind-1 events or when you want to ship relay hints with a link.
|
||||
|
||||
### `npub1` vs `nprofile1`
|
||||
|
||||
- `npub1` is just a pubkey.
|
||||
- `nprofile1` adds relay hints and a petname. Prefer it for shareable profile links where discoverability matters.
|
||||
|
||||
## Routing Rules
|
||||
|
||||
1. **All NIP-19 identifiers are handled at the URL root**: `/:nip19` in `AppRouter.tsx`. Never nest them under paths like `/note/:id` or `/profile/:npub`.
|
||||
2. **Invalid, vacant, or unsupported identifiers** (including `nsec1` and `nrelay1`) render the 404 page. The `NIP19Page` boilerplate already handles this.
|
||||
3. **Addressable event URLs must include the author**. `naddr1` already encodes `pubkey` + `kind` + `identifier`, which is exactly what a secure query filter needs. If you ever design an alternative URL, use the shape `/:npub/:dtag`, never `/:dtag` alone — otherwise anyone can publish a conflicting event with the same `d` tag.
|
||||
|
||||
## Decoding and Filtering
|
||||
|
||||
Nostr relay filters only accept hex strings. Always decode the NIP-19 identifier before building a filter.
|
||||
|
||||
```ts
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
const decoded = nip19.decode(value); // throws on invalid input
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub': {
|
||||
const pubkey = decoded.data; // hex string
|
||||
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'nprofile': {
|
||||
const { pubkey /*, relays */ } = decoded.data;
|
||||
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
const id = decoded.data;
|
||||
return nostr.query([{ ids: [id], kinds: [1], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'nevent': {
|
||||
const { id /*, relays, author, kind */ } = decoded.data;
|
||||
return nostr.query([{ ids: [id], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'naddr': {
|
||||
const { kind, pubkey, identifier } = decoded.data;
|
||||
return nostr.query([{
|
||||
kinds: [kind],
|
||||
authors: [pubkey], // critical: prevents d-tag spoofing
|
||||
'#d': [identifier],
|
||||
limit: 1,
|
||||
}]);
|
||||
}
|
||||
|
||||
default:
|
||||
// nsec, nrelay, unknown → 404
|
||||
throw new Error('Unsupported Nostr identifier');
|
||||
}
|
||||
```
|
||||
|
||||
### Common mistakes
|
||||
|
||||
```ts
|
||||
// ❌ Passing bech32 into a filter
|
||||
nostr.query([{ ids: [naddr] }]);
|
||||
|
||||
// ❌ Addressable lookup without the author — anyone can spoof the d-tag
|
||||
nostr.query([{ kinds: [30023], '#d': [slug] }]);
|
||||
|
||||
// ✅ Decode first, then include author
|
||||
const { kind, pubkey, identifier } = nip19.decode(naddr).data;
|
||||
nostr.query([{ kinds: [kind], authors: [pubkey], '#d': [identifier] }]);
|
||||
```
|
||||
|
||||
## Populating `NIP19Page`
|
||||
|
||||
`src/pages/NIP19Page.tsx` already:
|
||||
|
||||
- Decodes `params.nip19` with `nip19.decode`.
|
||||
- Branches on `decoded.type` with a section for each supported identifier.
|
||||
- Redirects invalid / unsupported identifiers to the 404 page.
|
||||
- Provides a responsive container wrapper.
|
||||
|
||||
To turn it into a real router, replace each placeholder section with a concrete component:
|
||||
|
||||
| `decoded.type` | Typical view |
|
||||
|-----------------------|---------------------------------------------------------------|
|
||||
| `npub` / `nprofile` | Profile page: header from kind 0, feed of the user's events |
|
||||
| `note` | Single kind:1 text note with thread + replies |
|
||||
| `nevent` | Generic event renderer; branch on `kind` for specialized UIs |
|
||||
| `naddr` | Addressable-event view (article, product, community, etc.) |
|
||||
|
||||
Inside each branch, pass the decoded payload (not the raw bech32 string) to a child component. That keeps filter construction colocated with the fetching hook and removes any chance of a re-decode mismatch.
|
||||
|
||||
## Linking to NIP-19 Routes
|
||||
|
||||
When building links elsewhere in the app:
|
||||
|
||||
```tsx
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// To a profile
|
||||
<Link to={`/${nip19.npubEncode(pubkey)}`}>Profile</Link>
|
||||
|
||||
// To an addressable event (article, product, …)
|
||||
<Link to={`/${nip19.naddrEncode({ kind, pubkey, identifier, relays })}`}>
|
||||
Open
|
||||
</Link>
|
||||
|
||||
// To a specific event of any kind, with relay hints
|
||||
<Link to={`/${nip19.neventEncode({ id, relays, author, kind })}`}>Open</Link>
|
||||
```
|
||||
|
||||
Always encode with the **most specific** identifier you have context for (`nprofile` > `npub`, `nevent` > `note`, `naddr` for addressable). The extra metadata makes links more robust across relays.
|
||||
|
||||
## Security Recap
|
||||
|
||||
- Decode **before** querying.
|
||||
- For addressable events, always include `authors: [pubkey]` in the filter — the `d` tag alone is not a trust boundary.
|
||||
- Treat `nsec1` and any unknown/invalid identifier as 404. Never render, log, or echo a decoded `nsec`.
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: nip85-stats
|
||||
description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig.
|
||||
---
|
||||
|
||||
# NIP-85 Trusted Assertion Stats
|
||||
|
||||
[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider.
|
||||
|
||||
This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers.
|
||||
|
||||
## Kinds Used
|
||||
|
||||
| Kind | Subject | `d` tag value |
|
||||
| ----- | ---------------------------- | -------------------------- |
|
||||
| 30382 | User | user pubkey (hex) |
|
||||
| 30383 | Event (regular, kind 1 etc.) | event id (hex) |
|
||||
| 30384 | Addressable event | `<kind>:<pubkey>:<d-tag>` |
|
||||
|
||||
The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it.
|
||||
|
||||
## Files Provided by This Skill
|
||||
|
||||
| Skill file | Copy to |
|
||||
|---|---|
|
||||
| `files/hooks/useNip85Stats.ts` | `src/hooks/useNip85Stats.ts` |
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Copy the Hooks File
|
||||
|
||||
Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template.
|
||||
|
||||
### 2. Add `nip85StatsPubkey` to `AppConfig`
|
||||
|
||||
In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface:
|
||||
|
||||
```typescript
|
||||
export interface AppConfig {
|
||||
// ...existing fields...
|
||||
/** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */
|
||||
nip85StatsPubkey: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update the Zod Schema in `AppProvider.tsx`
|
||||
|
||||
In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`:
|
||||
|
||||
```typescript
|
||||
const AppConfigSchema = z.object({
|
||||
// ...existing fields...
|
||||
nip85StatsPubkey: z.string().refine(
|
||||
(val) => val.length === 0 || /^[0-9a-f]{64}$/i.test(val),
|
||||
{ message: 'Must be empty or a 64-character hex pubkey' },
|
||||
),
|
||||
}) satisfies z.ZodType<AppConfig>;
|
||||
```
|
||||
|
||||
### 4. Set the Default in `App.tsx`
|
||||
|
||||
Pick a provider pubkey and add it to `defaultConfig`. The ditto.pub provider is a reasonable default:
|
||||
|
||||
```typescript
|
||||
const defaultConfig: AppConfig = {
|
||||
// ...existing fields...
|
||||
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
|
||||
};
|
||||
```
|
||||
|
||||
Set to `''` to ship with stats disabled.
|
||||
|
||||
### 5. Update `TestApp.tsx`
|
||||
|
||||
In `src/test/TestApp.tsx`, add the field to the test default config. Use an empty string so tests don't hit a live provider:
|
||||
|
||||
```typescript
|
||||
const defaultConfig: AppConfig = {
|
||||
// ...existing fields...
|
||||
nip85StatsPubkey: '',
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### User stats (kind 30382)
|
||||
|
||||
```tsx
|
||||
import { useNip85UserStats } from '@/hooks/useNip85Stats';
|
||||
|
||||
function FollowerCount({ pubkey }: { pubkey: string }) {
|
||||
const { data: stats } = useNip85UserStats(pubkey);
|
||||
if (!stats) return null; // no provider configured or no assertion yet
|
||||
return <span>{stats.followers.toLocaleString()} followers</span>;
|
||||
}
|
||||
```
|
||||
|
||||
### Event stats (kind 30383)
|
||||
|
||||
```tsx
|
||||
import { useNip85EventStats } from '@/hooks/useNip85Stats';
|
||||
|
||||
function NoteStats({ eventId }: { eventId: string }) {
|
||||
const { data: stats } = useNip85EventStats(eventId);
|
||||
if (!stats) return null;
|
||||
return (
|
||||
<div className="flex gap-3 text-sm text-muted-foreground">
|
||||
<span>{stats.reactionCount} reactions</span>
|
||||
<span>{stats.repostCount} reposts</span>
|
||||
<span>{stats.commentCount} comments</span>
|
||||
<span>{stats.zapAmount} sats</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Addressable event stats (kind 30384)
|
||||
|
||||
The `addr` argument is the full NIP-01 event address `<kind>:<pubkey>:<d-tag>`:
|
||||
|
||||
```tsx
|
||||
import { useNip85AddrStats } from '@/hooks/useNip85Stats';
|
||||
|
||||
function ArticleStats({ kind, pubkey, identifier }: { kind: number; pubkey: string; identifier: string }) {
|
||||
const { data: stats } = useNip85AddrStats(`${kind}:${pubkey}:${identifier}`);
|
||||
if (!stats) return null;
|
||||
return <span>{stats.reactionCount} reactions</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior Notes
|
||||
|
||||
- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth.
|
||||
- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI.
|
||||
- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically.
|
||||
- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics.
|
||||
- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts.
|
||||
|
||||
## Extending the Stats
|
||||
|
||||
The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`:
|
||||
|
||||
```typescript
|
||||
export interface Nip85UserStats {
|
||||
followers: number;
|
||||
postCount: number;
|
||||
rank: number; // new
|
||||
zapAmtReceived: number; // new
|
||||
}
|
||||
|
||||
// inside useNip85UserStats queryFn
|
||||
return {
|
||||
followers: getIntTag(tags, 'followers'),
|
||||
postCount: getIntTag(tags, 'post_cnt'),
|
||||
rank: getIntTag(tags, 'rank'),
|
||||
zapAmtReceived: getIntTag(tags, 'zap_amt_recd'),
|
||||
};
|
||||
```
|
||||
|
||||
See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md).
|
||||
|
||||
## Exposing a Provider Picker (Optional)
|
||||
|
||||
If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value:
|
||||
|
||||
```tsx
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
function StatsProviderInput() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
return (
|
||||
<input
|
||||
value={config.nip85StatsPubkey}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim().toLowerCase();
|
||||
if (v === '' || /^[0-9a-f]{64}$/.test(v)) {
|
||||
updateConfig(() => ({ nip85StatsPubkey: v }));
|
||||
}
|
||||
}}
|
||||
placeholder="64-char hex pubkey (blank to disable)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Related NIPs
|
||||
|
||||
- [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) — Trusted Assertions (this skill)
|
||||
- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Addressable event addressing (`<kind>:<pubkey>:<d-tag>`)
|
||||
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate)
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
/** Engagement counts exposed by NIP-85 kind 30383 (events) and 30384 (addressable events). */
|
||||
export interface Nip85EventStats {
|
||||
commentCount: number;
|
||||
repostCount: number;
|
||||
reactionCount: number;
|
||||
zapCount: number;
|
||||
/** Zap amount in sats. */
|
||||
zapAmount: number;
|
||||
}
|
||||
|
||||
/** A subset of NIP-85 kind 30382 (user) stats — extend as needed. */
|
||||
export interface Nip85UserStats {
|
||||
followers: number;
|
||||
postCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an integer tag value from a NIP-85 assertion event. Returns 0 when missing
|
||||
* or unparseable, which mirrors the semantics of "no data" in NIP-85.
|
||||
*/
|
||||
function getIntTag(tags: string[][], tagName: string): number {
|
||||
const tag = tags.find(([name]) => name === tagName);
|
||||
if (!tag?.[1]) return 0;
|
||||
const n = parseInt(tag[1], 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey.
|
||||
* Returns `null` when no stats pubkey is configured or the provider has no
|
||||
* assertion for this event.
|
||||
*/
|
||||
export function useNip85EventStats(eventId: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85EventStats | null>({
|
||||
queryKey: ['nip85-event-stats', eventId, statsPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!eventId || !statsPubkey) return null;
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const { tags } = events[0];
|
||||
return {
|
||||
commentCount: getIntTag(tags, 'comment_cnt'),
|
||||
repostCount: getIntTag(tags, 'repost_cnt'),
|
||||
reactionCount: getIntTag(tags, 'reaction_cnt'),
|
||||
zapCount: getIntTag(tags, 'zap_cnt'),
|
||||
zapAmount: getIntTag(tags, 'zap_amount'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!eventId && !!statsPubkey,
|
||||
staleTime: 30 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey.
|
||||
* Returns `null` when no stats pubkey is configured or the provider has no
|
||||
* assertion for this pubkey.
|
||||
*/
|
||||
export function useNip85UserStats(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85UserStats | null>({
|
||||
queryKey: ['nip85-user-stats', pubkey, statsPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!pubkey || !statsPubkey) return null;
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30382], authors: [statsPubkey], '#d': [pubkey], limit: 1 }],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const { tags } = events[0];
|
||||
return {
|
||||
followers: getIntTag(tags, 'followers'),
|
||||
postCount: getIntTag(tags, 'post_cnt'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!pubkey && !!statsPubkey,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches NIP-85 addressable event stats (kind 30384) from the configured
|
||||
* stats pubkey. The `addr` argument is the full NIP-01 event address string,
|
||||
* e.g. `30023:<pubkey>:<d-tag>`.
|
||||
*/
|
||||
export function useNip85AddrStats(addr: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85EventStats | null>({
|
||||
queryKey: ['nip85-addr-stats', addr, statsPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!addr || !statsPubkey) return null;
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30384], authors: [statsPubkey], '#d': [addr], limit: 1 }],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const { tags } = events[0];
|
||||
return {
|
||||
commentCount: getIntTag(tags, 'comment_cnt'),
|
||||
repostCount: getIntTag(tags, 'repost_cnt'),
|
||||
reactionCount: getIntTag(tags, 'reaction_cnt'),
|
||||
zapCount: getIntTag(tags, 'zap_cnt'),
|
||||
zapAmount: getIntTag(tags, 'zap_amount'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!addr && !!statsPubkey,
|
||||
staleTime: 30 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
---
|
||||
name: nostr-direct-messages
|
||||
description: Implement Nostr direct messaging features, build chat interfaces, or work with encrypted peer-to-peer communication (NIP-04 and NIP-17).
|
||||
---
|
||||
|
||||
# Direct Messaging on Nostr
|
||||
|
||||
This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage.
|
||||
|
||||
**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application.
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Add DMProvider to Your App
|
||||
|
||||
First, add the `DMProvider` to your app's provider tree in `src/App.tsx`:
|
||||
|
||||
```tsx
|
||||
// Add these imports at the top of src/App.tsx
|
||||
import { DMProvider, type DMConfig } from '@/components/DMProvider';
|
||||
import { PROTOCOL_MODE } from '@/lib/dmConstants';
|
||||
|
||||
// Add this configuration before your App component
|
||||
const dmConfig: DMConfig = {
|
||||
// Enable or disable DMs entirely
|
||||
enabled: true, // Set to true to enable messaging functionality
|
||||
|
||||
// Choose one protocol mode:
|
||||
// PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only
|
||||
// PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only
|
||||
// PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17)
|
||||
protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps
|
||||
};
|
||||
|
||||
// Then wrap your app components with DMProvider:
|
||||
export function App() {
|
||||
return (
|
||||
<UnheadProvider head={head}>
|
||||
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey='nostr:login'>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<DMProvider config={dmConfig}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</DMProvider>
|
||||
</NostrProvider>
|
||||
</NostrLoginProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>
|
||||
</UnheadProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Configure DM Settings
|
||||
|
||||
The `DMConfig` object supports the following options:
|
||||
|
||||
- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed.
|
||||
- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support:
|
||||
- `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only
|
||||
- `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended)
|
||||
- `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility)
|
||||
|
||||
**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Send Messages
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
|
||||
|
||||
function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) {
|
||||
const { sendMessage } = useDMContext();
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const handleSend = async () => {
|
||||
await sendMessage({
|
||||
recipientPubkey,
|
||||
content,
|
||||
protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping
|
||||
});
|
||||
setContent('');
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Display Conversations
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
function ConversationList({ onSelectConversation }: { onSelectConversation: (pubkey: string) => void }) {
|
||||
const { conversations, isLoading } = useDMContext();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading conversations...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{conversations.map((conversation) => (
|
||||
<ConversationItem
|
||||
key={conversation.pubkey}
|
||||
conversation={conversation}
|
||||
onClick={() => onSelectConversation(conversation.pubkey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationItem({ conversation, onClick }: {
|
||||
conversation: ConversationSummary;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const author = useAuthor(conversation.pubkey);
|
||||
const displayName = author.data?.metadata?.name || genUserName(conversation.pubkey);
|
||||
const avatarUrl = author.data?.metadata?.picture;
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className="w-full p-3 hover:bg-accent rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={avatarUrl} />
|
||||
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{displayName}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{conversation.lastMessage?.decryptedContent || 'No messages yet'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Display Messages in a Conversation
|
||||
|
||||
```tsx
|
||||
import { useConversationMessages } from '@/hooks/useConversationMessages';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
function MessageThread({ conversationPubkey }: { conversationPubkey: string }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(conversationPubkey);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
{hasMoreMessages && (
|
||||
<button onClick={loadEarlierMessages} className="text-sm text-muted-foreground">
|
||||
Load earlier messages
|
||||
</button>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
const isFromMe = message.pubkey === user?.pubkey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex",
|
||||
isFromMe ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"max-w-[70%] rounded-lg px-4 py-2",
|
||||
isFromMe ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}>
|
||||
{message.error ? (
|
||||
<span className="text-red-500">🔒 {message.error}</span>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{message.decryptedContent}
|
||||
</p>
|
||||
)}
|
||||
{message.isSending && (
|
||||
<span className="text-xs opacity-50">Sending...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using the Complete Messaging Interface
|
||||
|
||||
For a fully-featured messaging UI out of the box, use the `DMMessagingInterface` component:
|
||||
|
||||
```tsx
|
||||
import { DMMessagingInterface } from "@/components/dm/DMMessagingInterface";
|
||||
|
||||
function MessagesPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-4 h-screen">
|
||||
<DMMessagingInterface />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `DMMessagingInterface` component provides a complete messaging UI with:
|
||||
- Conversation list with Active/Requests tabs
|
||||
- Message thread view with pagination
|
||||
- Compose area with file upload support
|
||||
- Real-time message updates
|
||||
- Mobile-responsive layout (shows one panel at a time on mobile)
|
||||
|
||||
It requires no props and works automatically when wrapped in `DMProvider`.
|
||||
|
||||
**For custom layouts**, see the "Building Custom Messaging UIs" section below for individual components (`DMConversationList`, `DMChatArea`, `DMStatusInfo`).
|
||||
|
||||
## Sending Files with Messages
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
|
||||
import type { FileAttachment } from '@/contexts/DMContext';
|
||||
|
||||
function ComposeWithFiles({ recipientPubkey }: { recipientPubkey: string }) {
|
||||
const { sendMessage } = useDMContext();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const [content, setContent] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const handleSend = async () => {
|
||||
let attachments: FileAttachment[] | undefined;
|
||||
|
||||
// Upload file if one is selected
|
||||
if (selectedFile) {
|
||||
const tags = await uploadFile(selectedFile);
|
||||
|
||||
attachments = [{
|
||||
url: tags[0][1], // URL from first tag
|
||||
mimeType: selectedFile.type,
|
||||
size: selectedFile.size,
|
||||
name: selectedFile.name,
|
||||
tags: tags
|
||||
}];
|
||||
}
|
||||
|
||||
await sendMessage({
|
||||
recipientPubkey,
|
||||
content,
|
||||
protocol: MESSAGE_PROTOCOL.NIP17,
|
||||
attachments,
|
||||
});
|
||||
|
||||
setContent('');
|
||||
setSelectedFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
|
||||
{selectedFile && <div>Selected: {selectedFile.name}</div>}
|
||||
|
||||
<button type="submit" disabled={isUploading}>
|
||||
{isUploading ? 'Uploading...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Comparison
|
||||
|
||||
### NIP-04 (Legacy)
|
||||
- **Encryption**: NIP-04 (simpler, older)
|
||||
- **Metadata**: Sender and recipient visible to relays
|
||||
- **Event Kind**: Kind 4
|
||||
- **Use When**: Compatibility with older clients
|
||||
|
||||
### NIP-17 (Modern & Private)
|
||||
- **Encryption**: NIP-44 (stronger)
|
||||
- **Metadata**: Hidden via gift wrapping (NIP-59)
|
||||
- **Event Kinds**: Kind 14 (text), Kind 15 (files)
|
||||
- **Wrapped In**: Kind 1059 (Gift Wrap) with ephemeral keys
|
||||
- **Use When**: Maximum privacy (recommended)
|
||||
|
||||
**Key Privacy Features of NIP-17:**
|
||||
- Sender identity hidden (uses random ephemeral keys)
|
||||
- Timestamps randomized (±2 days) to hide send time
|
||||
- Dual gift wraps (recipient + sender) for message history
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Conversation Categorization
|
||||
|
||||
The system automatically categorizes conversations:
|
||||
|
||||
```tsx
|
||||
const { conversations } = useDMContext();
|
||||
|
||||
// Filter by category
|
||||
const knownConversations = conversations.filter(c => c.isKnown);
|
||||
const requestConversations = conversations.filter(c => c.isRequest);
|
||||
|
||||
// isKnown = true if user has sent at least one message
|
||||
// isRequest = true if only received messages, never replied
|
||||
```
|
||||
|
||||
### Loading States
|
||||
|
||||
```tsx
|
||||
const { isLoading, loadingPhase, scanProgress } = useDMContext();
|
||||
|
||||
// Check overall loading state
|
||||
if (isLoading) {
|
||||
console.log('Current phase:', loadingPhase);
|
||||
// LOADING_PHASES.CACHE - Loading from local cache
|
||||
// LOADING_PHASES.RELAYS - Querying relays
|
||||
// LOADING_PHASES.SUBSCRIPTIONS - Setting up real-time updates
|
||||
// LOADING_PHASES.READY - Fully loaded
|
||||
}
|
||||
|
||||
// Display scan progress for large message histories
|
||||
if (scanProgress.nip17) {
|
||||
console.log(`NIP-17: ${scanProgress.nip17.current} messages - ${scanProgress.nip17.status}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Clear Cache and Refresh
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
|
||||
function SettingsButton() {
|
||||
const { clearCacheAndRefetch } = useDMContext();
|
||||
|
||||
const handleClearCache = async () => {
|
||||
await clearCacheAndRefetch();
|
||||
// Clears IndexedDB cache and reloads all messages from relays
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleClearCache}>
|
||||
Clear Message Cache
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Data Flow
|
||||
1. **Cache First**: Messages load instantly from encrypted IndexedDB cache
|
||||
2. **Background Sync**: New messages fetched from relays in parallel
|
||||
3. **Real-time Updates**: WebSocket subscriptions for live messages
|
||||
4. **Optimistic UI**: Sent messages appear immediately, confirmed on relay response
|
||||
|
||||
### Storage
|
||||
- **IndexedDB**: All messages stored locally with NIP-44 encryption
|
||||
- **Per-User Storage**: Separate encrypted store for each logged-in user
|
||||
- **Automatic Sync**: Debounced writes (15s) + immediate on new messages
|
||||
|
||||
### Performance
|
||||
- **Parallel Queries**: NIP-04 and NIP-17 messages fetched simultaneously
|
||||
- **Batched Loading**: Messages loaded in batches (1000/batch, 20k limit)
|
||||
- **Pagination**: Conversation messages paginated (25/page)
|
||||
- **Deduplication**: Automatic filtering of duplicate messages by ID
|
||||
|
||||
### Security
|
||||
- **NIP-44 Encryption**: Modern authenticated encryption for all NIP-17 messages
|
||||
- **Local Encryption**: IndexedDB storage encrypted with user's NIP-44 key
|
||||
- **Ephemeral Keys**: Random keys for NIP-17 gift wraps (sender anonymity)
|
||||
- **No Plaintext**: Decrypted content never persisted unencrypted
|
||||
- **Domain Isolation**: IndexedDB databases are namespaced by hostname to prevent data conflicts
|
||||
|
||||
## Building Custom Messaging UIs
|
||||
|
||||
For advanced use cases, you can use the individual DM components to build custom layouts:
|
||||
|
||||
### Available Components
|
||||
|
||||
**`DMConversationList`** - Conversation sidebar with tabs
|
||||
```tsx
|
||||
import { DMConversationList } from '@/components/dm/DMConversationList';
|
||||
|
||||
<DMConversationList
|
||||
selectedPubkey={selectedPubkey}
|
||||
onSelectConversation={(pubkey) => setSelectedPubkey(pubkey)}
|
||||
onStatusClick={() => setShowStatus(true)} // optional
|
||||
className="h-full"
|
||||
/>
|
||||
```
|
||||
|
||||
**`DMChatArea`** - Message thread and compose area
|
||||
```tsx
|
||||
import { DMChatArea } from '@/components/dm/DMChatArea';
|
||||
|
||||
<DMChatArea
|
||||
pubkey={selectedPubkey}
|
||||
onBack={() => setSelectedPubkey(null)} // optional, for mobile back button
|
||||
className="h-full"
|
||||
/>
|
||||
```
|
||||
|
||||
**`DMStatusInfo`** - Debug/status panel
|
||||
```tsx
|
||||
import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
|
||||
|
||||
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
|
||||
```
|
||||
|
||||
### Custom Layout Example
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { DMConversationList } from '@/components/dm/DMConversationList';
|
||||
import { DMChatArea } from '@/components/dm/DMChatArea';
|
||||
|
||||
function CustomMessagingLayout() {
|
||||
const [selectedPubkey, setSelectedPubkey] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Custom sidebar */}
|
||||
<aside className="w-64 border-r">
|
||||
<DMConversationList
|
||||
selectedPubkey={selectedPubkey}
|
||||
onSelectConversation={setSelectedPubkey}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Custom main area */}
|
||||
<main className="flex-1">
|
||||
{selectedPubkey ? (
|
||||
<DMChatArea pubkey={selectedPubkey} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p>Select a conversation to start messaging</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: nostr-encryption
|
||||
description: Encrypt and decrypt content for Nostr direct messages, gift wraps, or any feature that needs NIP-44 (or legacy NIP-04) ciphertext, using the logged-in user's signer.
|
||||
---
|
||||
|
||||
# Nostr Encryption and Decryption
|
||||
|
||||
The logged-in user exposes a `signer` object that matches the NIP-07 signer interface. The signer handles all cryptographic operations internally — including ECDH, conversation-key derivation, and AEAD — so your code never touches a private key.
|
||||
|
||||
**Always use the signer interface for encryption. Never ask the user for their private key, and never derive a shared secret yourself.**
|
||||
|
||||
## NIP-44 (preferred)
|
||||
|
||||
NIP-44 is the modern, authenticated encryption scheme used for DMs (NIP-17), gift wraps (NIP-59), and most new encrypted payloads.
|
||||
|
||||
```ts
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
|
||||
function useEncryptedNote() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
if (!user) throw new Error("Must be logged in");
|
||||
|
||||
// Guard: older signers may not support NIP-44 yet.
|
||||
if (!user.signer.nip44) {
|
||||
throw new Error(
|
||||
"Please upgrade your signer extension to a version that supports NIP-44 encryption",
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt a message to a recipient (use your own pubkey to encrypt to self).
|
||||
const ciphertext = await user.signer.nip44.encrypt(
|
||||
recipientPubkey,
|
||||
"hello world",
|
||||
);
|
||||
|
||||
// Decrypt a message from a sender (use the *other party's* pubkey).
|
||||
const plaintext = await user.signer.nip44.decrypt(senderPubkey, ciphertext);
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
```
|
||||
|
||||
### Key points
|
||||
|
||||
- `encrypt(peerPubkey, plaintext)` — `peerPubkey` is the **other party's** hex public key. For self-encryption (notes, backups), pass `user.pubkey`.
|
||||
- `decrypt(peerPubkey, ciphertext)` — `peerPubkey` is the author of the ciphertext you're decrypting (for an incoming DM, this is the sender's pubkey).
|
||||
- Both methods are async and may throw if the signer rejects the request or the ciphertext is malformed. Wrap calls in `try/catch`.
|
||||
- The signer handles conversation-key caching; repeated calls for the same peer are cheap.
|
||||
|
||||
## NIP-04 (legacy)
|
||||
|
||||
NIP-04 is only needed when interacting with older clients that haven't adopted NIP-44. The API mirrors NIP-44:
|
||||
|
||||
```ts
|
||||
if (!user.signer.nip04) {
|
||||
throw new Error("Signer does not support NIP-04");
|
||||
}
|
||||
|
||||
const ciphertext = await user.signer.nip04.encrypt(peerPubkey, plaintext);
|
||||
const plaintext = await user.signer.nip04.decrypt(peerPubkey, ciphertext);
|
||||
```
|
||||
|
||||
Prefer NIP-44 for anything new. Only fall back to NIP-04 when a spec or peer explicitly requires it.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Encrypt-to-self (drafts, private notes)
|
||||
|
||||
```ts
|
||||
const ciphertext = await user.signer.nip44.encrypt(user.pubkey, draft);
|
||||
createEvent({ kind: 30078, content: ciphertext, tags: [["d", "my-draft"]] });
|
||||
```
|
||||
|
||||
### Decrypt an incoming DM (NIP-17 / NIP-59)
|
||||
|
||||
For gift-wrapped DMs, you'll typically decrypt the outer wrap, then the inner seal, then read the rumor's content. Each decryption uses the *sender* of that specific layer as the peer pubkey.
|
||||
|
||||
### Guarding the UI
|
||||
|
||||
Always check `user.signer.nip44` (or `nip04`) before calling encryption methods. Remote signers and older browser extensions may not implement every interface, and catching the missing-capability case lets you show a useful message ("Please upgrade your signer") instead of an unhandled promise rejection.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: nostr-kind-design
|
||||
description: Decide whether to reuse an existing NIP or mint a new kind, design tag structures that relays can index, choose what goes in content vs. tags, and document new kinds or extensions in NIP.md. Load when authoring a new schema — not when wiring up rendering for a kind that already exists (use nostr-kind-rendering for that).
|
||||
---
|
||||
|
||||
# Nostr Kinds — Design and Schema
|
||||
|
||||
Load this skill when:
|
||||
|
||||
- Minting a new event kind for a Ditto feature.
|
||||
- Extending an existing NIP with new tags.
|
||||
- Deciding whether an existing NIP covers a use case or whether a custom kind is warranted.
|
||||
- Documenting a custom kind or extension in `NIP.md`.
|
||||
|
||||
**Not this skill** — if an existing NIP/kind covers your use case and you only need to render it in Ditto's UI, use the **`nostr-kind-rendering`** skill instead.
|
||||
|
||||
## Choosing Between Existing NIPs and Custom Kinds
|
||||
|
||||
1. **Thorough NIP review first.** Browse the NIP index, then read candidate NIPs in detail. The goal is to find the closest existing solution.
|
||||
2. **Prefer extending existing NIPs** over creating custom kinds, even at the cost of minor schema compromises. Custom kinds fragment the ecosystem.
|
||||
3. **When an existing NIP is close but not perfect**, use its kind as the base and add domain-specific tags. Document the extension in `NIP.md`.
|
||||
4. **Only mint a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case requires different storage characteristics (regular vs. replaceable vs. addressable).
|
||||
5. **If a tool to generate a new kind number is available, you MUST call it.** Never pick an arbitrary number.
|
||||
6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description of the event's purpose.
|
||||
|
||||
**Example decision:**
|
||||
|
||||
```
|
||||
Need: Equipment marketplace for farmers
|
||||
Options:
|
||||
1. NIP-15 (Marketplace) — too structured for peer-to-peer sales
|
||||
2. NIP-99 (Classifieds) — good fit, extensible with farming tags
|
||||
3. Custom kind — perfect fit, no interoperability
|
||||
|
||||
Decision: NIP-99 + farming-specific tags.
|
||||
```
|
||||
|
||||
## Kind Ranges
|
||||
|
||||
An event's kind number determines storage semantics:
|
||||
|
||||
- **Regular** (1000 ≤ kind < 10000) — stored permanently by relays. Notes, articles, etc.
|
||||
- **Replaceable** (10000 ≤ kind < 20000) — only the latest event per `pubkey+kind` is kept. Profile metadata, contact lists, mute lists.
|
||||
- **Addressable** (30000 ≤ kind < 40000) — identified by `pubkey+kind+d-tag`; only the latest per combo is kept. Long-form content, products, definitions.
|
||||
|
||||
Kinds below 1000 are "legacy"; storage is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
|
||||
|
||||
## Tag Design Principles
|
||||
|
||||
- **Kind = schema, tags = semantics.** Don't mint a new kind just to represent a different category of the same data.
|
||||
- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
|
||||
- **Filter at the relay**, not in JavaScript:
|
||||
|
||||
```ts
|
||||
// ❌ Fetch everything, filter locally
|
||||
const events = await nostr.query([{ kinds: [30402] }]);
|
||||
const filtered = events.filter((e) => hasTag(e, 'product_type', 'electronics'));
|
||||
|
||||
// ✅ Filter at the relay
|
||||
const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
|
||||
```
|
||||
|
||||
- **For Ditto-specific niches** (community apps, regional variants), tag events with a `t` value and query on it. Don't do this for generic platforms — it would silo content.
|
||||
|
||||
## Content vs. Tags
|
||||
|
||||
- **`content`** — large freeform text or existing industry-standard JSON (GeoJSON, FHIR, Tiled maps). Kind 0 is the one exception where structured JSON goes in content.
|
||||
- **Tags** — queryable metadata, structured data, anything you might filter on.
|
||||
- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
|
||||
- **If you need to filter by a field, it must be a tag** — relays don't index content.
|
||||
|
||||
```json
|
||||
// ✅ Queryable
|
||||
{ "kind": 30402, "content": "",
|
||||
"tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] }
|
||||
|
||||
// ❌ Structured data buried in content
|
||||
{ "kind": 30402, "content": "{\"title\":\"Camera\",\"price\":250}", "tags": [["d", "product-123"]] }
|
||||
```
|
||||
|
||||
## `NIP.md`
|
||||
|
||||
`NIP.md` documents Ditto's custom kinds and any extensions to existing NIPs. Whenever you mint a new kind or change a custom schema, **create or update `NIP.md`** with the tag list, content format, and intended usage. If a kind you add is effectively the same shape as an existing NIP, note the NIP reference rather than duplicating the spec.
|
||||
|
||||
Standard NIPs (like NIP-84 Highlights, NIP-23 Articles) do **not** go in `NIP.md` — only Ditto-custom kinds and Ditto-specific extensions.
|
||||
|
||||
## After Designing — What's Next?
|
||||
|
||||
Once you've settled on a kind number and tag shape, you still need to render it in Ditto's UI. Load the **`nostr-kind-rendering`** skill for the full multi-location registration checklist (feed cards, detail pages, embedded previews, kind-label maps, notifications, feed-toggle registration).
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
name: nostr-kind-rendering
|
||||
description: Add UI rendering for an event kind Ditto doesn't yet display — feed cards, detail pages, embedded previews, notifications, routes, feed-toggle registration, and the several kind-label maps (KIND_LABELS, KIND_HEADER_MAP, NOTIFICATION_KIND_NOUNS, CommentContext) that must stay in sync. Load when asked to "support / display / render" a NIP or kind number, when a kind renders blank or as "Kind 12345", or when quote embeds of a kind show "This event kind is not supported".
|
||||
---
|
||||
|
||||
# Nostr Kinds — UI Rendering Checklist
|
||||
|
||||
Ditto's kind dispatch is **spread across many files** by design — feed cards, detail pages, embedded previews, notifications, and several kind-label maps each have their own rendering requirements. The central `KIND_LABELS` registry covers the easy cases, but most context-specific maps (grammar, icons, verbs) cannot be derived mechanically and must be updated manually.
|
||||
|
||||
**Missing any location causes visible bugs**: a kind might render blank in quote posts, show "Kind 12345" as a label, skip its action header, tombstone as "This event kind is not supported" in embeds, or — worst of all — have its content fed through the kind-1 tokenizer and auto-linkify URLs/hashtags that weren't authored by the event creator.
|
||||
|
||||
**When in doubt, grep for an existing kind number like `30617` or `9802`** — you'll find every registration point you need to mirror.
|
||||
|
||||
## Decision: Feed-toggle + dedicated page, or just rendering?
|
||||
|
||||
Before touching code, pick one:
|
||||
|
||||
- **Just render it everywhere Nostr content appears** (no feed toggle, no dedicated page). Use when the kind is niche or only reached via direct links / quote embeds. Minimal surface — steps 1–6 below.
|
||||
- **Add a feed toggle + optional dedicated page.** Use when users should be able to browse events of this kind or opt them in/out of their home feed. Requires the feed registration (step 7) and `AppConfig` triple (step 8).
|
||||
|
||||
When the user asks generally to "support" a kind, ask which direction they want if it's not obvious from context.
|
||||
|
||||
## Checklist
|
||||
|
||||
### 1. Content card component (`src/components/`)
|
||||
|
||||
Create `<MyKindCard event={event} />` that renders the event's tags/content appropriately.
|
||||
|
||||
- **Never run event content through the kind-1 tokenizer** (`<NoteContent>` / `<TruncatedNoteContent>`) unless the kind's content is actually free-form user prose. Quote-type content (highlights, snippets, citations) contains URLs and hashtags from the *source*, not the event author — tokenizing them is misleading.
|
||||
- Render plaintext with `whitespace-pre-wrap break-words` inside a `<p>` instead.
|
||||
- Route any event-sourced URLs (`r` tags, media URLs, source links) through `sanitizeUrl()` from `@/lib/sanitizeUrl` before using them in `href`/`src`.
|
||||
- Support an `expanded` prop if the card looks different on the detail page than in the feed.
|
||||
|
||||
### 2. Feed card dispatch (`src/components/NoteCard.tsx`)
|
||||
|
||||
Three edits in this file:
|
||||
|
||||
1. **Flag block** (around lines 384–435): add `const isMyKind = event.kind === XXXX;`.
|
||||
2. **`isTextNote` negation list** (around lines 440–475): add `&& !isMyKind`. Without this, unknown kinds fall through to `UnknownKindContent` (showing only the `alt` tag).
|
||||
3. **Content dispatch ternary** (around lines 578–692): add `) : isMyKind ? (<MyKindCard event={event} />`.
|
||||
4. **`KIND_HEADER_MAP`** (around lines 1710+): add an entry so the feed shows "Alice shared a *noun*" or similar. Pattern:
|
||||
```ts
|
||||
9802: {
|
||||
icon: Highlighter,
|
||||
action: "shared a",
|
||||
noun: "highlight",
|
||||
nounRoute: "/highlights", // omit if no dedicated page
|
||||
},
|
||||
```
|
||||
5. Import the card component and any new lucide icons.
|
||||
|
||||
### 3. Detail page dispatch (`src/pages/PostDetailPage.tsx`)
|
||||
|
||||
Mirror the three NoteCard edits:
|
||||
|
||||
1. **Flag block** (around lines 1021–1098): `const isMyKind = event.kind === XXXX;`.
|
||||
2. **`isTextNote` negation list**: add `&& !isMyKind`.
|
||||
3. **Content dispatch ternary** (around lines 2147–2251): add `) : isMyKind ? (<MyKindCard event={event} expanded />`.
|
||||
|
||||
The loading-state title uses `shellTitleForKind()`, which falls through to `KIND_LABELS` — no override needed unless the kind belongs to a group ("Music Details") or needs composite grammar.
|
||||
|
||||
### 4. Central kind label (`src/lib/kindLabels.ts`)
|
||||
|
||||
Add a **capitalized noun phrase, no articles** to the `KIND_LABELS` map:
|
||||
|
||||
```ts
|
||||
9802: 'Highlight',
|
||||
```
|
||||
|
||||
This is consumed by the detail-page loading title, nsite permission prompt, signer nudge toasts, and addressable-event preview headers. Ignoring this gives "Kind 9802" everywhere it appears.
|
||||
|
||||
### 5. Context-specific label and icon maps
|
||||
|
||||
Each of these maps exists because the surrounding UI needs a different grammatical form. They are **not derived** from `KIND_LABELS` and must be updated manually.
|
||||
|
||||
- **`src/components/CommentContext.tsx`** — `KIND_LABELS` (uses articles: `'a highlight'`, `'an article'`) and `KIND_ICONS` (lucide component reference). Rendered as "Commenting on {label}". Without an entry you get "an unsupported event".
|
||||
- **`src/pages/NotificationsPage.tsx`** — `NOTIFICATION_KIND_NOUNS` (bare lowercase nouns: `'highlight'`, `'article'`). Rendered as "reacted to your {noun}". Without an entry you get "post" as a fallback.
|
||||
- **`src/components/NoteCard.tsx`** — `KIND_HEADER_MAP` (already covered in step 2).
|
||||
|
||||
### 6. Embedded previews (`src/components/EmbeddedNote.tsx`)
|
||||
|
||||
The quote-embed dispatcher in `EmbeddedNote` (around lines 65–110) routes kinds to dedicated compact cards. **Without a branch here, non-content kinds fall through to `EmbeddedNoteCard`, which either:**
|
||||
|
||||
- Shows only the NIP-31 `alt` tag (if present), or
|
||||
- Tombstones as "This event kind is not supported", or
|
||||
- **Feeds the event's `content` through the kind-1 tokenizer** if the kind is mistakenly treated as a content-kind — auto-linkifying URLs and hashtags that weren't authored by the event creator. This is a security/UX bug.
|
||||
|
||||
For any kind whose `content` isn't freeform user prose, add an explicit dispatch branch even if it just renders a minimal compact card. Pattern:
|
||||
|
||||
```tsx
|
||||
if (event.kind === 9802) {
|
||||
return <EmbeddedHighlightCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
```
|
||||
|
||||
Then define the compact card using `EmbeddedCardShell` for the author row + navigation, and render the kind-specific body inside. See `EmbeddedHighlightCard` and `EmbeddedBadgeAwardCard` for reference.
|
||||
|
||||
`src/components/EmbeddedNaddr.tsx` works similarly for addressable kinds — add a branch there if your kind is addressable.
|
||||
|
||||
### 7. Feed/sidebar registration (`src/lib/extraKinds.ts`)
|
||||
|
||||
Only needed if you decided on "feed-toggle + dedicated page" above. Add an `ExtraKindDef`:
|
||||
|
||||
```ts
|
||||
{
|
||||
kind: 9802,
|
||||
id: 'highlights',
|
||||
showKey: 'showHighlights',
|
||||
feedKey: 'feedIncludeHighlights',
|
||||
label: 'Highlights',
|
||||
description: 'Noteworthy excerpts from articles, posts, and the web (NIP-84)',
|
||||
route: 'highlights', // omit for feed-only registration
|
||||
addressable: false,
|
||||
section: 'social', // feed | media | social | development | whimsy
|
||||
blurb: 'Longer marketing copy shown in the info modal.',
|
||||
},
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
- **Sidebar icon** (`src/lib/sidebarItems.tsx`) — add `{ id: "highlights", label: "Highlights", path: "/highlights", icon: Highlighter }` to `SIDEBAR_ITEMS`, and import the icon at the top. `CONTENT_KIND_ICONS` picks up the icon automatically from the sidebar definition.
|
||||
- **Route** (`src/AppRouter.tsx`) — add `const highlightsDef = getExtraKindDef("highlights")!;` at the top of the file and a `<Route path="/highlights" element={<KindFeedPage kind={highlightsDef.kind} title={highlightsDef.label} icon={sidebarItemIcon("highlights", "size-5")} />} />` above the catch-all `*` route.
|
||||
|
||||
### 8. `AppConfig` triple (required if you added feed/sidebar toggle keys in step 7)
|
||||
|
||||
Three files must stay in sync, or the build fails or the setting silently no-ops:
|
||||
|
||||
1. **`src/contexts/AppContext.ts`** — add the fields to the `FeedSettings` interface with JSDoc comments.
|
||||
2. **`src/lib/schemas.ts`** — add the same fields to `FeedSettingsSchema` as `z.boolean().optional()`. `DittoConfigSchema` is derived from `AppConfigSchema` with `.strict()` mode, so any `ditto.json` field missing from Zod is a build error.
|
||||
3. **`src/App.tsx`** — add the default value in the initial `feedSettings` block.
|
||||
4. **`src/test/TestApp.tsx`** — mirror the default in test config so component tests work.
|
||||
|
||||
Convention: `show*` toggles default to `true` (sidebar entries visible), `feedInclude*` toggles default to `false` for niche content, `true` for core feed content.
|
||||
|
||||
### 9. Notification integration (if applicable)
|
||||
|
||||
Load this step when the kind represents an interaction with the user's content (reactions, reposts, highlights, awards, etc.) — i.e. when an event author "does something with" another user's content via an `e`/`a`/`p` tag.
|
||||
|
||||
**Six files** to update:
|
||||
|
||||
1. **`src/hooks/useEncryptedSettings.ts`** — add `highlights?: boolean` (or equivalent) to the `notificationPreferences` object.
|
||||
2. **`src/lib/notificationKinds.ts`** — add the kind to `ALL_NOTIFICATION_KINDS` and add a `if (p.X !== false) kinds.push(XXXX);` line in `getEnabledNotificationKinds`.
|
||||
3. **`src/lib/notificationTemplates.ts`** — add a `NOTIFICATION_TEMPLATES` entry with a title and body for nostr-push server-side notifications.
|
||||
4. **`src/pages/NotificationSettings.tsx`** — extend `NotificationPrefKey` union, add a row to `NOTIFICATION_TYPES` with icon/label/description/kinds.
|
||||
5. **`src/hooks/useNotifications.ts`** — extend `groupKey` (decide if events of this kind group by referenced event or stand alone), and if it's a "did something to your content" kind, add it to the author-ownership filter so users only get notified for interactions with their own content.
|
||||
6. **`src/pages/NotificationsPage.tsx`** — add a case to `GroupedNotificationView`'s switch; write `MyKindNotification` + `MyKindNotificationGroup` components modeled on `RepostNotification` / `LikeNotification`.
|
||||
|
||||
### 10. Spam guards (`src/lib/feedUtils.ts`)
|
||||
|
||||
If the kind has required tags (NIP-spec-mandated references, minimum content, etc.), add a check in `shouldHideFeedEvent` to hide events that don't meet the minimum bar. This pre-filters events before `NoteCard` mounts them, avoiding layout shifts from components that would return `null`.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
// NIP-84 highlights with no excerpt AND no source reference.
|
||||
if (event.kind === 9802) {
|
||||
const hasContent = event.content.trim().length > 0;
|
||||
const hasSource = event.tags.some(([n]) => n === 'a' || n === 'e' || n === 'r');
|
||||
if (!hasContent && !hasSource) return true;
|
||||
}
|
||||
```
|
||||
|
||||
### 11. `NIP.md` (custom kinds only)
|
||||
|
||||
If the kind is a Ditto-custom kind or a Ditto-specific extension of an existing NIP, document it in `NIP.md` — see the **`nostr-kind-design`** skill for the format. Standard NIPs (like NIP-84, NIP-23) do not go in `NIP.md`.
|
||||
|
||||
## Validation
|
||||
|
||||
After making changes, run `npm run test` — it runs `tsc --noEmit`, `eslint`, `vitest`, and `vite build` in sequence. All must pass. Additions to the `AppConfig` triple in particular frequently break the build if one of the four files is missed.
|
||||
|
||||
## Why so many locations?
|
||||
|
||||
These are genuinely different UI contexts (feed cards, detail pages, embedded previews, comment-context labels, notifications, sidebar routes) with different rendering requirements and grammar needs. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common "what to call this kind" case, but feed headers, comment-context text, and notification verbs each need their own grammar, and notification integration involves a whole independent subsystem.
|
||||
|
||||
## Bugs that signal a missed step
|
||||
|
||||
- **"Kind 12345" shown as a label** → step 4 (`KIND_LABELS`).
|
||||
- **"an unsupported event" in CommentContext** → step 5 (`CommentContext` maps).
|
||||
- **"reacted to your **post**"** when it should say "highlight" → step 5 (`NOTIFICATION_KIND_NOUNS`).
|
||||
- **No action header above a feed card** → step 2.4 (`KIND_HEADER_MAP`).
|
||||
- **Blank / `alt`-only card in quote embeds** → step 6 (`EmbeddedNote` dispatcher).
|
||||
- **URLs/hashtags in quoted text auto-linkified** → step 6 (embedded dispatcher forgot to bypass the kind-1 tokenizer).
|
||||
- **Kind doesn't appear in the home feed even with the toggle on** → step 7 (`ExtraKindDef` missing `feedKey`).
|
||||
- **Build error mentioning a missing `FeedSettings` field** → step 8 (one of the three files out of sync).
|
||||
- **Users not notified when their content is interacted with** → step 9 (notification stack).
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: nostr-publishing
|
||||
description: Publish Nostr events with useNostrPublish. Covers the basic publishing pattern, safely mutating replaceable and addressable events (read-modify-write via fetchFreshEvent + prev), published_at preservation, and d-tag collision prevention for new addressable content.
|
||||
---
|
||||
|
||||
# Publishing Nostr Events
|
||||
|
||||
Use this skill when a feature needs to publish events — notes, reactions, list updates, profile edits, addressable content, etc. Covers the `useNostrPublish` hook, the correct read-modify-write pattern for replaceable/addressable lists, and d-tag collision prevention.
|
||||
|
||||
## The `useNostrPublish` Hook
|
||||
|
||||
`useNostrPublish` publishes an event through the app's connection pool and auto-adds a `client` tag. Always guard calls with `useCurrentUser` — publishing requires a signer.
|
||||
|
||||
```tsx
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
export function PostForm() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutate: createEvent } = useNostrPublish();
|
||||
|
||||
if (!user) return <span>You must be logged in to post.</span>;
|
||||
|
||||
return (
|
||||
<button onClick={() => createEvent({ kind: 1, content: 'hello' })}>
|
||||
Post
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `mutateAsync` over `mutate` when the caller needs to `await` the published event (e.g. to navigate to the new event's page, or to chain another publish).
|
||||
|
||||
## Mutating Replaceable and Addressable Events (CRITICAL)
|
||||
|
||||
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a **read-modify-write** cycle: fetch the current event, modify its tags, publish a new version. **Never read from the TanStack Query cache before mutating** — the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
|
||||
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
|
||||
|
||||
```typescript
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
// Inside a mutation function:
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
const currentTags = prev?.tags ?? [];
|
||||
// ...modify tags...
|
||||
await publishEvent({
|
||||
kind: 10003,
|
||||
content: prev?.content ?? '',
|
||||
tags: newTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
```
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
### The `prev` Property on Event Templates
|
||||
|
||||
`useNostrPublish` accepts an optional `prev` property on the event template — the **previous version** of the event being replaced. The hook uses it to manage the `published_at` tag (NIP-24) automatically:
|
||||
|
||||
- **First publish (no `prev`)** — `published_at` is set equal to `created_at`.
|
||||
- **Update (`prev` provided)** — `published_at` is preserved from the old event.
|
||||
- **Old event lacks `published_at`** — nothing is fabricated.
|
||||
- **Caller already set `published_at` in tags** — left alone.
|
||||
|
||||
**Convention**: name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
|
||||
|
||||
```typescript
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
// ...
|
||||
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
|
||||
```
|
||||
|
||||
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
|
||||
|
||||
## D-Tag Collision Prevention for Addressable Events
|
||||
|
||||
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
|
||||
|
||||
### When to check for collisions
|
||||
|
||||
- **Must check** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.).
|
||||
- **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with an embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
|
||||
|
||||
### Implementation pattern
|
||||
|
||||
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
|
||||
|
||||
```typescript
|
||||
// Before publishing a new addressable event:
|
||||
const slug = slugify(title, { lower: true, strict: true });
|
||||
|
||||
const existing = await nostr.query([
|
||||
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
|
||||
]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
toast({
|
||||
title: 'Slug already in use',
|
||||
description: 'Change the slug or edit the existing item.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe to publish
|
||||
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
|
||||
```
|
||||
|
||||
**Skip the check in edit mode** — when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
|
||||
|
||||
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: nostr-queries
|
||||
description: Query Nostr events efficiently with useNostr + TanStack Query. Covers the standard useQuery pattern, combining related kinds into a single request to avoid rate limiting, and validating events with required tags or strict schemas.
|
||||
---
|
||||
|
||||
# Querying Nostr Events
|
||||
|
||||
Use this skill when building a hook that fetches Nostr events. Covers the standard `useNostr` + `useQuery` pattern, efficient query design (combining kinds to avoid relay round-trips), and event validation for kinds with required tags.
|
||||
|
||||
## The Standard Pattern
|
||||
|
||||
Combine `useNostr` with TanStack Query in a custom hook. Pass the abort signal from `c.signal` into `nostr.query` so cancelled queries free relay resources:
|
||||
|
||||
```typescript
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function usePosts() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: async (c) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [1], limit: 20 }],
|
||||
{ signal: c.signal },
|
||||
);
|
||||
return events;
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Transform events into a domain model inside the `queryFn` if needed — callers should rarely see raw `NostrEvent`s. Multiple calls to `nostr.query()` inside one `queryFn` are fine for compound queries that can't be expressed as a single filter.
|
||||
|
||||
## Efficient Query Design
|
||||
|
||||
**Always minimize the number of separate round-trips** to relays. Each query consumes relay capacity and may count against rate limits.
|
||||
|
||||
**✅ Efficient — single query with multiple kinds:**
|
||||
|
||||
```typescript
|
||||
// Query repost variants in one request
|
||||
const events = await nostr.query([{
|
||||
kinds: [1, 6, 16],
|
||||
'#e': [eventId],
|
||||
limit: 150,
|
||||
}]);
|
||||
|
||||
// Separate by kind in JavaScript
|
||||
const notes = events.filter((e) => e.kind === 1);
|
||||
const reposts = events.filter((e) => e.kind === 6);
|
||||
const genericReposts = events.filter((e) => e.kind === 16);
|
||||
```
|
||||
|
||||
**❌ Inefficient — three separate round-trips:**
|
||||
|
||||
```typescript
|
||||
const [notes, reposts, genericReposts] = await Promise.all([
|
||||
nostr.query([{ kinds: [1], '#e': [eventId] }]),
|
||||
nostr.query([{ kinds: [6], '#e': [eventId] }]),
|
||||
nostr.query([{ kinds: [16], '#e': [eventId] }]),
|
||||
]);
|
||||
```
|
||||
|
||||
### Optimization rules
|
||||
|
||||
1. **Combine kinds** into one filter: `kinds: [1, 6, 16]`.
|
||||
2. **Use multiple filter objects** in a single `nostr.query()` call when different tag filters are needed simultaneously.
|
||||
3. **Raise the `limit`** when combining kinds so you still receive enough of each type.
|
||||
4. **Split by kind in JavaScript**, not by making separate requests.
|
||||
5. **Respect relay capacity** — heavy parallel queries can trigger rate limits even when each individually would be fine.
|
||||
|
||||
## Event Validation
|
||||
|
||||
For kinds with required tags or strict schemas (most custom kinds, anything beyond kind 1), filter query results through a validator before returning them. Loose kinds (kind 1 text notes) rarely need validation — all tags are optional and `content` is freeform.
|
||||
|
||||
```typescript
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
// Example validator for NIP-52 calendar events
|
||||
function validateCalendarEvent(event: NostrEvent): boolean {
|
||||
if (![31922, 31923].includes(event.kind)) return false;
|
||||
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
const title = event.tags.find(([n]) => n === 'title')?.[1];
|
||||
const start = event.tags.find(([n]) => n === 'start')?.[1];
|
||||
if (!d || !title || !start) return false;
|
||||
|
||||
// Date-based events require YYYY-MM-DD
|
||||
if (event.kind === 31922 && !/^\d{4}-\d{2}-\d{2}$/.test(start)) return false;
|
||||
|
||||
// Time-based events require a unix timestamp
|
||||
if (event.kind === 31923) {
|
||||
const ts = parseInt(start);
|
||||
if (isNaN(ts) || ts <= 0) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function useCalendarEvents() {
|
||||
const { nostr } = useNostr();
|
||||
return useQuery({
|
||||
queryKey: ['calendar-events'],
|
||||
queryFn: async (c) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [31922, 31923], limit: 20 }],
|
||||
{ signal: c.signal },
|
||||
);
|
||||
return events.filter(validateCalendarEvent);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Validation is a correctness layer, not a security layer. For trust-sensitive queries (admin actions, addressable events, moderator approvals), also constrain `authors` — see the `nostr-security` skill.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: nostr-relay-pools
|
||||
description: Query or publish to specific Nostr relays or curated relay groups using nostr.relay() and nostr.group(), instead of the default connection pool. Useful for debugging, testing, specialized relays, or geographically-targeted publishing.
|
||||
---
|
||||
|
||||
# Targeted Nostr Relay Connections
|
||||
|
||||
By default, the `nostr` object returned from `useNostr` uses the app's connection pool: it reads from one of the configured relays and publishes to all of them. For most features this is exactly what you want.
|
||||
|
||||
Use this skill when you need **more granular control** — talking to a single relay, a curated group of relays, or debugging a specific relay's behavior.
|
||||
|
||||
## Single Relay: `nostr.relay(url)`
|
||||
|
||||
```ts
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
function useSpecificRelay() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Connect to a specific relay
|
||||
const relay = nostr.relay('wss://relay.damus.io');
|
||||
|
||||
// Query from this relay only
|
||||
const events = await relay.query([{ kinds: [1], limit: 15 }]);
|
||||
|
||||
// Publish to this relay only
|
||||
await relay.event({ kind: 1, content: 'Hello from a specific relay!' });
|
||||
}
|
||||
```
|
||||
|
||||
**Good fits:**
|
||||
|
||||
- Testing a relay's behavior in isolation
|
||||
- Debugging connectivity or rate-limiting issues
|
||||
- Querying content that only lives on a specialized relay (paid relays, private relays, niche communities)
|
||||
- Health checks / admin tooling
|
||||
|
||||
## Relay Group: `nostr.group(urls)`
|
||||
|
||||
```ts
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
function useRelayGroup() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Create a group of specific relays
|
||||
const relayGroup = nostr.group([
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://nos.lol',
|
||||
]);
|
||||
|
||||
// Query from all relays in the group (deduplicated)
|
||||
const events = await relayGroup.query([{ kinds: [1], limit: 15 }]);
|
||||
|
||||
// Publish to all relays in the group
|
||||
await relayGroup.event({ kind: 1, content: 'Hello from a relay group!' });
|
||||
}
|
||||
```
|
||||
|
||||
**Good fits:**
|
||||
|
||||
- Publishing to a curated set of trusted relays for a specific feature
|
||||
- Community-scoped queries (e.g. a set of relays known to host a particular topic)
|
||||
- Geographic/region-targeted delivery
|
||||
- Load-balancing reads across a known-good subset
|
||||
|
||||
## API Consistency
|
||||
|
||||
Both the `relay` object and the `group` object expose the **same interface** as the top-level `nostr` object:
|
||||
|
||||
- `.query(filters, opts?)` — request events matching filters
|
||||
- `.req(filters, opts?)` — open a streaming subscription
|
||||
- `.event(event)` — publish a signed event
|
||||
- All other Nostrify methods
|
||||
|
||||
This means you can drop them into any existing hook or helper that expects a `nostr`-shaped object.
|
||||
|
||||
## Choosing Between Pool, Group, and Single Relay
|
||||
|
||||
| Scenario | Use |
|
||||
|----------------------------------------------------|---------------------|
|
||||
| Default app queries, best reach for publishing | `nostr` (pool) |
|
||||
| Trusted subset, community-specific publishing | `nostr.group([…])` |
|
||||
| Single-relay debugging or specialized relay access | `nostr.relay(url)` |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Don't hard-code user-facing relay lists.** If a feature should publish to "the user's write relays", read from `AppContext.config.relayMetadata` (NIP-65) instead of hard-coding URLs.
|
||||
- **Compose with TanStack Query.** Wrap `relay.query(...)` / `group.query(...)` inside a `useQuery` hook exactly as you would with the default `nostr` object; the caching layer is identical.
|
||||
- **Handle unreachable relays.** Specific relays can be offline, rate-limited, or slow. Always wrap calls in `try/catch` and respect the abort signal from the query function (`c.signal`).
|
||||
- **Avoid leaking subscriptions.** When using `.req(...)` for streaming, always close the subscription on unmount (`controller.abort()` or the returned disposer).
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: nostr-security
|
||||
description: Threat model and defenses for Ditto as a web Nostr client — why XSS is catastrophic when nsec keys live in localStorage, CSP as defense-in-depth, URL and CSS sanitization for untrusted event data, and author filtering for trust-sensitive queries (admin actions, moderators, addressable events, NIP-72 communities). Load when building trust-boundary features, rendering user-controlled URLs or markup, interpolating event data into CSS, or reviewing the app's security posture.
|
||||
---
|
||||
|
||||
# Nostr Security
|
||||
|
||||
## Threat model
|
||||
|
||||
**Nostr private keys (`nsec`) are stored in plaintext in `localStorage`.** Any JavaScript running on the origin can read them with `localStorage.getItem('nostr-login')`. A successful XSS = instant, silent, irreversible key theft — no rotation, no revocation, permanent impersonation across every Nostr client the user ever touches. External signers (NIP-07 extension, NIP-46 bunker) don't change this: an XSS can still ask the active signer to sign arbitrary events, drain funds via zaps, or scrape DMs as they decrypt.
|
||||
|
||||
**Treat every piece of untrusted data as a script-injection vector** — event tags, `content`, metadata, URL params, relay responses.
|
||||
|
||||
## Defense-in-depth
|
||||
|
||||
**Content Security Policy.** `index.html` ships a restrictive CSP: `default-src 'none'`, `script-src 'self'` (no inline scripts, no `eval`), `base-uri 'self'`, `connect-src 'self' https: wss:`. The one intentional gap is `style-src 'unsafe-inline'` — required by Tailwind/shadcn — which means **CSS injection is not blocked by CSP; sanitization is on you**. When modifying CSP, only narrow it. Never add `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcard sources.
|
||||
|
||||
**Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. React's JSX auto-escapes interpolated strings — the moment you bypass that, CSP alone won't save you. If you must render HTML from event data, pipe it through a strict allowlist sanitizer (DOMPurify, already installed) at the parse layer.
|
||||
|
||||
**Sanitize URLs and CSS values** — see §1 and §2.
|
||||
|
||||
## 1. URL sanitization
|
||||
|
||||
Any URL from event tags, `content`, metadata fields (`picture`, `banner`, `website`, `nip05`, etc.), or relay hints is untrusted. Threats beyond `javascript:` XSS: `data:` resource exhaustion / phishing, `http://` IP leaks, relative paths triggering same-origin requests, malformed strings crashing downstream parsers.
|
||||
|
||||
**Use the shipped helper at `src/lib/sanitizeUrl.ts`:**
|
||||
|
||||
```ts
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// Single URL — returns the normalised href, or undefined if not valid https
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
if (url) {
|
||||
// safe to use in any context
|
||||
}
|
||||
|
||||
// Array of URLs — filter out invalid entries
|
||||
const links = getAllTags(event.tags, 'r')
|
||||
.map(([, v]) => sanitizeUrl(v))
|
||||
.filter((v): v is string => !!v);
|
||||
```
|
||||
|
||||
`sanitizeUrl` returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
|
||||
|
||||
**Sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
|
||||
|
||||
**When sanitization is NOT required:** URLs matched by a regex that constrains the protocol (e.g. `NoteContent`'s tokenizer matching `https?://...` — the regex *is* the sanitizer), hardcoded/app-generated URLs (relay configs, internal routes), and strings rendered as plain text that never land in an attribute, CSS value, or network request.
|
||||
|
||||
## 2. CSS injection
|
||||
|
||||
Event data interpolated into CSS (a `<style>` element, `style=""`, or an injected stylesheet) is a CSS injection vector. A `"`, `)`, `}`, or `;` in the value can break out of the string context and inject rules — overlay phishing, hide UI, exfiltrate via `background-image: url()` requests.
|
||||
|
||||
Common surfaces: `background-image: url("${url}")`, `font-family: "${family}"`, `@font-face { src: url("${url}") }`.
|
||||
|
||||
**Mitigation:**
|
||||
|
||||
- **URLs in `url()`** — use `sanitizeUrl()`. The `URL` constructor percent-encodes `"`, `)`, `\` and rejects non-`https:`. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
|
||||
- **Non-URL strings** (font-family, animation names) — use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which allowlists Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, and periods:
|
||||
|
||||
```ts
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
|
||||
// ❌ UNSAFE
|
||||
style.textContent = `body { background-image: url("${rawUrl}"); font-family: "${rawFamily}"; }`;
|
||||
|
||||
// ✅ SAFE — validate URLs, allowlist identifiers
|
||||
const bgUrl = sanitizeUrl(rawUrl);
|
||||
const family = sanitizeCssString(rawFamily ?? '');
|
||||
if (bgUrl && family) {
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); font-family: "${family}"; }`;
|
||||
}
|
||||
```
|
||||
|
||||
If you can't justify the exact characters you're allowing, the policy is wrong.
|
||||
|
||||
## 3. Author filtering for trust-sensitive queries
|
||||
|
||||
Even with perfect XSS defenses, an attacker can publish forged events your UI will trust unless queries constrain `authors`. Relays are dumb pipes — any matching event comes back.
|
||||
|
||||
**Filter by `authors` when:**
|
||||
|
||||
- Querying admin/moderator/owner events — use a hardcoded trusted-pubkey list (e.g. `ADMIN_PUBKEYS` from `src/lib/admins`).
|
||||
- Querying addressable events (kinds 30000–39999) — the `d` tag alone is not a trust boundary; the `(kind, pubkey, d)` triple is.
|
||||
- Querying user-owned replaceable events (profile metadata, relay lists, mute lists) — `authors: [userPubkey]`.
|
||||
|
||||
**Do NOT filter by `authors`** for public UGC (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design.
|
||||
|
||||
```ts
|
||||
// ❌ Anyone can publish kind 30078 with this d-tag and self-appoint
|
||||
nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
|
||||
// ✅ Only trust the admin list
|
||||
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
```
|
||||
|
||||
**Routes for addressable/replaceable events must include the author** — otherwise the route handler can't construct a secure filter:
|
||||
|
||||
```tsx
|
||||
// ❌ Any pubkey can squat the slug
|
||||
<Route path="/article/:slug" element={<Article />} />
|
||||
// ✅ Filter can include authors
|
||||
<Route path="/article/:npub/:slug" element={<Article />} />
|
||||
```
|
||||
|
||||
### NIP-72 community moderation
|
||||
|
||||
Kind 4550 approvals are only trustworthy if signed by a moderator from the community definition (kind 34550). Two-step query:
|
||||
|
||||
```ts
|
||||
// 1. Fetch community definition — author-filter by the owner.
|
||||
const [community] = await nostr.query([{
|
||||
kinds: [34550], authors: [communityOwnerPubkey], '#d': [communityId], limit: 1,
|
||||
}]);
|
||||
if (!community) return [];
|
||||
|
||||
// 2. Extract moderator pubkeys from `p` tags with role "moderator".
|
||||
const moderators = community.tags
|
||||
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
|
||||
.map(([, pubkey]) => pubkey);
|
||||
|
||||
// 3. Query approvals — only from moderators.
|
||||
const approvals = await nostr.query([{
|
||||
kinds: [4550],
|
||||
authors: moderators,
|
||||
'#a': [`34550:${communityOwnerPubkey}:${communityId}`],
|
||||
limit: 100,
|
||||
}]);
|
||||
```
|
||||
|
||||
Without step 3's `authors` filter, anyone can publish a kind 4550 "approval".
|
||||
|
||||
## Pre-merge checklist
|
||||
|
||||
- [ ] No `dangerouslySetInnerHTML` / `innerHTML` / `document.write` with untrusted data.
|
||||
- [ ] CSP unchanged or narrowed; no new `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcards.
|
||||
- [ ] Every event-sourced URL passes `sanitizeUrl()` before reaching `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS.
|
||||
- [ ] Every event-sourced string in CSS passes `sanitizeUrl()` (URLs) or `sanitizeCssString()` (identifiers).
|
||||
- [ ] Every trust-sensitive query includes `authors`.
|
||||
- [ ] Routes for addressable/replaceable events carry the author in the URL.
|
||||
@@ -87,6 +87,8 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
```markdown
|
||||
## [X.Y.Z] - YYYY-MM-DD
|
||||
|
||||
A short single-paragraph summary of this release written in plain prose -- max 500 characters. This appears on the App Store, Google Play, and the in-app "what's new" toast.
|
||||
|
||||
### Added
|
||||
- Description of new features
|
||||
|
||||
@@ -100,7 +102,100 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Description of removed features
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
#### The Summary Paragraph
|
||||
|
||||
Every release section MUST start with a single plaintext paragraph (not a bullet, not a heading) that summarises the release for app-store-style audiences:
|
||||
|
||||
- **Single paragraph, plain prose.** No bullets, no headings, no Markdown formatting beyond plain text.
|
||||
- **Max ~500 characters.** Apple App Store and Google Play both cap "What's new" text at 500. The CI `release-notes` job warns when the summary is longer.
|
||||
- **Audience: end users discovering the update.** Describe the most noticeable user-visible changes; omit internal cleanups even if they're in the bullets below.
|
||||
- **Tone matches the bullets.** Present-tense, no Nostr jargon, no NIP/kind numbers (see Rules below).
|
||||
- **Maintenance releases** -- write a one-sentence summary like `A behind-the-scenes maintenance release with no user-facing changes.` Don't leave it blank; the CI fallback `Ditto vX.Y.Z` is a last resort for legacy entries, not new ones.
|
||||
|
||||
The same paragraph is used in three places automatically:
|
||||
- **App Store** -- "What's New in This Version" via fastlane `deliver`
|
||||
- **Google Play** -- "What's new in this version" via fastlane `supply` `metadata/android/<lang>/changelogs/<versionCode>.txt`
|
||||
- **In-app toast** -- the `What's new in vX.Y.Z` toast that fires when users load a new version (see `src/components/VersionCheck.tsx`)
|
||||
- The full section (summary + lists) goes into the GitLab Release description.
|
||||
|
||||
Extraction is handled by `scripts/extract-release-notes.mjs`; you don't have to write store-specific copy.
|
||||
|
||||
#### Changelog Quality Checklist
|
||||
|
||||
Before drafting any entries, run through this checklist. It is NOT optional -- skipping steps here is the most common way a release goes out with misleading notes.
|
||||
|
||||
##### 5.1. Diff the code, not just the commit log
|
||||
|
||||
Commit messages describe intent at the moment of commit; they over- and under-represent the cumulative effect at release time. Before drafting entries, **run a real diff** for each area of substantial change:
|
||||
|
||||
```bash
|
||||
# Full diff between tags
|
||||
git diff v<prev>..HEAD
|
||||
|
||||
# Or narrowed to an area you're unsure about
|
||||
git diff v<prev>..HEAD -- src/components/ComposeBox.tsx
|
||||
```
|
||||
|
||||
Only the diff reveals intra-release churn (commits that cancel each other out, bugs introduced and then fixed, refactors that land and get reverted). Reading commit messages alone is insufficient.
|
||||
|
||||
##### 5.2. Trace every candidate "Fixed" entry to its origin commit
|
||||
|
||||
For each bug fix you're considering listing, find the commit that introduced the bug.
|
||||
|
||||
**Fast path -- check for `Regression-of:` trailers** (see AGENTS.md "Attributing Regressions"). If the fix commit declares its origin in a trailer, you don't need to hunt:
|
||||
|
||||
```bash
|
||||
# List all commits in the release window with their Regression-of trailers (if any)
|
||||
git log v<prev>..HEAD --no-merges \
|
||||
--format='%h %s%n Regression-of: %(trailers:key=Regression-of,valueonly,separator=%x20)'
|
||||
```
|
||||
|
||||
For each `Regression-of: <sha>` entry, check whether `<sha>` is also in the release window:
|
||||
|
||||
```bash
|
||||
# Returns 0 if <sha> is BEFORE v<prev> (pre-existing bug -> legit "Fixed" entry)
|
||||
# Returns non-zero if <sha> is AFTER v<prev> (intra-release -> omit from "Fixed")
|
||||
git merge-base --is-ancestor <sha> v<prev>
|
||||
```
|
||||
|
||||
**Fallback -- manual tracing** (when no trailer is present):
|
||||
|
||||
```bash
|
||||
# Show the history of a file across all commits
|
||||
git log --oneline v<prev>..HEAD -- path/to/file.tsx
|
||||
|
||||
# Or blame the specific lines the fix touched
|
||||
git blame -L <start>,<end> -- path/to/file.tsx
|
||||
```
|
||||
|
||||
**If the introducing commit is also in this release window (i.e. after the previous tag), the bug is intra-release.** The user on the previous version never experienced it. Do NOT list it as a "Fixed" entry. Fold it into the relevant "Added" or "Changed" entry, or omit it entirely.
|
||||
|
||||
##### 5.3. The "Would a user on the previous version notice this?" test
|
||||
|
||||
The changelog describes the delta between the previous release and this one **from the user's perspective** -- not the development history. Before writing each entry, ask:
|
||||
|
||||
> "Did a user on the previous published version experience this exact thing?"
|
||||
|
||||
- If they experienced a broken state that is now fixed: **"Fixed" entry**
|
||||
- If they experienced the old behavior and now see new behavior: **"Changed" or "Added" entry**
|
||||
- If they never saw either state (introduced AND resolved within this release window): **omit entirely**
|
||||
|
||||
This applies to more than just bugs:
|
||||
- A feature added and then reverted in the same release: omit both
|
||||
- A refactor that was done and then undone: omit both
|
||||
- A performance regression introduced and then fixed: omit both
|
||||
- A typo introduced in a new string and then corrected: mention the new string (if user-facing) as a single "Added"/"Changed" entry, with no "Fixed" entry
|
||||
|
||||
##### 5.4. Worked example -- intra-release bug
|
||||
|
||||
> **Scenario:** Commit A overhauls the compose box and, as a side effect, breaks the background of the expanded emoji picker. Commit B, later in the same release window, restores the background.
|
||||
>
|
||||
> **Correct changelog:** One "Added" entry describing the compose box overhaul. The emoji picker background is part of the finished state the user receives.
|
||||
>
|
||||
> **Incorrect changelog:** An "Added" entry for the overhaul AND a "Fixed" entry for the emoji picker background. The user on the previous version never saw the broken background; listing it invents a problem they didn't have and makes the release notes read like a developer changelog.
|
||||
|
||||
#### Rules
|
||||
|
||||
- Only include categories that have entries (omit empty categories)
|
||||
- Write **user-facing descriptions**, not raw commit messages
|
||||
- Keep descriptions concise -- one line per change
|
||||
@@ -109,9 +204,9 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Focus on what the user sees/experiences, not internal implementation details
|
||||
- Use the current date in YYYY-MM-DD format
|
||||
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
|
||||
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Only ship what the user sees.** If a bug was introduced AND fixed within this release, the user never saw it -- omit the fix entirely (or fold the net result into the relevant Added/Changed entry). The same applies to features that were added and reverted, refactors that cancel out, and any other intra-release churn. See the Changelog Quality Checklist above (especially 5.2 and 5.3) for the procedure to verify this.
|
||||
- **Collapse related work into one entry.** If a feature was added and then tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
|
||||
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
|
||||
|
||||
### Step 6: Update Version in All Files
|
||||
|
||||
@@ -190,8 +285,12 @@ git push origin main vX.Y.Z
|
||||
|
||||
This triggers the GitLab CI pipeline which will:
|
||||
1. Build a signed Android APK and AAB
|
||||
2. Create a GitLab Release with download links
|
||||
3. Publish the APK to Zapstore
|
||||
2. Build a signed iOS IPA on the self-hosted Mac runner
|
||||
3. Extract release notes (full body + summary paragraph) from `CHANGELOG.md`
|
||||
4. Create a GitLab Release with APK / AAB / IPA download links
|
||||
5. Publish the APK to Zapstore
|
||||
6. Publish the AAB to Google Play (production track) with the summary as the "What's new" text
|
||||
7. Submit the iOS IPA to App Store Connect for review with the summary as the "What's New" text
|
||||
|
||||
### Step 12: Confirm
|
||||
|
||||
@@ -212,11 +311,15 @@ After pushing, inform the user:
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs three jobs:
|
||||
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs seven jobs:
|
||||
|
||||
1. **build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build
|
||||
2. **release**: Creates a GitLab Release with the changelog content and download links
|
||||
3. **publish-zapstore**: Publishes the APK to Zapstore
|
||||
2. **build-ipa**: Builds the signed App Store IPA on the self-hosted Mac runner (`tags: [macos]`); stamps `MARKETING_VERSION` and `CFBundleVersion` into the Xcode project. The IPA is uploaded to GitLab's Generic Packages registry and exposed as a CI artifact for downstream jobs
|
||||
3. **release-notes**: Extracts the version's changelog section and summary paragraph from `CHANGELOG.md` into two artifacts (`release-notes.md` and `release-notes-summary.txt`) consumed by `release`, `publish-app-store`, and `publish-google-play`
|
||||
4. **release**: Creates a GitLab Release with the full changelog section and APK / AAB / IPA download links
|
||||
5. **publish-zapstore**: Publishes the APK to Zapstore
|
||||
6. **publish-google-play**: Uploads the AAB to Google Play production track and writes the release summary to `metadata/android/en-US/changelogs/<versionCode>.txt`
|
||||
7. **publish-app-store**: Submits the prebuilt IPA to App Store Connect for review with the release summary as the "What's New" text. Runs on the self-hosted Mac runner (`tags: [macos]`) because `fastlane deliver` shells out to Apple's iTMSTransporter to upload the IPA, and that tool only ships inside Xcode — the previous Linux runner crashed at the upload step with `No such file or directory @ dir_chdir0` because `Helper.itms_path` resolved to a missing Xcode path. The build appears in App Store Connect within ~30 minutes; Apple's human review then takes 24-48 hours typically. Once approved, you must release manually in App Store Connect (`automatic_release: false`) — this is the final human gate. For runner operations, match cert rotation, and debugging, load the **`mac-runner`** skill.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: testing
|
||||
description: Write Vitest unit tests for React components and hooks using the project's `TestApp` wrapper, jsdom environment, and pre-mocked browser APIs (localStorage, matchMedia, scrollTo, IntersectionObserver, ResizeObserver). Also covers the project policy on when to create new test files.
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
Load this skill when the user asks you to write a test, diagnose a bug with a test, or add coverage for a component/hook. Running the existing test script is a standing requirement (see `AGENTS.md` → *Validating Your Changes*) and doesn't require this skill.
|
||||
|
||||
## Policy: when to create new test files
|
||||
|
||||
**Do not create new test files unless one of these applies:**
|
||||
|
||||
1. The user explicitly asks for tests.
|
||||
2. The user describes a specific bug and asks for tests to diagnose it.
|
||||
3. The user says a problem persists after you tried to fix it.
|
||||
|
||||
Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature. The request must come from the user.
|
||||
|
||||
If none of the above apply, stop — don't create a test file. Keep running the existing test script as usual.
|
||||
|
||||
## Test setup
|
||||
|
||||
The project uses **Vitest + jsdom** with **React Testing Library** and **jest-dom** matchers. Global setup lives in `src/test/setup.ts` and mocks these browser APIs that jsdom doesn't provide (or that Node's built-ins conflict with):
|
||||
|
||||
- `localStorage` — a Map-backed mock, because Node 22's built-in `localStorage` lacks the Web Storage API surface jsdom expects
|
||||
- `window.matchMedia`
|
||||
- `window.scrollTo`
|
||||
- `IntersectionObserver`
|
||||
- `ResizeObserver`
|
||||
|
||||
If your component needs another browser API, extend `src/test/setup.ts` rather than mocking per-file.
|
||||
|
||||
## Writing a component test
|
||||
|
||||
Wrap rendered components in `TestApp` (`src/test/TestApp.tsx`) so all context providers — `UnheadProvider`, `AppProvider`, `QueryClientProvider`, `NostrLoginProvider`, `NostrProvider`, `BrowserRouter`, etc. — are available. Without it, hooks like `useQuery`, `useNostr`, `useAppContext`, or `useNavigate` will throw.
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import { MyComponent } from './MyComponent';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<TestApp><MyComponent /></TestApp>);
|
||||
expect(screen.getByText('Expected text')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Writing a hook test
|
||||
|
||||
Use `renderHook` from `@testing-library/react` and pass `TestApp` as the `wrapper`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import { useMyHook } from './useMyHook';
|
||||
|
||||
describe('useMyHook', () => {
|
||||
it('returns expected data', async () => {
|
||||
const { result } = renderHook(() => useMyHook(), { wrapper: TestApp });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Files placed next to the code under test with the `.test.ts` / `.test.tsx` suffix are picked up automatically. For reference, see `src/test/ErrorBoundary.test.tsx`.
|
||||
|
||||
## Running tests
|
||||
|
||||
The `npm test` script runs `tsc --noEmit`, `eslint`, `vitest run`, and `vite build` in sequence. Always run it after changes — a passing test file alone doesn't mean your task is done.
|
||||
|
||||
For fast iteration, run just Vitest:
|
||||
|
||||
```bash
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Or in watch mode while editing:
|
||||
|
||||
```bash
|
||||
npx vitest
|
||||
```
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: theming
|
||||
description: Customize Ditto's visual design — install Google Fonts via @fontsource, change the color scheme, configure light/dark themes, and apply consistent component styling patterns with Tailwind and CSS variables.
|
||||
---
|
||||
|
||||
# Theming, Fonts, and Color Schemes
|
||||
|
||||
Use this skill when the user wants to change fonts, colors, light/dark appearance, or general visual styling. Ditto ships with a light/dark theme system built on CSS custom properties and Tailwind v3, plus a `useTheme` hook for runtime switching.
|
||||
|
||||
## Adding Fonts
|
||||
|
||||
Any Google Font can be installed via the `@fontsource` / `@fontsource-variable` packages.
|
||||
|
||||
1. **Install the font package.** Prefer the variable version when available.
|
||||
```bash
|
||||
npm install @fontsource-variable/inter
|
||||
```
|
||||
Package naming:
|
||||
- `@fontsource-variable/<font-name>` — variable fonts (preferred; one file, all weights)
|
||||
- `@fontsource/<font-name>` — static fonts
|
||||
|
||||
2. **Import the font once** in `src/main.tsx`:
|
||||
```ts
|
||||
import '@fontsource-variable/inter';
|
||||
```
|
||||
|
||||
3. **Register the family** in `tailwind.config.ts`:
|
||||
```ts
|
||||
export default {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Suggested families by use case
|
||||
|
||||
- **Modern / Clean:** Inter Variable, Outfit Variable, Manrope
|
||||
- **Professional / Corporate:** Roboto, Open Sans, Source Sans Pro
|
||||
- **Creative / Artistic:** Poppins, Nunito, Comfortaa
|
||||
- **Monospace / Code:** JetBrains Mono, Fira Code, Source Code Pro
|
||||
|
||||
For expressive hierarchies, pair a sans body font with a display/serif heading font (e.g. Inter + Playfair Display) and expose the second family as `fontFamily.serif` or `fontFamily.display` in Tailwind.
|
||||
|
||||
### Runtime font loading from Nostr events
|
||||
|
||||
Ditto also supports loading fonts referenced from Nostr events (theme events, letter stationery, etc.) through `src/lib/fontLoader.ts`. That path is separate from the build-time `@fontsource` approach — it constructs `@font-face` rules at runtime from sanitized URLs. Never feed event data through the `@fontsource` path; always go through `fontLoader` so the URL and family name are passed through `sanitizeUrl()` and `sanitizeCssString()` (see the `nostr-security` skill).
|
||||
|
||||
## Color Schemes
|
||||
|
||||
Colors are defined as CSS custom properties in `src/index.css` under two selectors:
|
||||
|
||||
- `:root` — light-mode values
|
||||
- `.dark` — dark-mode overrides
|
||||
|
||||
When the user requests a new color scheme:
|
||||
|
||||
1. **Update both `:root` and `.dark`** in `src/index.css`. Each variable is an HSL triplet (no `hsl()` wrapper), e.g. `--primary: 222 47% 11%;`.
|
||||
2. **Keep contrast ratios ≥ 4.5:1** for body text and interactive elements. Test both modes.
|
||||
3. **Prefer extending Tailwind's palette** (`tailwind.config.ts`) over hard-coding hex values in components — this keeps the theme consistent and dark-mode-friendly.
|
||||
4. **Apply colors through semantic tokens** (`bg-primary`, `text-muted-foreground`, `border-input`) rather than raw palette names when possible, so future theme changes propagate.
|
||||
|
||||
The shadcn/ui components consume these semantic tokens, so changing the variables automatically restyles the entire component library.
|
||||
|
||||
## Light/Dark Theme Switching
|
||||
|
||||
Ditto includes:
|
||||
|
||||
- **`useTheme` hook** (`src/hooks/useTheme.ts`) — read and set the current theme programmatically.
|
||||
- **CSS custom properties** in `src/index.css` — one set in `:root`, dark overrides in `.dark`.
|
||||
- **Automatic persistence** via the `AppContext` config (`config.theme`), saved to local storage.
|
||||
|
||||
To add a theme toggle:
|
||||
|
||||
```tsx
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Component Styling Patterns
|
||||
|
||||
- **Class merging:** use the `cn()` utility (`@/lib/utils`) to combine conditional classes and override defaults without class-order bugs.
|
||||
- **Variants:** follow shadcn/ui's `class-variance-authority` pattern for component variants (`variant`, `size`). Copy an existing `ui/` component as a template.
|
||||
- **Responsive design:** lean on Tailwind breakpoints (`sm:`, `md:`, `lg:`) rather than JS media queries. Use `useIsMobile` only when layout must change based on JS-measured viewport.
|
||||
- **Interactive states:** always define `hover:`, `focus-visible:`, and `disabled:` states for clickable elements. Focus rings should use `ring-ring` / `ring-offset-background` so they pick up theme colors.
|
||||
- **Spacing:** an 8px grid (Tailwind's default 4-based scale) keeps visual rhythm consistent. Common paddings: `p-4`, `p-6`; gaps: `gap-2`, `gap-4`.
|
||||
- **Depth:** soft shadows (`shadow-sm`, `shadow-md`), subtle gradients, and `rounded-lg` / `rounded-xl` corners match Ditto's aesthetic. Avoid heavy drop shadows.
|
||||
|
||||
### Negative z-index gotcha
|
||||
|
||||
When placing decorative elements behind content with `-z-10` (e.g. blurred background gradients), **add `isolate` to the parent container**. Without `isolate`, the negative z-index escapes the local stacking context and the element disappears behind the page's background color.
|
||||
|
||||
```tsx
|
||||
<section className="relative isolate">
|
||||
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-primary/20 to-transparent" />
|
||||
{/* content */}
|
||||
</section>
|
||||
```
|
||||
|
||||
## Design Quality Checklist
|
||||
|
||||
Before finishing a visual change, verify:
|
||||
|
||||
- [ ] Both light and dark modes look correct — no hard-coded colors, all text readable.
|
||||
- [ ] Contrast ratios meet WCAG AA (≥ 4.5:1 for body, ≥ 3:1 for large text).
|
||||
- [ ] Interactive elements have visible `hover`, `focus-visible`, and `disabled` states.
|
||||
- [ ] Layout is responsive down to ~360px width without horizontal scroll.
|
||||
- [ ] Animations respect `prefers-reduced-motion` (Tailwind: `motion-safe:` / `motion-reduce:`).
|
||||
- [ ] Spacing is consistent — no one-off `p-[13px]` style values.
|
||||
@@ -3,5 +3,9 @@ VITE_PLAUSIBLE_DOMAIN="example.tld"
|
||||
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
|
||||
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
|
||||
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://agora.spot"
|
||||
VITE_SHARE_ORIGIN=""
|
||||
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
|
||||
# ALLOWED_HOSTS="*"
|
||||
@@ -11,6 +11,8 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.eslintcache
|
||||
.tsbuildinfo
|
||||
yarn.lock
|
||||
deploy.sh
|
||||
|
||||
|
||||
@@ -26,6 +26,38 @@ 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
|
||||
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,21 +93,38 @@ deploy-nsite:
|
||||
--use-fallback-relays
|
||||
--use-fallback-servers
|
||||
|
||||
build-web:
|
||||
release-notes:
|
||||
stage: build
|
||||
timeout: 10 minutes
|
||||
timeout: 2 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
- npm ci
|
||||
- npm run build
|
||||
- cp dist/index.html dist/404.html
|
||||
# Extract release notes from CHANGELOG.md for this tag.
|
||||
# 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 "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
|
||||
- echo "--- release-notes.md ---"
|
||||
- cat artifacts/release-notes.md
|
||||
- echo "--- release-notes-summary.txt (length $(wc -c < artifacts/release-notes-summary.txt)) ---"
|
||||
- cat artifacts/release-notes-summary.txt
|
||||
- echo "------------------------"
|
||||
# Warn (don't fail) when the summary exceeds the documented 500-character
|
||||
# limit so the user spots it before App Store / Play Store reject the upload.
|
||||
- |
|
||||
SUMMARY_LEN=$(wc -c < artifacts/release-notes-summary.txt)
|
||||
if [ "$SUMMARY_LEN" -gt 501 ]; then
|
||||
echo "WARNING: release-notes-summary.txt is $SUMMARY_LEN bytes; convention is <=500."
|
||||
fi
|
||||
artifacts:
|
||||
paths:
|
||||
- dist/
|
||||
- artifacts/release-notes.md
|
||||
- artifacts/release-notes-summary.txt
|
||||
expire_in: 90 days
|
||||
|
||||
build-apk:
|
||||
stage: build
|
||||
@@ -183,28 +232,99 @@ build-apk:
|
||||
- android/.gradle/
|
||||
- .gradle/
|
||||
|
||||
build-ipa:
|
||||
stage: build
|
||||
tags:
|
||||
- macos
|
||||
timeout: 20 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
before_script:
|
||||
# PATH is set up via ~/.bash_profile on the runner host (brew + Ruby 3.3 + user gems)
|
||||
- node --version
|
||||
- ruby --version
|
||||
- fastlane --version | head -3
|
||||
|
||||
# Decode the App Store Connect API key (.p8) into a private location.
|
||||
# The Fastfile reads this directly via File.binread. We pass the API
|
||||
# key into match so it contacts Apple's portal to verify the cert is
|
||||
# still valid for the team — fails fast on a revoked / expired cert.
|
||||
- mkdir -p "$HOME/.private_keys"
|
||||
- chmod 700 "$HOME/.private_keys"
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
# Avoid env-var collision: match's APP_STORE_CONNECT_API_KEY_PATH expects
|
||||
# a JSON descriptor; we pass the API key inline via the Fastfile.
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
|
||||
# Build web assets and sync to Capacitor iOS project
|
||||
- npm ci
|
||||
- npx vite build -l error
|
||||
- cp dist/index.html dist/404.html
|
||||
- npx cap sync ios
|
||||
- node scripts/patch-cap-config.mjs
|
||||
script:
|
||||
# Stamp marketing version from the git tag (e.g. v2.1.0 -> 2.1.0)
|
||||
- VERSION="${CI_COMMIT_TAG#v}"
|
||||
- echo "Building iOS version $VERSION (build ${CI_PIPELINE_IID}) from tag $CI_COMMIT_TAG"
|
||||
- >-
|
||||
/usr/bin/sed -i ''
|
||||
"s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g"
|
||||
ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Run match (cert verify + decrypt) and build_app to produce the IPA.
|
||||
# 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/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/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/Agora.ipa
|
||||
expire_in: 90 days
|
||||
|
||||
release:
|
||||
stage: release
|
||||
image: registry.gitlab.com/gitlab-org/release-cli:latest
|
||||
needs:
|
||||
- build-apk
|
||||
- job: build-apk
|
||||
artifacts: false
|
||||
- job: build-ipa
|
||||
artifacts: false
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
- echo "Creating release for $CI_COMMIT_TAG"
|
||||
# Extract the latest changelog section for the release description.
|
||||
# Reads from "## [version]" to the next "## [" or end of file.
|
||||
- |
|
||||
VERSION="${CI_COMMIT_TAG#v}"
|
||||
RELEASE_NOTES=$(awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md)
|
||||
if [ -z "$RELEASE_NOTES" ]; then
|
||||
RELEASE_NOTES="Agora ${CI_COMMIT_TAG}"
|
||||
fi
|
||||
- echo "$RELEASE_NOTES" > release-notes.md
|
||||
- test -f artifacts/release-notes.md
|
||||
- echo "--- release-notes.md ---"
|
||||
- cat artifacts/release-notes.md
|
||||
- echo "------------------------"
|
||||
release:
|
||||
tag_name: $CI_COMMIT_TAG
|
||||
name: $CI_COMMIT_TAG
|
||||
description: './release-notes.md'
|
||||
description: './artifacts/release-notes.md'
|
||||
assets:
|
||||
links:
|
||||
- name: Agora-${CI_COMMIT_TAG}.apk
|
||||
@@ -213,6 +333,9 @@ 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: 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:
|
||||
stage: publish
|
||||
@@ -244,7 +367,10 @@ publish-google-play:
|
||||
stage: publish
|
||||
image: ruby:3.3
|
||||
needs:
|
||||
- build-apk
|
||||
- job: build-apk
|
||||
artifacts: true
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
@@ -253,18 +379,87 @@ publish-google-play:
|
||||
# Decode base64-encoded service account JSON to a temp file
|
||||
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
|
||||
|
||||
# Upload the AAB to Google Play production track
|
||||
# Build the fastlane supply metadata layout for the changelog.
|
||||
# supply maps changelogs/<versionCode>.txt to the Play Console "What's
|
||||
# new in this version" field. versionCode matches what build-apk stamped
|
||||
# into build.gradle (= CI_PIPELINE_IID).
|
||||
- VERSION_CODE="${CI_PIPELINE_IID}"
|
||||
- CHANGELOG_DIR="android/fastlane/metadata/android/en-US/changelogs"
|
||||
- mkdir -p "$CHANGELOG_DIR"
|
||||
- cp artifacts/release-notes-summary.txt "${CHANGELOG_DIR}/${VERSION_CODE}.txt"
|
||||
- echo "--- ${CHANGELOG_DIR}/${VERSION_CODE}.txt ---"
|
||||
- cat "${CHANGELOG_DIR}/${VERSION_CODE}.txt"
|
||||
- echo "-------------------------------------------"
|
||||
|
||||
# Upload the AAB to Google Play production track with the changelog.
|
||||
- >-
|
||||
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
|
||||
--skip_upload_metadata
|
||||
--skip_upload_changelogs
|
||||
--skip_upload_images
|
||||
--skip_upload_screenshots
|
||||
--skip_upload_apk
|
||||
|
||||
# Clean up
|
||||
- rm -f /tmp/play-service-account.json
|
||||
|
||||
publish-app-store:
|
||||
stage: publish
|
||||
# Runs on the self-hosted Mac runner, same as build-ipa. fastlane's `deliver`
|
||||
# action shells out to Apple's iTMSTransporter / altool to upload the IPA
|
||||
# binary, and those tools ship inside Xcode. On a generic Linux container
|
||||
# the upload step crashes with `No such file or directory @ dir_chdir0`
|
||||
# because `Helper.itms_path` resolves to a path inside Xcode that doesn't
|
||||
# exist. The IPA is already signed in `build-ipa`; we just need an Apple
|
||||
# tool to push it, which means macOS.
|
||||
tags:
|
||||
- macos
|
||||
needs:
|
||||
- job: build-ipa
|
||||
artifacts: true
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
before_script:
|
||||
# PATH is set up via ~/.bash_profile on the runner host (brew + Ruby 3.3 + user gems)
|
||||
- ruby --version
|
||||
- fastlane --version | head -3
|
||||
|
||||
# Decode the App Store Connect API key (.p8) into a private location.
|
||||
# The Fastfile reads this directly via File.binread.
|
||||
- mkdir -p "$HOME/.private_keys"
|
||||
- chmod 700 "$HOME/.private_keys"
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
# Avoid env-var collision: match's APP_STORE_CONNECT_API_KEY_PATH expects
|
||||
# a JSON descriptor; we pass the API key inline via the Fastfile.
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
script:
|
||||
- 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.
|
||||
# Generated by the release-notes job from CHANGELOG.md.
|
||||
- mkdir -p ios/fastlane/metadata/en-US
|
||||
- cp artifacts/release-notes-summary.txt ios/fastlane/metadata/en-US/release_notes.txt
|
||||
- echo "--- release_notes.txt ---"
|
||||
- cat ios/fastlane/metadata/en-US/release_notes.txt
|
||||
- echo "-------------------------"
|
||||
|
||||
# Submit the prebuilt IPA from build-ipa to App Store Connect for review.
|
||||
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Agora.ipa"
|
||||
- cd ios
|
||||
- fastlane submit_release
|
||||
after_script:
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
|
||||
@@ -44,6 +44,8 @@ Read the full "Understanding Agora" section above for the complete vision.
|
||||
|
||||
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
|
||||
|
||||
When the bug was introduced by an identifiable prior commit, add a `Regression-of: <short-sha>` trailer to the bottom of your commit message. See AGENTS.md "Attributing Regressions" for the convention.
|
||||
|
||||
### New features and significant changes
|
||||
|
||||
Every feature MR must link to an existing open issue and clearly align with the "Understanding Agora" section in this file. The philosophy alignment section in the MR template is where you make the case for why your change belongs in Agora. If you can't articulate that clearly, the change probably doesn't belong.
|
||||
@@ -131,6 +133,7 @@ maintain it long-term. For each finding, state the file, line, and issue.
|
||||
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
|
||||
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
|
||||
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
|
||||
- [ ] If this is a bug fix and the offending commit is identifiable, does the commit message include a `Regression-of: <short-sha>` trailer? (See AGENTS.md "Attributing Regressions".)
|
||||
|
||||
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
|
||||
|
||||
|
||||
@@ -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.8.0"
|
||||
versionName "2.14.4"
|
||||
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 * {
|
||||
|
||||
@@ -58,4 +58,6 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<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" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
|
||||
*
|
||||
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
|
||||
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
|
||||
* The React code can serve files identically regardless of platform.
|
||||
*/
|
||||
@CapacitorPlugin(name = "SandboxPlugin")
|
||||
public class SandboxPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "SandboxPlugin";
|
||||
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
@PluginMethod
|
||||
public void create(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
if (sandboxes.containsKey(sandboxId)) {
|
||||
call.reject("Sandbox already exists: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
|
||||
sandboxes.put(sandboxId, sandbox);
|
||||
|
||||
// Add the container (WebView + spinner overlay) on top of the
|
||||
// Capacitor WebView. The parent is a CoordinatorLayout — using
|
||||
// the wrong LayoutParams type causes a ClassCastException when
|
||||
// it intercepts touch events.
|
||||
View capWebView = getBridge().getWebView();
|
||||
ViewGroup parent = (ViewGroup) capWebView.getParent();
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
parent.addView(sandbox.container, params);
|
||||
|
||||
// The spinner is now visible. Navigation is deferred until the
|
||||
// JS layer calls navigate() — this allows the caller to
|
||||
// pre-fetch blobs while the spinner animates.
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void navigate(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void updateFrame(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
sandbox.container.setLayoutParams(params);
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void respondToFetch(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
String requestId = call.getString("requestId");
|
||||
if (requestId == null) {
|
||||
call.reject("Missing required parameter: requestId");
|
||||
return;
|
||||
}
|
||||
JSObject response = call.getObject("response");
|
||||
if (response == null) {
|
||||
call.reject("Missing required parameter: response");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
int status = response.optInt("status", 200);
|
||||
String statusText = response.optString("statusText", "OK");
|
||||
String bodyBase64 = response.optString("body", null);
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
JSONObject headersObj = response.optJSONObject("headers");
|
||||
if (headersObj != null) {
|
||||
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
headers.put(key, headersObj.optString(key));
|
||||
}
|
||||
}
|
||||
|
||||
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void postMessage(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
JSObject message = call.getObject("message");
|
||||
if (message == null) {
|
||||
call.reject("Missing required parameter: message");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void destroy(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.remove(sandboxId);
|
||||
if (sandbox != null) {
|
||||
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.container);
|
||||
}
|
||||
sandbox.webView.destroy();
|
||||
}
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("requestId", requestId);
|
||||
data.put("request", request);
|
||||
notifyListeners("fetch", data);
|
||||
}
|
||||
|
||||
void emitScriptMessage(String sandboxId, JSObject message) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("message", message);
|
||||
notifyListeners("scriptMessage", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single sandboxed WebView instance.
|
||||
*/
|
||||
private static class SandboxInstance {
|
||||
final String id;
|
||||
/** Wrapper layout that holds the WebView and the loading overlay. */
|
||||
final FrameLayout container;
|
||||
final WebView webView;
|
||||
final SandboxPlugin plugin;
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
/** Native spinner overlay, shown while the sandbox content loads. */
|
||||
private ProgressBar spinner;
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
this.plugin = plugin;
|
||||
|
||||
this.container = new FrameLayout(plugin.getActivity());
|
||||
this.webView = new WebView(plugin.getActivity());
|
||||
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setAllowFileAccess(false);
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setDatabaseEnabled(true);
|
||||
|
||||
webView.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
|
||||
// Add JavaScript interface for script->native communication.
|
||||
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
|
||||
|
||||
// Inject the bridge script and intercept requests.
|
||||
webView.setWebViewClient(new SandboxWebViewClient(this));
|
||||
|
||||
// Build the container: WebView fills it, spinner overlays on top.
|
||||
container.addView(webView, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
// Native spinner overlay — uses the Android indeterminate
|
||||
// ProgressBar which animates on the render thread, so it keeps
|
||||
// spinning even when the main/IO threads are busy.
|
||||
spinner = new ProgressBar(plugin.getActivity());
|
||||
spinner.setIndeterminate(true);
|
||||
spinner.getIndeterminateDrawable().setColorFilter(
|
||||
Color.parseColor("#7c5cdc"), PorterDuff.Mode.SRC_IN);
|
||||
FrameLayout.LayoutParams spinnerParams = new FrameLayout.LayoutParams(
|
||||
dpToPx(plugin, 32), dpToPx(plugin, 32), Gravity.CENTER);
|
||||
container.addView(spinner, spinnerParams);
|
||||
|
||||
// Dark background behind the spinner.
|
||||
View overlay = new View(plugin.getActivity());
|
||||
overlay.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
// Insert the overlay between the WebView (index 0) and spinner (index 1)
|
||||
// so it covers the WebView but sits behind the spinner.
|
||||
container.addView(overlay, 1, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
}
|
||||
|
||||
/** Remove the native loading overlay. Safe to call multiple times. */
|
||||
void hideSpinner() {
|
||||
if (spinner != null) {
|
||||
// Remove spinner and overlay (indices 2 and 1 after WebView at 0).
|
||||
if (container.getChildCount() > 2) container.removeViewAt(2); // spinner
|
||||
if (container.getChildCount() > 1) container.removeViewAt(1); // overlay
|
||||
spinner = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int dpToPx(SandboxPlugin plugin, int dp) {
|
||||
float density = plugin.getActivity().getResources().getDisplayMetrics().density;
|
||||
return Math.round(dp * density);
|
||||
}
|
||||
|
||||
void postMessageToWebView(String jsonString) {
|
||||
String js = "(function() { " +
|
||||
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
|
||||
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
|
||||
"} " +
|
||||
"})();";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
|
||||
void resolveRequest(String requestId, int status, String statusText,
|
||||
Map<String, String> headers, String bodyBase64) {
|
||||
PendingRequest pending = pendingRequests.remove(requestId);
|
||||
if (pending == null) return;
|
||||
|
||||
byte[] bodyBytes = null;
|
||||
if (bodyBase64 != null && !bodyBase64.equals("null")) {
|
||||
try {
|
||||
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
|
||||
String encoding = contentType.contains("text/") ? "UTF-8" : null;
|
||||
|
||||
InputStream body = bodyBytes != null
|
||||
? new ByteArrayInputStream(bodyBytes)
|
||||
: new ByteArrayInputStream(new byte[0]);
|
||||
|
||||
WebResourceResponse response = new WebResourceResponse(
|
||||
contentType, encoding, status, statusText, headers, body
|
||||
);
|
||||
|
||||
pending.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebViewClient that intercepts all requests and forwards them to JS.
|
||||
*/
|
||||
private static class SandboxWebViewClient extends WebViewClient {
|
||||
private final SandboxInstance sandbox;
|
||||
private boolean bridgeInjected = false;
|
||||
|
||||
SandboxWebViewClient(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
String url = request.getUrl().toString();
|
||||
|
||||
// Only intercept requests to the sandbox domain.
|
||||
if (!url.contains(".sandbox.native")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
// Create a pending request with a blocking latch.
|
||||
PendingRequest pending = new PendingRequest();
|
||||
sandbox.pendingRequests.put(requestId, pending);
|
||||
|
||||
// Rewrite URL to include the sandbox ID for the JS handler.
|
||||
String path = request.getUrl().getPath();
|
||||
if (path == null || path.isEmpty()) path = "/";
|
||||
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
|
||||
|
||||
// Serialise the request.
|
||||
JSObject serialisedRequest = new JSObject();
|
||||
serialisedRequest.put("url", rewrittenURL);
|
||||
serialisedRequest.put("method", request.getMethod());
|
||||
|
||||
JSObject headers = new JSObject();
|
||||
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
|
||||
headers.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
serialisedRequest.put("headers", headers);
|
||||
serialisedRequest.put("body", JSONObject.NULL);
|
||||
|
||||
// Emit to JS.
|
||||
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
|
||||
|
||||
// Block until JS responds. Each asset is fetched from a Blossom
|
||||
// server over the network, so we need a generous timeout. The
|
||||
// WebView IO thread pool has ~6 threads; if all are blocked,
|
||||
// subsequent requests queue until a thread frees up.
|
||||
WebResourceResponse response = pending.awaitResponse(60000);
|
||||
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Timeout — return error response.
|
||||
sandbox.pendingRequests.remove(requestId);
|
||||
return new WebResourceResponse(
|
||||
"text/plain", "UTF-8", 504,
|
||||
"Gateway Timeout", new HashMap<>(),
|
||||
new ByteArrayInputStream("Request timed out".getBytes())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
|
||||
if (!bridgeInjected) {
|
||||
bridgeInjected = true;
|
||||
view.evaluateJavascript(getBridgeScript(), null);
|
||||
}
|
||||
|
||||
// Remove the native spinner once the first page has finished
|
||||
// loading (all initial resources resolved). This runs on the
|
||||
// main thread, so the removal is safe.
|
||||
sandbox.hideSpinner();
|
||||
}
|
||||
|
||||
private String getBridgeScript() {
|
||||
return "(function() {" +
|
||||
"'use strict';" +
|
||||
"var messageListeners = [];" +
|
||||
"window.__sandboxBridge = {" +
|
||||
" onMessage: function(data) {" +
|
||||
" var event = {" +
|
||||
" data: data," +
|
||||
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
|
||||
" source: window.parent," +
|
||||
" type: 'message'" +
|
||||
" };" +
|
||||
" for (var i = 0; i < messageListeners.length; i++) {" +
|
||||
" try { messageListeners[i](event); } catch(e) {}" +
|
||||
" }" +
|
||||
" }" +
|
||||
"};" +
|
||||
"var origAdd = window.addEventListener;" +
|
||||
"window.addEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
|
||||
" return origAdd.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"var origRemove = window.removeEventListener;" +
|
||||
"window.removeEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message') {" +
|
||||
" var idx = messageListeners.indexOf(fn);" +
|
||||
" if (idx !== -1) messageListeners.splice(idx, 1);" +
|
||||
" }" +
|
||||
" return origRemove.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"if (!window.parent || window.parent === window) window.parent = {};" +
|
||||
"window.parent.postMessage = function(data) {" +
|
||||
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
|
||||
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
|
||||
" }" +
|
||||
"};" +
|
||||
"})();";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript interface exposed to the sandbox WebView.
|
||||
*/
|
||||
private static class SandboxBridge {
|
||||
private final SandboxInstance sandbox;
|
||||
|
||||
SandboxBridge(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void postMessage(String json) {
|
||||
try {
|
||||
JSONObject obj = new JSONObject(json);
|
||||
JSObject jsObj = new JSObject();
|
||||
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
jsObj.put(key, obj.get(key));
|
||||
}
|
||||
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
|
||||
} catch (JSONException e) {
|
||||
Log.w(TAG, "Failed to parse script message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebViewClient IO thread until JS
|
||||
* responds with the complete resource.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private volatile WebResourceResponse response;
|
||||
private final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -19,7 +19,6 @@ public class MainActivity extends BridgeActivity {
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
// Register native plugins before super.onCreate.
|
||||
registerPlugin(DittoNotificationPlugin.class);
|
||||
registerPlugin(SandboxPlugin.class);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
@@ -64,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;
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 868 B After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 940 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 531 B After Width: | Height: | Size: 3.2 KiB |
|
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: 7.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 20 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">#ff6600</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,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: {
|
||||
|
||||
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
|
||||
import customRules from "./eslint-rules/index.js";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "android", "ios"] },
|
||||
{ ignores: ["dist", "android", "ios", ".agents"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
<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>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
|
||||
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */; };
|
||||
@@ -33,7 +32,6 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40004000100000002 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
@@ -76,7 +74,6 @@
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
B1A2C3D40004000100000002 /* App.entitlements */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */,
|
||||
B1A2C3D40007000100000002 /* NostrPoller.swift */,
|
||||
@@ -174,7 +171,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
|
||||
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */,
|
||||
B1A2C3D40007000100000001 /* NostrPoller.swift in Sources */,
|
||||
@@ -327,9 +323,9 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
MARKETING_VERSION = 2.14.4;
|
||||
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;
|
||||
@@ -352,7 +348,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
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: 291 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WebKit
|
||||
|
||||
// MARK: - Plugin
|
||||
|
||||
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
|
||||
///
|
||||
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
|
||||
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
|
||||
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
|
||||
/// and forwarded to the JS layer as fetch events — the same protocol
|
||||
/// iframe.diy uses. This lets the existing React code serve files identically.
|
||||
@objc(SandboxPlugin)
|
||||
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "SandboxPlugin"
|
||||
public let jsName = "SandboxPlugin"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "navigate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// Active sandbox instances, keyed by sandbox ID.
|
||||
private var sandboxes: [String: SandboxInstance] = [:]
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
@objc func create(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
if sandboxes[sandboxId] != nil {
|
||||
call.reject("Sandbox already exists: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
|
||||
let sandbox = SandboxInstance(
|
||||
id: sandboxId,
|
||||
frame: webViewFrame,
|
||||
plugin: self
|
||||
)
|
||||
self.sandboxes[sandboxId] = sandbox
|
||||
|
||||
// Add the container (WebView + spinner overlay) on top of
|
||||
// the Capacitor WebView.
|
||||
if let bridge = self.bridge,
|
||||
let webView = bridge.webView {
|
||||
webView.superview?.addSubview(sandbox.containerView)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func navigate(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.navigateToApp()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateFrame(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func respondToFetch(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let requestId = call.getString("requestId") else {
|
||||
call.reject("Missing required parameter: requestId")
|
||||
return
|
||||
}
|
||||
guard let response = call.getObject("response") else {
|
||||
call.reject("Missing required parameter: response")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
sandbox.schemeHandler.resolveRequest(
|
||||
requestId: requestId,
|
||||
status: response["status"] as? Int ?? 200,
|
||||
statusText: response["statusText"] as? String ?? "OK",
|
||||
headers: response["headers"] as? [String: String] ?? [:],
|
||||
bodyBase64: response["body"] as? String
|
||||
)
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func postMessage(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let message = call.getObject("message") else {
|
||||
call.reject("Missing required parameter: message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
sandbox.postMessageToWebView(message)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func destroy(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
|
||||
sandbox.containerView.removeFromSuperview()
|
||||
sandbox.schemeHandler.cancelAll()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Forwarding
|
||||
|
||||
/// Forward a fetch request from the native WebView to JS.
|
||||
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
|
||||
notifyListeners("fetch", data: [
|
||||
"id": sandboxId,
|
||||
"requestId": requestId,
|
||||
"request": request,
|
||||
])
|
||||
}
|
||||
|
||||
/// Forward a script message from the sandbox to JS.
|
||||
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
|
||||
notifyListeners("scriptMessage", data: [
|
||||
"id": sandboxId,
|
||||
"message": message,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxInstance
|
||||
|
||||
/// Manages a single sandboxed WKWebView instance.
|
||||
private class SandboxInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
||||
let id: String
|
||||
let webView: WKWebView
|
||||
let schemeHandler: SandboxSchemeHandler
|
||||
private weak var plugin: SandboxPlugin?
|
||||
private let customScheme: String
|
||||
|
||||
/// Container view that holds the WebView and spinner overlay.
|
||||
let containerView: UIView
|
||||
|
||||
/// Native spinner overlay, removed when the first page finishes loading.
|
||||
private var spinnerOverlay: UIView?
|
||||
|
||||
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
|
||||
self.id = id
|
||||
self.plugin = plugin
|
||||
|
||||
// Each sandbox gets a unique custom URL scheme so that WKWebView
|
||||
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
|
||||
self.customScheme = "sbx-\(id)"
|
||||
|
||||
self.schemeHandler = SandboxSchemeHandler(
|
||||
sandboxId: id,
|
||||
scheme: self.customScheme,
|
||||
plugin: plugin
|
||||
)
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
|
||||
|
||||
// Add a script message handler for communication from injected scripts.
|
||||
let userContentController = WKUserContentController()
|
||||
|
||||
// Inject a bridge script that:
|
||||
// 1. Provides window.parent.postMessage()-like functionality
|
||||
// 2. Routes messages through the native bridge
|
||||
let bridgeScript = WKUserScript(
|
||||
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
|
||||
injectionTime: .atDocumentStart,
|
||||
forMainFrameOnly: false
|
||||
)
|
||||
userContentController.addUserScript(bridgeScript)
|
||||
|
||||
config.userContentController = userContentController
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
|
||||
// Container view that holds the WebView + spinner overlay.
|
||||
self.containerView = UIView(frame: frame)
|
||||
|
||||
self.webView = WKWebView(frame: containerView.bounds, configuration: config)
|
||||
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.webView.scrollView.backgroundColor = self.webView.backgroundColor
|
||||
self.webView.scrollView.bounces = false
|
||||
self.containerView.addSubview(self.webView)
|
||||
|
||||
// Dark overlay behind the spinner.
|
||||
let overlay = UIView(frame: containerView.bounds)
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
overlay.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.containerView.addSubview(overlay)
|
||||
|
||||
// Native spinner — uses UIActivityIndicatorView which animates on
|
||||
// the render thread independently of JS/main-thread work.
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.color = UIColor(red: 124/255.0, green: 92/255.0, blue: 220/255.0, alpha: 1)
|
||||
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||
spinner.startAnimating()
|
||||
overlay.addSubview(spinner)
|
||||
NSLayoutConstraint.activate([
|
||||
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
||||
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
|
||||
])
|
||||
|
||||
self.spinnerOverlay = overlay
|
||||
|
||||
super.init()
|
||||
|
||||
// Register the message handler and navigation delegate after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
self.webView.navigationDelegate = self
|
||||
}
|
||||
|
||||
/// Navigate the WebView to the sandbox's entry point.
|
||||
func navigateToApp() {
|
||||
let initialURL = URL(string: "\(customScheme)://app/index.html")!
|
||||
webView.load(URLRequest(url: initialURL))
|
||||
}
|
||||
|
||||
/// Remove the native loading overlay. Safe to call multiple times.
|
||||
func hideSpinner() {
|
||||
spinnerOverlay?.removeFromSuperview()
|
||||
spinnerOverlay = nil
|
||||
}
|
||||
|
||||
/// Post a JSON-RPC message to injected scripts inside the WebView.
|
||||
func postMessageToWebView(_ message: [String: Any]) {
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
let js = """
|
||||
(function() {
|
||||
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
|
||||
window.__sandboxBridge.onMessage(\(jsonString));
|
||||
}
|
||||
})();
|
||||
"""
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - WKScriptMessageHandler
|
||||
|
||||
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
|
||||
func userContentController(
|
||||
_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
guard message.name == "sandboxBridge",
|
||||
let body = message.body as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
plugin?.emitScriptMessage(sandboxId: id, message: body)
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
/// Remove the spinner overlay once the first page finishes loading.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
hideSpinner()
|
||||
}
|
||||
|
||||
// MARK: - Bridge Script
|
||||
|
||||
/// JavaScript injected at document start that provides:
|
||||
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
|
||||
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
|
||||
/// - `window.addEventListener("message", ...)` support for injected scripts
|
||||
private static func bridgeScript(scheme: String) -> String {
|
||||
return """
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Message listeners registered by injected scripts.
|
||||
var messageListeners = [];
|
||||
|
||||
// Bridge object for native communication.
|
||||
window.__sandboxBridge = {
|
||||
onMessage: function(data) {
|
||||
// Dispatch to all registered message listeners.
|
||||
var event = {
|
||||
data: data,
|
||||
origin: '\(scheme)://app',
|
||||
source: window.parent,
|
||||
type: 'message'
|
||||
};
|
||||
for (var i = 0; i < messageListeners.length; i++) {
|
||||
try {
|
||||
messageListeners[i](event);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] Listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Override addEventListener to capture "message" listeners.
|
||||
var originalAddEventListener = window.addEventListener;
|
||||
window.addEventListener = function(type, listener, options) {
|
||||
if (type === 'message' && typeof listener === 'function') {
|
||||
messageListeners.push(listener);
|
||||
}
|
||||
return originalAddEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
var originalRemoveEventListener = window.removeEventListener;
|
||||
window.removeEventListener = function(type, listener, options) {
|
||||
if (type === 'message') {
|
||||
var idx = messageListeners.indexOf(listener);
|
||||
if (idx !== -1) messageListeners.splice(idx, 1);
|
||||
}
|
||||
return originalRemoveEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
// Emulate window.parent.postMessage for scripts that use it
|
||||
// (e.g. the webxdc bridge script, preview injected script).
|
||||
if (!window.parent || window.parent === window) {
|
||||
window.parent = {};
|
||||
}
|
||||
window.parent.postMessage = function(data, targetOrigin, transfer) {
|
||||
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
|
||||
try {
|
||||
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] postMessage failed:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxSchemeHandler
|
||||
|
||||
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
|
||||
/// URL scheme and forwards them to the JS layer as fetch events.
|
||||
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private let sandboxId: String
|
||||
private let scheme: String
|
||||
private weak var plugin: SandboxPlugin?
|
||||
|
||||
/// Pending scheme tasks waiting for a response from JS.
|
||||
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
|
||||
private var pendingTasks: [String: WKURLSchemeTask] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
|
||||
self.sandboxId = sandboxId
|
||||
self.scheme = scheme
|
||||
self.plugin = plugin
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
let request = urlSchemeTask.request
|
||||
guard let url = request.url else {
|
||||
urlSchemeTask.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
let requestId = UUID().uuidString
|
||||
|
||||
lock.lock()
|
||||
pendingTasks[requestId] = urlSchemeTask
|
||||
lock.unlock()
|
||||
|
||||
// Serialise the request for the fetch event.
|
||||
// Rewrite the URL so it looks like a normal HTTP URL to the parent
|
||||
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
|
||||
// The JS side only cares about the pathname.
|
||||
var headers: [String: String] = [:]
|
||||
if let allHeaders = request.allHTTPHeaderFields {
|
||||
headers = allHeaders
|
||||
}
|
||||
|
||||
var bodyBase64: String? = nil
|
||||
if let bodyData = request.httpBody {
|
||||
bodyBase64 = bodyData.base64EncodedString()
|
||||
}
|
||||
|
||||
let path = url.path.isEmpty ? "/" : url.path
|
||||
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
|
||||
|
||||
let serialisedRequest: [String: Any] = [
|
||||
"url": rewrittenURL,
|
||||
"method": request.httpMethod ?? "GET",
|
||||
"headers": headers,
|
||||
"body": bodyBase64 as Any,
|
||||
]
|
||||
|
||||
plugin?.emitFetchRequest(
|
||||
sandboxId: sandboxId,
|
||||
requestId: requestId,
|
||||
request: serialisedRequest
|
||||
)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
// Remove the task from pending — JS response will be ignored if it arrives later.
|
||||
lock.lock()
|
||||
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
|
||||
if let key = removed?.key {
|
||||
pendingTasks.removeValue(forKey: key)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Called by the plugin when JS responds to a fetch request.
|
||||
func resolveRequest(
|
||||
requestId: String,
|
||||
status: Int,
|
||||
statusText: String,
|
||||
headers: [String: String],
|
||||
bodyBase64: String?
|
||||
) {
|
||||
lock.lock()
|
||||
guard let task = pendingTasks.removeValue(forKey: requestId) else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
// Decode the base64 body.
|
||||
var bodyData: Data? = nil
|
||||
if let b64 = bodyBase64 {
|
||||
bodyData = Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
// Build the response.
|
||||
// Use the task's original URL for the response.
|
||||
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
|
||||
let response = HTTPURLResponse(
|
||||
url: responseURL,
|
||||
statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: headers
|
||||
)!
|
||||
|
||||
DispatchQueue.main.async {
|
||||
task.didReceive(response)
|
||||
if let data = bodyData {
|
||||
task.didReceive(data)
|
||||
}
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all pending tasks (called on destroy).
|
||||
func cancelAll() {
|
||||
lock.lock()
|
||||
let tasks = pendingTasks
|
||||
pendingTasks.removeAll()
|
||||
lock.unlock()
|
||||
|
||||
for (_, task) in tasks {
|
||||
task.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -999,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
app_identifier("spot.agora.app")
|
||||
team_id("GZLTTH5DLM")
|
||||
@@ -0,0 +1,146 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
# ─── Lanes ────────────────────────────────────────────────────────────
|
||||
|
||||
desc "Build and sign the App Store IPA. Output at ../artifacts/Agora.ipa."
|
||||
lane :build_ipa do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
end
|
||||
|
||||
desc "Submit an already-built IPA to App Store Connect for review. " \
|
||||
"Set IPA_PATH to the IPA's location."
|
||||
lane :submit_release do
|
||||
ipa_path = ENV.fetch("IPA_PATH") do
|
||||
UI.user_error!("submit_release requires the IPA_PATH env var")
|
||||
end
|
||||
UI.user_error!("IPA not found at #{ipa_path}") unless File.exist?(ipa_path)
|
||||
submit_release_for_review!(ipa_path)
|
||||
end
|
||||
|
||||
desc "Build, sign, and submit Agora to the App Store for review (single-step convenience)."
|
||||
lane :release do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
# Use the IPA path set by build_app rather than recomputing it from
|
||||
# __dir__, which gets fragile across fastlane-relative paths.
|
||||
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||
UI.user_error!("build_app did not set IPA_OUTPUT_PATH") unless ipa_path
|
||||
submit_release_for_review!(ipa_path)
|
||||
end
|
||||
|
||||
desc "Submit an already-uploaded build for review (skip build/upload). " \
|
||||
"Use BUILD_NUMBER and VERSION env vars."
|
||||
lane :submit_only do
|
||||
submit_release_for_review!(nil)
|
||||
end
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def setup_lane_signing!
|
||||
# Create an ephemeral keychain so we never touch the login keychain.
|
||||
setup_ci
|
||||
|
||||
api_key = build_api_key!
|
||||
|
||||
# Fetch encrypted distribution cert + provisioning profile from the
|
||||
# shared certificates repo. --readonly: never mutate from CI.
|
||||
# Passing api_key makes match contact Apple's portal to verify the
|
||||
# cert is still valid for the team — fails fast on revoked/expired
|
||||
# certs instead of letting xcodebuild stumble later.
|
||||
match(type: "appstore", readonly: true, api_key: api_key)
|
||||
|
||||
api_key
|
||||
end
|
||||
|
||||
def build_api_key!
|
||||
# Build the API key hash inline. We avoid the app_store_connect_api_key
|
||||
# action because it sets APP_STORE_CONNECT_API_KEY_PATH (path to the .p8)
|
||||
# which collides with match's APP_STORE_CONNECT_API_KEY_PATH (path to a
|
||||
# JSON descriptor). Same env name, different formats.
|
||||
@api_key ||= {
|
||||
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
|
||||
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
|
||||
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
|
||||
duration: 1200,
|
||||
in_house: false,
|
||||
}
|
||||
end
|
||||
|
||||
def build_release_ipa!
|
||||
# Stamp build number from CI pipeline ID so every release is monotonically increasing.
|
||||
increment_build_number(
|
||||
xcodeproj: "App/App.xcodeproj",
|
||||
build_number: ENV.fetch("CI_PIPELINE_IID"),
|
||||
)
|
||||
|
||||
# Marketing version is set externally (sed in CI) before this lane runs.
|
||||
|
||||
build_app(
|
||||
project: "App/App.xcodeproj",
|
||||
scheme: "App",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
output_directory: "../artifacts",
|
||||
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
|
||||
# ephemeral keychain; tell xcodebuild to use them explicitly so it
|
||||
# doesn't also try to find an iOS Development cert (which we never
|
||||
# provision in CI).
|
||||
xcargs: [
|
||||
"CODE_SIGN_STYLE=Manual",
|
||||
"CODE_SIGN_IDENTITY='Apple Distribution'",
|
||||
"PROVISIONING_PROFILE_SPECIFIER='match AppStore spot.agora.app'",
|
||||
"DEVELOPMENT_TEAM=GZLTTH5DLM",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
signingStyle: "manual",
|
||||
teamID: "GZLTTH5DLM",
|
||||
provisioningProfiles: {
|
||||
"spot.agora.app" => "match AppStore spot.agora.app",
|
||||
},
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
# If ipa_path is nil, deliver picks up the latest processed build for the
|
||||
# configured app version (used by the submit_only lane).
|
||||
def submit_release_for_review!(ipa_path)
|
||||
api_key = build_api_key!
|
||||
|
||||
options = {
|
||||
api_key: api_key,
|
||||
submit_for_review: true,
|
||||
automatic_release: false,
|
||||
force: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
# Don't try to PATCH content rights on every submit — Apple's API
|
||||
# rejects updates to contentRightsDeclaration once the listing has
|
||||
# an established state. The values stay as set in the App Store
|
||||
# Connect UI / from a prior submission.
|
||||
submission_information: {
|
||||
export_compliance_uses_encryption: false,
|
||||
},
|
||||
skip_screenshots: true,
|
||||
# Keep skip_app_version_update=false: deliver needs to PATCH the
|
||||
# version's whatsNew (release notes) and platform-version metadata
|
||||
# before submit_for_review will accept the version.
|
||||
skip_app_version_update: false,
|
||||
skip_metadata: false,
|
||||
metadata_path: "./fastlane/metadata",
|
||||
run_precheck_before_submit: false,
|
||||
}
|
||||
options[:ipa] = ipa_path if ipa_path
|
||||
if ENV["BUILD_NUMBER"]
|
||||
options[:build_number] = ENV["BUILD_NUMBER"]
|
||||
options[:skip_binary_upload] = true
|
||||
end
|
||||
options[:app_version] = ENV["VERSION"] if ENV["VERSION"]
|
||||
|
||||
deliver(**options)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
git_url("https://gitlab.com/soapbox-pub/certificates.git")
|
||||
storage_mode("git")
|
||||
type("appstore")
|
||||
app_identifier(["spot.agora.app"])
|
||||
team_id("GZLTTH5DLM")
|
||||
@@ -0,0 +1 @@
|
||||
Placeholder. CI overwrites this file with the release summary paragraph from CHANGELOG.md (the leading plaintext paragraph in the section for the current version).
|
||||
@@ -8,7 +8,8 @@
|
||||
"name": "agora",
|
||||
"version": "2.8.0",
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
|
||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -64,9 +65,9 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@nostrify/nostrify": "^0.52.0",
|
||||
"@nostrify/react": "^0.6.0",
|
||||
"@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",
|
||||
@@ -95,21 +96,23 @@
|
||||
"@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/bip39": "^1.6.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"bitcoinjs-lib": "^7.0.1",
|
||||
"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",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-celestial": "^0.7.35",
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"ecpair": "^3.0.1",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -122,7 +125,6 @@
|
||||
"iso-3166": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
@@ -353,17 +355,40 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/secp256k1": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
|
||||
"integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@breeztech/breez-sdk-spark": {
|
||||
"version": "0.13.2-dev1",
|
||||
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.13.2-dev1.tgz",
|
||||
"integrity": "sha512-W7udRIz+ehjqzCFGCmzJ6fYhSPZ6AGsXyO/X3upOmbJdHXw2DtIVaRYz5sxHLlmIHre8MYAbNUFS3nRqMMVfVQ==",
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.10.0.tgz",
|
||||
"integrity": "sha512-eBsh0oX2B8uGuWfCMmtH3SNXmSkED5du/CiWQKh1Ei1r0LsO6jlVnUmh94j7R5W4siIi7M6CC7ywll3FQ47rYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"pg": "^8.18.0"
|
||||
"better-sqlite3": "^12.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
@@ -2601,11 +2626,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/nostrify": {
|
||||
"version": "0.51.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.1.tgz",
|
||||
"integrity": "sha512-oPJhUiO1TlV5sGYizqAP4GvLijib34Uwh48wxlFimR/2MoCuSmab4AppcztGPNwxQoTKkJbLJwsSpl42V+WIXA==",
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.0.tgz",
|
||||
"integrity": "sha512-x+gc8rxJ4C+mnoFgd4Zzi0JnXUz0acQA69nKqR0fnWhpc/KiQosgIILfaNUTWkecTPJ92iazT4Es+TrUUSFcRg==",
|
||||
"dependencies": {
|
||||
"@nostrify/types": "0.36.9",
|
||||
"@nostrify/types": "0.37.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@smithy/util-base64": "^4.3.0",
|
||||
"@smithy/util-hex-encoding": "^4.2.0",
|
||||
@@ -2642,12 +2667,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
|
||||
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.0.tgz",
|
||||
"integrity": "sha512-6vjF5UagAW5QRpxAu/of9lyI7837wwoyX/NLGQbEs6fcMQXjTo/m7wUBPipoj0E460QvyNXff5O8Byn72enWbQ==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
"@nostrify/nostrify": "0.52.0",
|
||||
"@nostrify/types": "0.37.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
@@ -2657,9 +2682,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/types": {
|
||||
"version": "0.36.9",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.36.9.tgz",
|
||||
"integrity": "sha512-tMx/r0W+QoVRRgs8d6ltaSgrftasOXuFsi33kW8WirswCy2b3UR1tqRgc0iBU9zRa9XR0nlej/wJZW+6wUFi+Q=="
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.37.0.tgz",
|
||||
"integrity": "sha512-P0AKR+qcNeBBTA5UDnJM6SxLAQbgud2+ZcdVyheoP37XGQvi7rUncQUDKwebG+Ui5kswp/IPEmvqNtCMQpwRoA=="
|
||||
},
|
||||
"node_modules/@ocavue/utils": {
|
||||
"version": "1.6.0",
|
||||
@@ -6170,39 +6195,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@samthomson/nostr-messaging": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@samthomson/nostr-messaging/-/nostr-messaging-0.17.1.tgz",
|
||||
"integrity": "sha512-TfgC3L/7sKnkLSqod1UyF9Bt/F36kH02nRffWjm5YEMfLvHLEYlT5ECgzyrnt9QVpYXG25rVAhEpXF9wxmPX0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb": "^8.0.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"react-blurhash": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nostrify/nostrify": ">=0.47.0",
|
||||
"@nostrify/react": ">=0.2.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.0.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
|
||||
@@ -7715,6 +7707,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base-x": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -7735,6 +7733,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bech32": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
|
||||
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.9.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
|
||||
@@ -7783,6 +7787,37 @@
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bip174": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz",
|
||||
"integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.9",
|
||||
"varuint-bitcoin": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bitcoinjs-lib": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz",
|
||||
"integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"bech32": "^2.0.0",
|
||||
"bip174": "^3.0.0",
|
||||
"bs58check": "^4.0.0",
|
||||
"uint8array-tools": "^0.0.9",
|
||||
"valibot": "^1.2.0",
|
||||
"varuint-bitcoin": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@@ -7896,6 +7931,25 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base-x": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58check": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
|
||||
"integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"bs58": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
@@ -8346,6 +8400,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "3.5.17",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz",
|
||||
"integrity": "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
@@ -8358,6 +8418,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-celestial": {
|
||||
"version": "0.7.35",
|
||||
"resolved": "https://registry.npmjs.org/d3-celestial/-/d3-celestial-0.7.35.tgz",
|
||||
"integrity": "sha512-cURxIl0E+FGWnYj6gTDt80SjuiM9lklcGykj/skVy7glDg5nj/QxTUoPPArU+bpEQ+1fLy5hi920OvJ/TgliRw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
@@ -8716,6 +8785,29 @@
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/ecpair": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz",
|
||||
"integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.8",
|
||||
"valibot": "^1.2.0",
|
||||
"wif": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ecpair/node_modules/uint8array-tools": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
|
||||
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.149",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
|
||||
@@ -9318,19 +9410,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz",
|
||||
"integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/krisk"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -11644,15 +11723,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ngeohash": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
|
||||
"integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.89.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||
@@ -12003,102 +12073,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
"pg-protocol": "^1.13.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -12336,49 +12310,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
||||
@@ -14084,7 +14015,7 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
@@ -14701,6 +14632,15 @@
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uint8array-tools": {
|
||||
"version": "0.0.9",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz",
|
||||
"integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -15039,6 +14979,38 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.0.tgz",
|
||||
"integrity": "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/varuint-bitcoin": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
|
||||
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/varuint-bitcoin/node_modules/uint8array-tools": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
|
||||
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
@@ -16641,6 +16613,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wif": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
|
||||
"integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bs58check": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -16781,16 +16762,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint --cache && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
|
||||
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
|
||||
"icons": "bash scripts/generate-icons.sh"
|
||||
@@ -15,7 +15,8 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
|
||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -71,9 +72,9 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@nostrify/nostrify": "^0.52.0",
|
||||
"@nostrify/react": "^0.6.0",
|
||||
"@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",
|
||||
@@ -102,21 +103,23 @@
|
||||
"@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/bip39": "^1.6.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"bitcoinjs-lib": "^7.0.1",
|
||||
"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",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-celestial": "^0.7.35",
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"ecpair": "^3.0.1",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -129,7 +132,6 @@
|
||||
"iso-3166": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 569 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 520 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 5.6 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: 75 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 98 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 |
@@ -5,7 +5,7 @@
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0c14",
|
||||
"theme_color": "#e85d3c",
|
||||
"theme_color": "#ff6600",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
@@ -87,5 +87,13 @@
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
]
|
||||
],
|
||||
"related_applications": [
|
||||
{
|
||||
"platform": "play",
|
||||
"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 |
@@ -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(),
|
||||
),
|
||||
);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract release notes from CHANGELOG.md for a given version.
|
||||
*
|
||||
* The CHANGELOG follows Keep a Changelog format with one extension: each release
|
||||
* section MAY begin with a single plaintext paragraph (the "summary") before any
|
||||
* `### Added` / `### Changed` / etc. heading. The summary is used as the release
|
||||
* blurb on the App Store, Play Store, and the in-app version-update toast. The
|
||||
* full section body is used as the GitLab Release description.
|
||||
*
|
||||
* Format:
|
||||
*
|
||||
* ## [X.Y.Z] - YYYY-MM-DD
|
||||
*
|
||||
* A short single-paragraph summary (max 500 characters by convention).
|
||||
*
|
||||
* ### Added
|
||||
* - bullet
|
||||
* - bullet
|
||||
*
|
||||
* ### Changed
|
||||
* - bullet
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/extract-release-notes.mjs <version> [--summary] [--changelog <path>]
|
||||
*
|
||||
* --summary Print only the summary paragraph (no headings, no bullets).
|
||||
* 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
|
||||
* not found in the changelog.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { argv, exit, stderr, stdout } from 'node:process';
|
||||
|
||||
function parseArgs(args) {
|
||||
let version;
|
||||
let summary = false;
|
||||
let changelog = 'CHANGELOG.md';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--summary') summary = true;
|
||||
else if (arg === '--changelog') changelog = args[++i];
|
||||
else if (!arg.startsWith('--') && !version) version = arg;
|
||||
else {
|
||||
stderr.write(`Unknown argument: ${arg}\n`);
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
if (!version) {
|
||||
stderr.write('Usage: extract-release-notes.mjs <version> [--summary] [--changelog <path>]\n');
|
||||
exit(2);
|
||||
}
|
||||
// Strip a leading "v" so callers can pass either "v2.14.3" or "2.14.3".
|
||||
if (version.startsWith('v')) version = version.slice(1);
|
||||
return { version, summary, changelog };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the lines belonging to a single version section from changelog text,
|
||||
* not including the version heading itself.
|
||||
*/
|
||||
function extractSection(markdown, version) {
|
||||
const lines = markdown.split('\n');
|
||||
const headingPattern = new RegExp(
|
||||
`^## \\[${version.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\]`,
|
||||
);
|
||||
const nextHeadingPattern = /^## \[/;
|
||||
let inSection = false;
|
||||
const out = [];
|
||||
for (const line of lines) {
|
||||
if (!inSection) {
|
||||
if (headingPattern.test(line)) {
|
||||
inSection = true;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (nextHeadingPattern.test(line)) break;
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
return inSection ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the leading non-blank paragraph from a section, stopping at the first
|
||||
* `###` category heading or `-` bullet. Returns null if no summary paragraph.
|
||||
*/
|
||||
function extractSummary(sectionLines) {
|
||||
const paragraph = [];
|
||||
let started = false;
|
||||
for (const line of sectionLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!started) {
|
||||
if (!trimmed) continue;
|
||||
// If the very first non-blank line is a heading or bullet, there's no summary.
|
||||
if (trimmed.startsWith('#') || trimmed.startsWith('- ')) return null;
|
||||
started = true;
|
||||
paragraph.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
// We're inside the paragraph. A blank line, a heading, or a bullet ends it.
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('- ')) break;
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
return paragraph.length ? paragraph.join(' ') : null;
|
||||
}
|
||||
|
||||
/** Trim leading and trailing blank lines from a list of lines. */
|
||||
function trimBlankEdges(lines) {
|
||||
let start = 0;
|
||||
let end = lines.length;
|
||||
while (start < end && !lines[start].trim()) start++;
|
||||
while (end > start && !lines[end - 1].trim()) end--;
|
||||
return lines.slice(start, end);
|
||||
}
|
||||
|
||||
const { version, summary, changelog } = parseArgs(argv.slice(2));
|
||||
const markdown = readFileSync(changelog, 'utf8');
|
||||
const section = extractSection(markdown, version);
|
||||
|
||||
if (!section) {
|
||||
stderr.write(`Version ${version} not found in ${changelog}\n`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
const text = extractSummary(section);
|
||||
stdout.write(text ?? `Agora v${version}`);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
const body = trimBlankEdges(section).join('\n');
|
||||
if (body) {
|
||||
stdout.write(body);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
stdout.write(`Agora v${version}\n`);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
// NOTE: This file should normally not be modified unless you are adding a new provider.
|
||||
// To add new routes, edit the AppRouter.tsx file.
|
||||
|
||||
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
|
||||
import { NostrLoginProvider } from "@nostrify/react/login";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { InferSeoMetaPlugin } from "@unhead/addons";
|
||||
import { createHead, UnheadProvider } from "@unhead/react/client";
|
||||
import { useEffect } from "react";
|
||||
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";
|
||||
@@ -21,10 +18,8 @@ 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 { 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({
|
||||
@@ -45,11 +40,13 @@ const queryClient = new QueryClient({
|
||||
const hardcodedConfig: AppConfig = {
|
||||
appName: "Agora",
|
||||
appId: "agora",
|
||||
homePage: "feed",
|
||||
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
|
||||
homePage: "campaigns",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
useAppRelays: true,
|
||||
useUserRelays: false,
|
||||
relayMetadata: {
|
||||
relays: [],
|
||||
updatedAt: 0,
|
||||
@@ -59,8 +56,12 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeComments: true,
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeReactions: false,
|
||||
feedIncludeZaps: false,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showHighlights: true,
|
||||
feedIncludeHighlights: false,
|
||||
showEvents: true,
|
||||
feedIncludeEvents: true,
|
||||
showVines: true,
|
||||
@@ -69,13 +70,13 @@ const hardcodedConfig: AppConfig = {
|
||||
showTreasureGeocaches: true,
|
||||
showTreasureFoundLogs: true,
|
||||
showColors: true,
|
||||
showPacks: true,
|
||||
showPeopleLists: true,
|
||||
feedIncludeVines: true,
|
||||
feedIncludePolls: true,
|
||||
feedIncludeTreasureGeocaches: true,
|
||||
feedIncludeTreasureFoundLogs: true,
|
||||
feedIncludeColors: true,
|
||||
feedIncludePacks: true,
|
||||
feedIncludePeopleLists: true,
|
||||
showDecks: true,
|
||||
feedIncludeDecks: true,
|
||||
showWebxdc: true,
|
||||
@@ -103,23 +104,27 @@ const hardcodedConfig: AppConfig = {
|
||||
showBadges: true,
|
||||
showBadgeDefinitions: true,
|
||||
showProfileBadges: true,
|
||||
showBadgeAwards: true,
|
||||
feedIncludeBadgeDefinitions: true,
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeBadgeAwards: true,
|
||||
feedIncludeVanish: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeBirdex: true,
|
||||
feedIncludeConstellations: true,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
"wallet",
|
||||
"verified",
|
||||
"actions",
|
||||
"polls",
|
||||
"world",
|
||||
"badges",
|
||||
"feed",
|
||||
"notifications",
|
||||
"messages",
|
||||
"communities",
|
||||
"world",
|
||||
"wallet",
|
||||
"agent",
|
||||
"messages",
|
||||
"profile",
|
||||
"notifications",
|
||||
"search",
|
||||
"settings",
|
||||
],
|
||||
nip85StatsPubkey:
|
||||
@@ -138,21 +143,20 @@ const hardcodedConfig: AppConfig = {
|
||||
plausibleDomain: import.meta.env.VITE_PLAUSIBLE_DOMAIN || "",
|
||||
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
|
||||
savedFeeds: [],
|
||||
autoplayVideos: false,
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
esploraBaseUrl: 'https://mempool.space/api',
|
||||
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: '',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -185,17 +189,6 @@ const defaultConfig: AppConfig = {
|
||||
export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize system bars for mobile apps.
|
||||
// On Android 16+ (API 36), edge-to-edge is enforced by the OS so
|
||||
// setOverlaysWebView / setBackgroundColor no longer work. The new
|
||||
// SystemBars API (bundled with @capacitor/core 8+) is the replacement.
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
|
||||
// SystemBars may not be available on all platforms
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UnheadProvider head={head}>
|
||||
@@ -206,18 +199,13 @@ export function App() {
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<InitialSyncRunner />
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<SparkWalletProvider>
|
||||
<DMProviderWrapper>
|
||||
<TooltipProvider>
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
<AppRouter />
|
||||
</TooltipProvider>
|
||||
</DMProviderWrapper>
|
||||
</SparkWalletProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
</NostrLoginProvider>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { FundraiserLayout } from "./components/FundraiserLayout";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
@@ -21,54 +21,88 @@ 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 })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
// Lazy-loaded emoji pack dialog
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
|
||||
// when an naddr resolves to kind 30223.) The campaigns list IS the homepage;
|
||||
// the configurable HomePage delegation from the Twitter-era app is gone.
|
||||
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 })));
|
||||
|
||||
// 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 CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
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 CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
|
||||
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const DiscoverPage = lazy(() => import("./pages/DiscoverPage").then(m => ({ default: m.DiscoverPage })));
|
||||
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 DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
|
||||
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
|
||||
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 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 EventDashboardPage = lazy(() => import("./pages/EventDashboardPage").then(m => ({ default: m.EventDashboardPage })));
|
||||
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
|
||||
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
|
||||
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 WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
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 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 ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
|
||||
const ClaimPage = lazy(() => import("./pages/ClaimPage").then(m => ({ default: m.ClaimPage })));
|
||||
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() {
|
||||
@@ -90,6 +124,26 @@ function PollsFeedPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
@@ -111,14 +165,21 @@ export function AppRouter() {
|
||||
<Routes>
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
<Route path="/receive" element={<ReceivePage />} />
|
||||
<Route path="/claim" element={<ClaimPage />} />
|
||||
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
|
||||
<Route element={<FundraiserLayout />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/discover" element={<DiscoverPage />} />
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/campaigns" element={<Navigate to="/" replace />} />
|
||||
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
|
||||
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
|
||||
<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 />} />
|
||||
@@ -133,7 +194,6 @@ export function AppRouter() {
|
||||
path="/settings/notifications"
|
||||
element={<NotificationSettings />}
|
||||
/>
|
||||
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
|
||||
<Route
|
||||
path="/settings/advanced"
|
||||
element={<AdvancedSettingsPage />}
|
||||
@@ -142,8 +202,40 @@ export function AppRouter() {
|
||||
<Route path="/settings/network" element={<NetworkSettingsPage />} />
|
||||
<Route path="/lists" element={<UserListsPage />} />
|
||||
<Route path="/events" element={<EventsFeedPage />} />
|
||||
<Route path="/events/new" element={<CreateEventPage />} />
|
||||
<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
|
||||
@@ -157,16 +249,62 @@ export function AppRouter() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<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="/wallet/recovery" element={<WalletRecoveryPage />} />
|
||||
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
|
||||
<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="/communities/new" element={<CreateCommunityPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/help/donors" element={<DonorGuidePage />} />
|
||||
<Route path="/help/activists" element={<ActivistGuidePage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
@@ -177,7 +315,13 @@ export function AppRouter() {
|
||||
/>
|
||||
<Route path="/i/*" element={<ExternalContentPage />} />
|
||||
<Route path="/actions" element={<ActionsPage />} />
|
||||
<Route path="/actions/new" element={<CreateActionPage />} />
|
||||
<Route path="/pledges" element={<ActionsPage />} />
|
||||
<Route path="/pledges/new" element={<CreateActionPage />} />
|
||||
<Route path="/agent" element={<AIChatPage />} />
|
||||
<Route path="/organizers" element={<OrganizersPage />} />
|
||||
<Route path="/dashboard" element={<EventDashboardPage />} />
|
||||
<Route path="/event-dashboard" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
|
||||
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
|
||||
|
||||