Compare commits
804 Commits
ios-sec
...
feat/zap-all
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| bd6852041e | |||
| e15c2b312c | |||
| 17e7bbd07e | |||
| 910d759155 | |||
| a8d5a1538c | |||
| 3f982e2241 | |||
| 418909f531 | |||
| 44098af247 | |||
| 268b171ba4 | |||
| 2e44d2a677 | |||
| 4277a8fe7d | |||
| 9045ff3c41 | |||
| f56ff2f305 | |||
| 699bc6ca33 | |||
| 16ec99b327 | |||
| 09e211f48a | |||
| 27b60b2a6f | |||
| 07ea1f94d1 | |||
| d1017697a4 | |||
| abe12fdefa | |||
| b02a3b604e | |||
| 305af8ad93 | |||
| c03705d6d6 | |||
| 189411ff77 | |||
| ebfa8fc6d2 | |||
| b639bd7a58 | |||
| 74a2522af1 | |||
| ea53a1b0dd | |||
| 5d99337cd2 | |||
| e2ec2892ab | |||
| af67e7f812 | |||
| 2788127894 | |||
| 9a34fa0102 | |||
| 9f425366c0 | |||
| 7eb70f3a61 | |||
| 0436949797 | |||
| b2634d2fcb | |||
| 8fdb5cf1ad | |||
| 981e4f0726 | |||
| b46703eaed | |||
| 476e99ab81 | |||
| 9483fbc99a | |||
| bf540fb5c1 | |||
| 0cae729335 | |||
| 3b48359aa7 | |||
| c5d5165f84 | |||
| 94f531cdd4 | |||
| 222f641123 | |||
| ecbee21d34 | |||
| 3f28bf571a | |||
| 2e7eee66ee | |||
| 7c4d3012ec | |||
| 01af784953 | |||
| 30b10fd435 | |||
| 19cc0d13c9 | |||
| 8016ecb32d | |||
| 43b2ac91b6 | |||
| 0078ba90cb | |||
| dc168bc978 | |||
| 44c3888ac1 | |||
| 7918ee3662 | |||
| 98644047eb | |||
| 423d53ea58 | |||
| 460926fa99 | |||
| cf2f466772 | |||
| 71fe5aaa3a | |||
| 9813a226ec | |||
| 8eb31223a5 | |||
| 00412385c8 | |||
| 5a79c7cbe0 | |||
| da8a5e1dde | |||
| e3b16a3c5b | |||
| a5849fc747 | |||
| 42430e510d | |||
| 09c364b060 | |||
| d96361c578 | |||
| 1346112f36 | |||
| 44b1019d98 | |||
| b4c5db0c0e | |||
| fcfcb381a8 | |||
| 3f3d99e25a | |||
| c957041cf3 | |||
| a56b4839c8 | |||
| 5768dc9183 | |||
| e871229248 | |||
| 141166cdc8 | |||
| b99590bc5e | |||
| 6684efd146 | |||
| b75d8dc16b | |||
| 4a4e6e4398 | |||
| 9054decb16 | |||
| 3f8d6a6c56 | |||
| 3708730c7d | |||
| 40c3e1d025 | |||
| 6242940985 | |||
| 88fd6a74d8 | |||
| fe800401ad | |||
| 2c0e32a039 | |||
| f9f9a8b0d2 | |||
| 480e0aa97f | |||
| e66ab53562 | |||
| da0bffdac2 | |||
| 7afbfb4307 | |||
| ee5d3415ac | |||
| b2f4cc3583 | |||
| e21ee2e4fc | |||
| 8923aa87e2 | |||
| 527b31247b | |||
| 864057f382 | |||
| 7440b2d620 | |||
| f48ba562ea | |||
| c91bdc1d89 | |||
| c7b3305ef4 | |||
| 09c904917d | |||
| 3e099bb08d | |||
| 0e99250a3b | |||
| 9be5650dcd | |||
| 3efdcd5a63 | |||
| fec7021a7f | |||
| 0940358fba | |||
| 50637a4dc1 | |||
| 89a3562a1e | |||
| e9def50a85 | |||
| 2852590e09 | |||
| e883309791 | |||
| bd68a32708 | |||
| 7675d010c2 | |||
| a2f088f86a | |||
| de9a7b0c39 | |||
| c25d772bca | |||
| 75f1b14551 | |||
| 9d914a430c | |||
| aa8541298e | |||
| 4fdbb4d960 | |||
| cb48434f96 | |||
| f4f8e49627 | |||
| 2602182bb7 | |||
| ca39448605 | |||
| 841d10c39c | |||
| f12e2a72da | |||
| dec3d04ca5 | |||
| ca581e37c2 | |||
| 8353f125ff | |||
| dd00cbff24 | |||
| c98b738290 | |||
| f2a8cd75b9 | |||
| b5c941f9fb | |||
| 9cdbb7c9e8 | |||
| 0c9da915ef | |||
| 0ba6bacaf5 | |||
| 3f02fb83f9 | |||
| cd2afb8300 | |||
| 9120cff708 | |||
| 482dca78ec | |||
| 10fc3bf0a7 | |||
| d3462f42dc | |||
| 357d108c7e | |||
| 755f3b9fb0 | |||
| 7aaf9f1cad | |||
| 9be0c22b03 | |||
| a55233fdb1 | |||
| 50e9aee290 | |||
| 97aacd96aa | |||
| 30adbdc947 | |||
| 6b52926da1 | |||
| e14c727568 | |||
| 7d69f48bf6 | |||
| 1d9cd2cd3f | |||
| 8ab7be43dd | |||
| 0c6479f17e | |||
| 4eaec7fead | |||
| 94ca6d162f | |||
| f351443049 | |||
| 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 | |||
| 348bbf6522 | |||
| 9aa7366c74 | |||
| f68f257234 | |||
| 360a8c88e3 | |||
| feca8bc357 | |||
| 5080970366 | |||
| be4a741a73 | |||
| 589fb8ebba | |||
| 0156a82629 | |||
| 8497d87238 | |||
| 787e0f6902 | |||
| 6ac7bdf826 | |||
| d1ca846d30 | |||
| 0240e77bf9 | |||
| cfcc4b8858 | |||
| b3b7bdd20c | |||
| cbfd4a1f60 | |||
| 2a2ebd6a46 | |||
| ef7af83e5d | |||
| b5b7424472 | |||
| 3805bf39a5 | |||
| 008f3979e1 | |||
| 01980918bc | |||
| ca63c21080 | |||
| 0d637a55b1 | |||
| bddfe4b838 | |||
| 664a555fbd | |||
| 4d00ba9542 | |||
| 12c7676882 | |||
| ea99fdf288 | |||
| 8411fb997d | |||
| 3cc1e1dcec | |||
| 56650efe74 | |||
| ef64668fac | |||
| ce4550cae5 | |||
| d951aab997 | |||
| 3dac492e31 | |||
| 907370e270 | |||
| 1eeaf4c10e | |||
| c5140bf118 | |||
| f0f54d76c5 | |||
| 819d0a88f1 | |||
| 08e61eea89 | |||
| 273469eda8 | |||
| 97a219aa8c | |||
| 5dafdf85f7 | |||
| 7830269ea1 | |||
| 118b0c11ab | |||
| 4ad0a9cfb4 | |||
| 3e5840b9a2 | |||
| ae622909f3 | |||
| c23af72da7 | |||
| bfee3dfdf1 | |||
| b29f7ec4d5 | |||
| a42e5f085e | |||
| cc655891d5 | |||
| 708c25d938 | |||
| 5fa021329e | |||
| a7cd13228b | |||
| ef100bfac1 | |||
| c82b256128 | |||
| a5c52c72be | |||
| 865a472ef1 | |||
| 85b8e68f52 | |||
| c26aa709d0 | |||
| 618655e921 | |||
| e1d4939c81 | |||
| 8c83758461 | |||
| da1d872dd7 | |||
| 70f74c6f9d | |||
| 556af013db | |||
| b7a128ad28 | |||
| c17be3d191 | |||
| e2d3a164a6 | |||
| 88d2fdd904 | |||
| 6929097466 | |||
| 52dae96a61 | |||
| c82c6f4179 | |||
| 436324fe8f | |||
| d0a11e266f | |||
| 5bf99176bb | |||
| 9c20102dad | |||
| 8b311bde81 | |||
| b4e42778fa | |||
| 986adeb901 | |||
| 1ce9beeaf5 | |||
| 0c389397d2 | |||
| 7254f40fc9 | |||
| e704399c3d | |||
| d1ae988024 | |||
| 27736c7047 | |||
| 6f68153306 | |||
| 3260350377 | |||
| 03d174e5cc | |||
| 243ce98dd4 | |||
| f14316f024 | |||
| 399a3586b2 | |||
| 3bba781f49 | |||
| 91fe272bea | |||
| 1ffa5289ba | |||
| 6d51f6eeac | |||
| bd6eb18022 | |||
| 0618a1ca13 | |||
| 3fe1256381 | |||
| 1bce67d21d | |||
| 00fa9cad57 | |||
| 5f2e88c0f3 | |||
| 9b9abaa855 | |||
| 55fe82adf9 | |||
| 81a91f033b | |||
| 06b53dbc82 | |||
| bf6788c141 | |||
| 363e39d72c | |||
| e2ce575b25 | |||
| 36373400f8 | |||
| e12d8eebdd | |||
| 711a9527e9 | |||
| 91de4f80d8 | |||
| ced5d00163 | |||
| b6dffa9828 | |||
| 5a94ef10d7 | |||
| ec9f57476d | |||
| 6a60612ba6 | |||
| 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 | |||
| 945ae3b126 | |||
| a23a470eac | |||
| 2ee979afc0 | |||
| ba996d9878 | |||
| e0e2300521 | |||
| 0f0ea01f9a | |||
| a56860a6ce | |||
| 9550094ffb | |||
| 3aa08ba93e | |||
| 9837c23a96 | |||
| 71918f8381 | |||
| 99fefdda67 | |||
| dabe3c1687 | |||
| 1caf911f53 | |||
| c3f0e9d3fa | |||
| bc39c99d07 | |||
| 377b536456 | |||
| bf0fde9d06 | |||
| fb5278b891 | |||
| a27ee3af86 | |||
| 7073cadb43 | |||
| 2dfb880566 | |||
| 13d4f667b6 | |||
| d73460a617 | |||
| ec9b6c43be | |||
| 0d3b8ed23d | |||
| a61925b821 | |||
| cbfbca063e | |||
| f3393b2cc8 | |||
| 2eb643f422 | |||
| e22dbbe85c | |||
| e01ed039fb | |||
| 17cdb87723 | |||
| a55ff61669 | |||
| 5c215aeec5 | |||
| 591ab57352 | |||
| cb42b1b6a3 | |||
| 3039c46565 | |||
| 2d74088b25 | |||
| 2b9dd6ed6a | |||
| 8ccc2c4a7a | |||
| 2d52aa8a56 | |||
| 02b83be58e | |||
| 8c3371e968 | |||
| 1a106545f7 | |||
| 86c4594cdd | |||
| 6d157c0a65 | |||
| 43c75175f4 | |||
| ffa1094f93 | |||
| e890e913f5 | |||
| 12a4966b84 | |||
| b68ea276db | |||
| cc702027b0 | |||
| 328c858e4e | |||
| dcf77aac2a | |||
| cdf3391aad | |||
| 787446b4ee | |||
| 5febdb2d7d | |||
| 005f40b536 | |||
| 01a6012a0a | |||
| c009eb4d5c | |||
| 9bdfa1a485 | |||
| 6742792e90 | |||
| 8f6d52a9f9 | |||
| 51a25919c7 | |||
| 1405b5e2c2 | |||
| 8b3b412b16 | |||
| bbcefbb79e | |||
| 83f2f1de7e | |||
| 3dd77c2fcc | |||
| b4b94698b4 | |||
| 7fa751492b | |||
| 496dfd48e0 | |||
| b51b11063f | |||
| bae285dd8f | |||
| d628619eca | |||
| 4ffa3119a7 | |||
| dbf7ed9bb2 | |||
| edc4163852 | |||
| 08cc77dbdc | |||
| 8f5f33560e | |||
| 41392d9299 | |||
| 4623438652 | |||
| 6948938768 | |||
| db9cdd04c5 | |||
| 528cf905fb | |||
| 2c08bcd94a | |||
| 9de3fa7112 | |||
| 28027cd7b2 | |||
| e54fad61ae | |||
| 31189801f8 | |||
| d579e91bbd | |||
| 27133d69f2 | |||
| 5e895e59ae | |||
| c5f9f8be6c | |||
| 1a58875418 | |||
| 8ee6388ab8 | |||
| 5878b8ad5f | |||
| ec4359f1aa | |||
| f217394012 | |||
| 32908f7b4f | |||
| bd333b9584 | |||
| 3ac1dc6b0a | |||
| 025ecd8645 | |||
| 0fca39a1bd | |||
| 3152f7f0ec | |||
| 4764202a44 | |||
| 3d951cdaea | |||
| 7cba044b9d | |||
| 4245b2aede | |||
| 3cdec3ceb6 | |||
| aa8f7539ae | |||
| aadd2908e2 | |||
| c6b3cb8758 | |||
| 59f68efdc7 | |||
| dc81585f9a | |||
| 54e6c964db | |||
| dceda199c3 | |||
| 8967012035 | |||
| 0b73d4aac5 | |||
| 6f53f7ad99 | |||
| 399df4da4d | |||
| c06a66ade4 | |||
| 1fca26ae2e | |||
| ccd8f213f6 | |||
| 1c25702453 | |||
| 357ba7d8c8 | |||
| 6dc7fb7ade | |||
| d256acdef3 | |||
| 98e0273bdb | |||
| e26407d740 | |||
| b42f12ce77 | |||
| 7a10e4a406 | |||
| eda18d8b93 | |||
| 70809a8c7c | |||
| 5b15300f23 | |||
| 8585dd4833 | |||
| 12bda76526 | |||
| 173f789242 | |||
| 5c8c33747e | |||
| 07a9b956cb | |||
| 0e7f847de0 | |||
| 4998ea8f5d | |||
| 0cc81cd35f | |||
| ed09c8947d | |||
| 2e79d93806 | |||
| f05097087b | |||
| 5cb731e557 | |||
| 5660a1cb1b | |||
| aa618edc43 | |||
| c49afc7add | |||
| 64bac10758 | |||
| e74cd1efbb | |||
| 773592f9dd | |||
| 995088842a | |||
| 4abc45a849 | |||
| 5ce2d3d8b4 | |||
| 4391743695 | |||
| a145f92bcb | |||
| 2c853ff02a | |||
| c8d46b3611 | |||
| a75fef039d | |||
| cf6fcc353c | |||
| 2fbc9e0409 | |||
| 313222d12e | |||
| 46ba6978dd | |||
| f4363dcbff |
@@ -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://ditto.pub"
|
||||
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,13 +26,17 @@ test:
|
||||
script:
|
||||
- npm run test
|
||||
|
||||
# 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:
|
||||
stage: deploy
|
||||
timeout: 10 minutes
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
- when: never
|
||||
# rules:
|
||||
# - if: $CI_COMMIT_TAG
|
||||
# when: never
|
||||
# - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
variables:
|
||||
NSYTE_VERSION: "v0.24.1"
|
||||
script:
|
||||
@@ -50,7 +54,7 @@ deploy-nsite:
|
||||
nsyte deploy ./dist
|
||||
-i
|
||||
--sec "$NSITE_NBUNKSEC"
|
||||
--name ditto
|
||||
--name agora
|
||||
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
|
||||
--fallback "/index.html"
|
||||
@@ -73,6 +77,39 @@ build-web:
|
||||
paths:
|
||||
- dist/
|
||||
|
||||
release-notes:
|
||||
stage: build
|
||||
timeout: 2 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
# 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 "Ditto 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:
|
||||
- artifacts/release-notes.md
|
||||
- artifacts/release-notes-summary.txt
|
||||
expire_in: 90 days
|
||||
|
||||
build-apk:
|
||||
stage: build
|
||||
image: eclipse-temurin:21-jdk
|
||||
@@ -154,24 +191,24 @@ build-apk:
|
||||
|
||||
# Copy APK to a predictable artifact path
|
||||
- mkdir -p artifacts
|
||||
- cp android/app/build/outputs/apk/release/app-release.apk "artifacts/Ditto.apk"
|
||||
- cp android/app/build/outputs/bundle/release/app-release.aab "artifacts/Ditto.aab"
|
||||
- cp android/app/build/outputs/apk/release/app-release.apk "artifacts/Agora.apk"
|
||||
- cp android/app/build/outputs/bundle/release/app-release.aab "artifacts/Agora.aab"
|
||||
- ls -lh artifacts/
|
||||
|
||||
# Upload to Generic Packages registry for a stable public download URL
|
||||
- |
|
||||
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||
--upload-file "artifacts/Ditto.apk" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk"
|
||||
--upload-file "artifacts/Agora.apk" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.apk"
|
||||
- |
|
||||
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||
--upload-file "artifacts/Ditto.aab" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab"
|
||||
--upload-file "artifacts/Agora.aab" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab"
|
||||
|
||||
artifacts:
|
||||
paths:
|
||||
- artifacts/Ditto.apk
|
||||
- artifacts/Ditto.aab
|
||||
- artifacts/Agora.apk
|
||||
- artifacts/Agora.aab
|
||||
expire_in: 90 days
|
||||
cache:
|
||||
key: android-gradle
|
||||
@@ -179,35 +216,109 @@ 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/Ditto.ipa relative to the project root.
|
||||
- cd ios
|
||||
- fastlane build_ipa
|
||||
- cd ..
|
||||
|
||||
# Move the IPA to a stable name in the artifact directory.
|
||||
- ls -lh artifacts/
|
||||
- test -f artifacts/Ditto.ipa
|
||||
|
||||
# Upload to the Generic Packages registry for a stable public download URL,
|
||||
# mirroring how build-apk publishes the APK and AAB.
|
||||
- |
|
||||
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||
--upload-file "artifacts/Ditto.ipa" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa"
|
||||
after_script:
|
||||
# Wipe the API key so nothing sensitive sticks around between jobs.
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
artifacts:
|
||||
paths:
|
||||
- artifacts/Ditto.ipa
|
||||
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="Ditto ${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: Ditto-${CI_COMMIT_TAG}.apk
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk
|
||||
- name: Agora-${CI_COMMIT_TAG}.apk
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.apk
|
||||
link_type: package
|
||||
- name: Ditto-${CI_COMMIT_TAG}.aab
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab
|
||||
- name: Agora-${CI_COMMIT_TAG}.aab
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab
|
||||
link_type: package
|
||||
- name: Ditto-${CI_COMMIT_TAG}.ipa
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa
|
||||
link_type: package
|
||||
|
||||
publish-zapstore:
|
||||
@@ -219,7 +330,7 @@ publish-zapstore:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
SIGN_WITH: $ZAPSTORE_BUNKER_URL
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
BLOSSOM_URL: "https://blossom.ditto.pub"
|
||||
script:
|
||||
- go install github.com/zapstore/zsp@latest
|
||||
@@ -230,8 +341,109 @@ publish-zapstore:
|
||||
- mkdir -p ~/.config/zsp/bunker-keys
|
||||
- echo "$ZAPSTORE_CLIENT_KEY" > ~/.config/zsp/bunker-keys/${BUNKER_PUBKEY}.key
|
||||
|
||||
- APK_PATH="artifacts/Ditto.apk"
|
||||
- APK_PATH="artifacts/Agora.apk"
|
||||
- VERSION="${CI_COMMIT_TAG#v}"
|
||||
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
|
||||
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
|
||||
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
|
||||
|
||||
publish-google-play:
|
||||
stage: publish
|
||||
image: ruby:3.3
|
||||
needs:
|
||||
- job: build-apk
|
||||
artifacts: true
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
- gem install fastlane --no-document
|
||||
|
||||
# Decode base64-encoded service account JSON to a temp file
|
||||
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
|
||||
|
||||
# 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
|
||||
--track production
|
||||
--json_key /tmp/play-service-account.json
|
||||
--metadata_path android/fastlane/metadata/android
|
||||
--skip_upload_metadata
|
||||
--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/Ditto.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/Ditto.ipa"
|
||||
- cd ios
|
||||
- fastlane submit_release
|
||||
after_script:
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Thanks for contributing to Ditto! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
|
||||
Thanks for contributing to Agora! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
|
||||
|
||||
## Related Issue
|
||||
|
||||
@@ -29,9 +29,9 @@ Closes #
|
||||
## Philosophy Alignment
|
||||
|
||||
<!-- Answer this question for your change: -->
|
||||
<!-- "Does this make Ditto more magnetic, more threatening to the status quo, -->
|
||||
<!-- "Does this make Agora more magnetic, more threatening to the status quo, -->
|
||||
<!-- and more peaceful to inhabit?" -->
|
||||
<!-- See: https://about.ditto.pub/philosophy -->
|
||||
<!-- See: CONTRIBUTING.md -> "Understanding Agora" -->
|
||||
<!-- For bug fixes: "Bug fix -- restores intended behavior" is acceptable. -->
|
||||
|
||||
## How to Test
|
||||
@@ -50,7 +50,7 @@ Closes #
|
||||
### Process
|
||||
|
||||
- [ ] I read `AGENTS.md` before starting
|
||||
- [ ] I read the [Ditto Philosophy](https://about.ditto.pub/philosophy)
|
||||
- [ ] I read "Understanding Agora" in `CONTRIBUTING.md`
|
||||
- [ ] I used plan/research mode before writing code
|
||||
- [ ] I used Claude Opus 4.6 (or equivalent frontier model)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"editor.tabSize": 2
|
||||
"editor.tabSize": 2,
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
@@ -1,365 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- Manage your interest tabs (hashtags and locations) from the settings page
|
||||
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
|
||||
- Follow packs and follow sets now show author info and action headers in the feed
|
||||
- Posts now show whether they were created or updated, so you can tell when something's been edited
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
|
||||
- Nsite previews now use the same secure sandbox as webxdc apps
|
||||
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
|
||||
|
||||
### Fixed
|
||||
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
|
||||
- Mobile compose box no longer randomly collapses or becomes unclickable
|
||||
- Profile avatar and banner lightbox no longer hides behind the right sidebar
|
||||
- Infinite scroll on custom profile tab feeds no longer reloads the same content
|
||||
- Reaction emoji are now visible on each row in the interactions modal
|
||||
- Missing bottom border on collapsed thread expand button restored
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
|
||||
- Poll votes now appear as activity cards in feeds and on detail pages
|
||||
|
||||
### Fixed
|
||||
- Threads and replies load more reliably by following relay and author hints when fetching parent events
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
|
||||
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
|
||||
|
||||
### Changed
|
||||
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
|
||||
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
|
||||
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
|
||||
|
||||
### Fixed
|
||||
- Notification dot not clearing after marking notifications as read
|
||||
- Followers/following modal staying open after navigating to a profile
|
||||
|
||||
## [2.2.7] - 2026-03-31
|
||||
|
||||
### Fixed
|
||||
- Nushu script in encrypted letters now renders correctly on Android and iOS
|
||||
|
||||
## [2.2.6] - 2026-03-31
|
||||
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
- Badge detail page streamlined with unified tab bar
|
||||
|
||||
### Fixed
|
||||
- Hashtags now support accented and Unicode characters
|
||||
- Letter compose opens correctly from notifications and the letters page
|
||||
- Letter font picker loads fonts so each option previews in the correct typeface
|
||||
- Zap comment positioned inside the right column instead of floating with offset
|
||||
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
|
||||
|
||||
## [2.2.5] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Crash when dragging profile tabs to reorder them
|
||||
|
||||
## [2.2.4] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
|
||||
- Zap moved to the profile overflow menu so it's still one tap away
|
||||
|
||||
### Fixed
|
||||
- Crash on the notifications page caused by malformed badge award tags
|
||||
- Deleting a badge now also deletes all awards you issued for it
|
||||
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
|
||||
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
|
||||
- Profile reactions no longer collapse into a single grouped notification
|
||||
- Oversized reaction emoji in comment context headers
|
||||
|
||||
## [2.2.3] - 2026-03-30
|
||||
|
||||
### Added
|
||||
- Letters now have an overflow menu, reply button, and a grid layout for browsing
|
||||
- Independent feed toggles for comments and generic reposts in content settings
|
||||
- Sidebar items are now visible to logged-out users so newcomers can explore everything
|
||||
|
||||
### Changed
|
||||
- Compose textarea expands smoothly as you type instead of snapping to a new height
|
||||
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
|
||||
|
||||
### Fixed
|
||||
- Feed gaps when replies are disabled no longer cause missing posts
|
||||
- Avatar shape no longer flashes on load
|
||||
- Top bar arc no longer flickers during navigation transitions
|
||||
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
|
||||
- Notification rendering for badges and letters
|
||||
- Duplicate React keys in content settings
|
||||
- Layout rendering warning when switching views
|
||||
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
- Changelog link in the app footer
|
||||
|
||||
### Changed
|
||||
- "Vines" renamed to "Divines" everywhere in the app
|
||||
- Custom emojis appear first in the emoji picker, right after recent
|
||||
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
|
||||
|
||||
### Fixed
|
||||
- Delete post dialog no longer freezes the feed on desktop
|
||||
- Amber login on Android now properly retries when returning from the background
|
||||
- Key downloads on Android save to the correct location
|
||||
- Custom emoji SVGs render correctly in the emoji-mart picker
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
- Inconsistent use of "geocache" vs "treasures" terminology
|
||||
- Search page "N new posts" pill no longer shows unfiltered count
|
||||
- Stale-cache overwrites in replaceable event mutations
|
||||
- Click-through on delete confirmation and note menu items
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
|
||||
- Mobile header no longer shows double-layered backgrounds on notched devices
|
||||
- Pinned tabs stay properly positioned when scrolling on mobile
|
||||
- Signer approval toasts no longer fire in rapid succession on unstable connections
|
||||
- Toasts are easier to swipe away on mobile
|
||||
- Content warnings now blur thumbnails in the media grid
|
||||
|
||||
## [2.2.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
### Changed
|
||||
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
|
||||
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
|
||||
- Upgraded from React 18 to React 19
|
||||
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
|
||||
|
||||
### Fixed
|
||||
- Zapping Primal users no longer produces an error
|
||||
- Hashtag feeds now match case-insensitively for parity with search results
|
||||
- Mobile top bar arc no longer lingers on pages without tabs
|
||||
- Give Badge dialog and profile menu action handlers
|
||||
|
||||
## [2.1.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- Emoji picker and shortcode autocomplete in zap comment box
|
||||
- Zap button on badge detail view
|
||||
- Theme descriptions now display on "updated their theme" posts and detail pages
|
||||
- Badge thumbnail previews in award notifications
|
||||
- Letter notifications with envelope card preview
|
||||
- Kind-specific labels in notification text instead of generic "post"
|
||||
|
||||
### Fixed
|
||||
- Compose modal no longer closes when dismissing emoji picker on mobile
|
||||
- Compose preview overflow is now scrollable in modal
|
||||
- Toast notifications swipe up to dismiss on mobile instead of sideways
|
||||
- File downloads and URL opening work correctly on iOS
|
||||
- Badges page no longer shows infinite skeleton when logged out
|
||||
|
||||
## [2.1.0] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
|
||||
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
|
||||
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
|
||||
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
|
||||
- Letters page added to the sidebar with a custom mailbox icon
|
||||
|
||||
## [2.0.1] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Tap the version number in settings to see what's new
|
||||
|
||||
## [2.0.0] - 2026-03-26
|
||||
|
||||
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
|
||||
- Initial Agora 3 release.
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
# Contributing to Ditto
|
||||
# Contributing to Agora
|
||||
|
||||
We welcome contributions, but we have high standards. Ditto is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
|
||||
We welcome contributions, but we have high standards. Agora is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
|
||||
|
||||
**Required reading before you start:**
|
||||
|
||||
- [Ditto Philosophy](https://about.ditto.pub/philosophy) -- the product vision. Your change must align with it.
|
||||
- [Contributing Guide](https://about.ditto.pub/contributing) -- the upstream contribution process.
|
||||
- [Understanding Agora](#understanding-agora) -- the product vision. Your change must align with it.
|
||||
- This `CONTRIBUTING.md` guide -- the contribution process for this repository.
|
||||
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
|
||||
|
||||
## Understanding Ditto
|
||||
## Understanding Agora
|
||||
|
||||
Ditto is a carnival, not a platform. Before contributing, you need to understand what that means.
|
||||
Agora is a carnival, not a platform. Before contributing, you need to understand what that means.
|
||||
|
||||
### The product decision filter
|
||||
|
||||
Every change to Ditto should pass this test:
|
||||
Every change to Agora should pass this test:
|
||||
|
||||
> *Does this make Ditto more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
|
||||
> *Does this make Agora more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
|
||||
|
||||
- **Magnetic** -- Ditto attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
|
||||
- **Threatening to the status quo** -- Ditto threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
|
||||
- **Peaceful to inhabit** -- Ditto displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
|
||||
- **Magnetic** -- Agora attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
|
||||
- **Threatening to the status quo** -- Agora threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
|
||||
- **Peaceful to inhabit** -- Agora displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
|
||||
|
||||
If a change does all three, it belongs. If it only does one, think harder. If it does none, it doesn't belong here.
|
||||
|
||||
### What Ditto is NOT
|
||||
### What Agora is NOT
|
||||
|
||||
- A Twitter/X clone with decentralization bolted on
|
||||
- A place to replicate features that mainstream platforms already do well
|
||||
- A showcase for generic UI components or boilerplate social features
|
||||
|
||||
### What Ditto IS
|
||||
### What Agora IS
|
||||
|
||||
- A convergence point for interoperable Nostr experiences (games, treasure hunts, magic decks, themes, color moments, live streams, and things nobody has imagined yet)
|
||||
- A place where profiles feel like worlds, not business cards
|
||||
- The most fun you've had on the internet in years
|
||||
|
||||
Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete vision.
|
||||
Read the full "Understanding Agora" section above for the complete vision.
|
||||
|
||||
## What we accept
|
||||
|
||||
@@ -44,17 +44,19 @@ Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete
|
||||
|
||||
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 [Ditto Philosophy](https://about.ditto.pub/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Ditto. If you can't articulate that clearly, the change probably doesn't belong.
|
||||
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.
|
||||
|
||||
If you have an idea for a feature that doesn't have an issue yet:
|
||||
|
||||
1. Build it as a standalone Nostr app first (see [Contributing Guide](https://about.ditto.pub/contributing)).
|
||||
1. Build it as a standalone Nostr app first (then document traction/feedback in the linked issue).
|
||||
2. Prove it works and get user feedback.
|
||||
3. Open an issue to discuss integration.
|
||||
|
||||
**Feature MRs that don't link to an issue or don't align with the Ditto Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
|
||||
**Feature MRs that don't link to an issue or don't align with the Agora Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
|
||||
|
||||
## Required tools
|
||||
|
||||
@@ -80,7 +82,7 @@ Read `AGENTS.md` in the repo root. This is the single source of truth for how co
|
||||
|
||||
### 4. Read the philosophy
|
||||
|
||||
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy). Ditto is a carnival, not a platform. Your change should feel like it belongs in Ditto -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
|
||||
Read "Understanding Agora" in this file. Agora is a carnival, not a platform. Your change should feel like it belongs in Agora -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
|
||||
|
||||
### 5. Plan before you code
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -163,7 +166,7 @@ Fill out every field in the MR template. Incomplete MRs will not be reviewed.
|
||||
## What gets your MR closed without review
|
||||
|
||||
- No linked issue
|
||||
- Feature MRs with no clear alignment with the [Ditto Philosophy](https://about.ditto.pub/philosophy)
|
||||
- Feature MRs with no clear alignment with "Understanding Agora" in this file
|
||||
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
|
||||
- Incomplete MR template (missing checklist, screenshots, or preview URL)
|
||||
- Changes that go beyond what was asked for (scope creep)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
COPY package*.json ./
|
||||
COPY .npmrc ./
|
||||
COPY scripts/ ./scripts/
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,24 +1,25 @@
|
||||
# Ditto
|
||||
# Agora
|
||||
|
||||
Your content. Your vibe. Your rules. A fun, customizable [Nostr](https://nostr.com/) client that puts you in control.
|
||||
Power to the people.
|
||||
|
||||
**[ditto.pub](https://ditto.pub)** | **[Docs](https://docs.ditto.pub)** | **[Source](https://gitlab.com/soapbox-pub/ditto)**
|
||||
Agora is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository (`agora-3`) is the Agora-branded app built from the Ditto codebase.
|
||||
|
||||
## About
|
||||
**[agora.spot](https://agora.spot)** | **[Source](https://gitlab.com/soapbox-pub/agora-3)**
|
||||
|
||||
Ditto is an open-source, decentralized social media client built on the Nostr protocol. It's designed for people who want to have fun online without feeding the Big Tech machine. Express yourself with custom themes, Lightning payments, and an ever-growing set of content types -- all while owning your identity and data.
|
||||
## What This Repo Is
|
||||
|
||||
Made by [Soapbox](https://soapbox.pub).
|
||||
- Agora product identity (name, theme, assets, native IDs)
|
||||
- Ditto-derived implementation with broad Nostr feature coverage
|
||||
- Configurable deployment defaults via `agora.json`
|
||||
|
||||
## Features
|
||||
|
||||
- **Theming** -- 9 built-in theme presets, 19 CSS token properties for full customization, and the ability to publish and share themes as Nostr events
|
||||
- **Infinite Content Types** -- Text notes, articles, short-form videos (Divines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
|
||||
- **Lightning Payments** -- Zap posts and profiles with sats via Nostr Wallet Connect (NWC) or WebLN
|
||||
- **Private Messaging** -- End-to-end encrypted DMs (NIP-04 and NIP-17)
|
||||
- **Comments** -- Comment on anything: posts, URLs, profiles, hashtags, books, and more (NIP-22)
|
||||
- **Self-Hosting** -- Builds to static HTML/JS/CSS. Deploy anywhere -- GitHub Pages, Netlify, Vercel, a VPS, or a Raspberry Pi
|
||||
- **Mobile** -- Android native app via Capacitor, responsive design for all screen sizes
|
||||
- **Community-first social client**: notes, articles, comments, reposts, reactions, and rich event rendering
|
||||
- **Theming system**: built-in presets + custom color/font/background themes that can be shared as events
|
||||
- **Lightning support**: zaps with Nostr Wallet Connect and WebLN
|
||||
- **Private messaging**: NIP-04 and NIP-17 direct messages
|
||||
- **Mobile app shell**: Capacitor-powered Android/iOS wrappers
|
||||
- **Self-hostable**: static web build + configurable relay and upload infrastructure
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -30,13 +31,43 @@ Made by [Soapbox](https://soapbox.pub).
|
||||
### Development
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.com/soapbox-pub/ditto.git
|
||||
cd ditto
|
||||
git clone https://gitlab.com/soapbox-pub/agora-3.git
|
||||
cd agora-3
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev server starts at `http://localhost:8080`.
|
||||
Development server: `http://localhost:8080`
|
||||
|
||||
### Docker Getting Started
|
||||
|
||||
Use Docker Compose when you want the nginx reverse-proxy stack (necessary if you want decryptable media in messages - kind 15s of NIP 17):
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.com/soapbox-pub/agora-3.git
|
||||
cd agora-3
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Proxy URL: `http://localhost:8083`
|
||||
|
||||
This starts:
|
||||
|
||||
- `vite` service on the internal Docker network (`vite:8080`)
|
||||
- `web` service (`nginx`) on host port `8082`, proxying to Vite with websocket support
|
||||
|
||||
Stop stack:
|
||||
|
||||
```sh
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Production-style container build:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.prod.yml up --build
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
@@ -44,66 +75,58 @@ The dev server starts at `http://localhost:8080`.
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built site is output to `dist/`.
|
||||
Build output: `dist/`
|
||||
|
||||
### Test
|
||||
|
||||
Runs type-checking, linting, unit tests, and a production build:
|
||||
### Validate
|
||||
|
||||
```sh
|
||||
npm test
|
||||
```
|
||||
|
||||
This runs type-checking, linting, unit tests, and production build checks.
|
||||
|
||||
## Configuration
|
||||
|
||||
Ditto is configured through a `ditto.json` file at the project root, read at build time. This file is gitignored so each deployment can have its own configuration.
|
||||
Build-time config is read from `agora.json` (gitignored by default so each deployment can provide its own values).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"theme": "dark",
|
||||
"relayMetadata": {
|
||||
"relays": [
|
||||
{ "url": "wss://relay.ditto.pub", "read": true, "write": true }
|
||||
{ "url": "wss://relay.ditto.pub", "read": true, "write": true },
|
||||
{ "url": "wss://relay.primal.net", "read": true, "write": true },
|
||||
{ "url": "wss://relay.damus.io", "read": true, "write": true }
|
||||
]
|
||||
},
|
||||
"blossomServers": ["https://blossom.ditto.pub"],
|
||||
"feedSettings": {
|
||||
"showPosts": true,
|
||||
"showReposts": true,
|
||||
"showArticles": true
|
||||
// ...and more content type toggles
|
||||
}
|
||||
"blossomServers": [
|
||||
"https://blossom.ditto.pub",
|
||||
"https://blossom.primal.net/"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Configuration is resolved in three layers (highest priority first):
|
||||
Configuration priority (highest first):
|
||||
|
||||
1. **User settings** stored in localStorage
|
||||
2. **Build config** from `ditto.json`
|
||||
3. **Hardcoded defaults**
|
||||
1. User settings (local storage)
|
||||
2. Build config (`agora.json`)
|
||||
3. Hardcoded app defaults
|
||||
|
||||
Use an alternate config file path with: `CONFIG_FILE=./my-config.json npm run build`
|
||||
Use a custom config path:
|
||||
|
||||
### Custom Branding
|
||||
|
||||
For self-hosted instances:
|
||||
|
||||
- Replace `public/logo.svg` and `public/logo.png` with your logo
|
||||
- Update the app name in `index.html` and `public/manifest.webmanifest`
|
||||
- Replace `public/og-image.jpg` for social sharing previews
|
||||
- Set default relays and upload servers in `ditto.json`
|
||||
```sh
|
||||
CONFIG_FILE=./my-config.json npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Ditto builds to static files and can be deployed anywhere that serves HTML.
|
||||
Agora builds to static files and can be deployed to any static host.
|
||||
|
||||
- **GitHub Pages / GitLab Pages** -- Push to `main` and CI auto-deploys
|
||||
- **Netlify / Vercel** -- Connect your fork and deploy. A `_redirects` file is included for SPA routing
|
||||
- **VPS / Any web server** -- Build and copy `dist/` to your server. Configure SPA routing (e.g., Nginx `try_files $uri $uri/ /index.html`)
|
||||
- GitLab/GitHub Pages
|
||||
- Netlify/Vercel
|
||||
- VPS or any web server with SPA routing fallback
|
||||
|
||||
### Android
|
||||
|
||||
Build a native Android app with [Capacitor](https://capacitorjs.com/):
|
||||
For Android:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
@@ -114,40 +137,20 @@ npx cap open android
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| Framework | React 18 |
|
||||
| Build | Vite |
|
||||
| Language | TypeScript |
|
||||
| Styling | TailwindCSS 3 + shadcn/ui |
|
||||
| Routing | React Router 6 |
|
||||
| Routing | React Router |
|
||||
| Data | TanStack Query |
|
||||
| Nostr | Nostrify + nostr-tools |
|
||||
| Mobile | Capacitor |
|
||||
| Testing | Vitest + React Testing Library |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
components/ UI components (100+), including shadcn/ui primitives
|
||||
hooks/ Custom React hooks (65+)
|
||||
pages/ Page components for each route (30+)
|
||||
contexts/ React context providers
|
||||
lib/ Utilities and shared logic
|
||||
test/ Test setup and helpers
|
||||
public/ Static assets, icons, manifest
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions but have high standards. Please read the full [Contributing Guide](CONTRIBUTING.md) before submitting a merge request. The short version:
|
||||
|
||||
- **Bug fixes**: One bug, one MR. Keep it small and focused.
|
||||
- **New features**: Must link to an existing issue and align with the [Ditto Philosophy](https://about.ditto.pub/philosophy).
|
||||
- **Required**: Live preview URL, before/after screenshots, completed self-review checklist.
|
||||
- **Required tools**: Claude Opus 4.6 (or latest frontier model), an AI coding agent with plan mode.
|
||||
|
||||
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy) to understand what Ditto is and isn't.
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a merge request.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "pub.ditto.app"
|
||||
namespace = "pub.agora.app"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "pub.ditto.app"
|
||||
applicationId "pub.agora.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.6.3"
|
||||
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.
|
||||
|
||||
@@ -11,10 +11,10 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-autofill-save-password')
|
||||
implementation project(':capacitor-secure-storage-plugin')
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -22,12 +24,12 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep links: open ditto.pub URLs in the app -->
|
||||
<!-- Deep links: open agora.spot URLs in the app -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="ditto.pub" />
|
||||
<data android:scheme="https" android:host="agora.spot" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
@@ -56,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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
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 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;
|
||||
|
||||
/**
|
||||
* 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 WebView 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.webView, params);
|
||||
|
||||
// Load the initial page.
|
||||
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.webView.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.webView.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.webView);
|
||||
}
|
||||
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;
|
||||
final WebView webView;
|
||||
final SandboxPlugin plugin;
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
this.plugin = plugin;
|
||||
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.WHITE);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
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 this thread until JS responds (with a timeout).
|
||||
WebResourceResponse response = pending.awaitResponse(10000);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 thread until resolved.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private WebResourceResponse response;
|
||||
private final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Ditto</string>
|
||||
<string name="title_activity_main">Ditto</string>
|
||||
<string name="package_name">pub.ditto.app</string>
|
||||
<string name="custom_url_scheme">pub.ditto.app</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Android Auto Backup rules (Android 11 and below).
|
||||
|
||||
Ditto excludes WebView storage (Local Storage, IndexedDB, databases) and
|
||||
any shared_prefs that hold sensitive credentials so they don't end up in
|
||||
Google Drive backups. Keychain/KeyStore entries used by
|
||||
capacitor-secure-storage-plugin are not backed up by default, so we don't
|
||||
need to exclude those explicitly; but we also exclude the plugin's
|
||||
SharedPreferences for defense in depth.
|
||||
|
||||
See: https://developer.android.com/guide/topics/data/autobackup
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!-- WebView: localStorage, IndexedDB, cookies, caches -->
|
||||
<exclude domain="file" path="app_webview/" />
|
||||
<exclude domain="database" path="webview.db" />
|
||||
<exclude domain="database" path="webviewCache.db" />
|
||||
|
||||
<!-- capacitor-secure-storage-plugin fallback SharedPreferences -->
|
||||
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
|
||||
|
||||
<!-- Capacitor preferences plugin — may contain app-level settings -->
|
||||
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Android 12+ data extraction rules.
|
||||
|
||||
Separate rules apply to cloud backups (Google Drive) and device-to-device
|
||||
transfers. Both exclude WebView storage and sensitive SharedPreferences so
|
||||
wallet credentials, login tokens, and cached private data don't leak.
|
||||
|
||||
See: https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="file" path="app_webview/" />
|
||||
<exclude domain="database" path="webview.db" />
|
||||
<exclude domain="database" path="webviewCache.db" />
|
||||
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
|
||||
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="file" path="app_webview/" />
|
||||
<exclude domain="database" path="webview.db" />
|
||||
<exclude domain="database" path="webviewCache.db" />
|
||||
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
|
||||
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-haptics'
|
||||
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
|
||||
|
||||
include ':capacitor-keyboard'
|
||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
|
||||
|
||||
@@ -17,9 +20,6 @@ project(':capacitor-local-notifications').projectDir = new File('../node_modules
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
include ':capgo-capacitor-autofill-save-password'
|
||||
project(':capgo-capacitor-autofill-save-password').projectDir = new File('../node_modules/@capgo/capacitor-autofill-save-password/android')
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'pub.ditto.app',
|
||||
appName: 'Ditto',
|
||||
appId: 'pub.agora.app',
|
||||
appName: 'Agora',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
// Handle deep links from your domain
|
||||
hostname: 'ditto.pub',
|
||||
androidScheme: 'https',
|
||||
iosScheme: 'https'
|
||||
},
|
||||
@@ -18,11 +16,13 @@ const config: CapacitorConfig = {
|
||||
ios: {
|
||||
backgroundColor: '#14161f',
|
||||
contentInset: 'never',
|
||||
scheme: 'Ditto'
|
||||
scheme: 'Agora'
|
||||
},
|
||||
plugins: {
|
||||
Keyboard: {
|
||||
resizeOnFullScreen: true,
|
||||
SystemBars: {
|
||||
// Inject --safe-area-inset-* CSS variables on Android to work around
|
||||
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
|
||||
insetsHandling: 'css',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8083:80"
|
||||
volumes:
|
||||
- ./nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./dist:/usr/share/nginx/html:ro
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- vite
|
||||
networks:
|
||||
- agora-network
|
||||
|
||||
vite:
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
# Use host node_modules so new dependencies are picked up after install.
|
||||
command: sh -c "npm install && npm run dev"
|
||||
volumes:
|
||||
- .:/app
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
networks:
|
||||
- agora-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
agora-network:
|
||||
driver: bridge
|
||||
@@ -1,383 +0,0 @@
|
||||
# Theme System
|
||||
|
||||
This document describes the two separate but overlapping theme features in Ditto: the **App Theme** (which controls the local UI) and the **Profile Theme** (which is published to Nostr for others to see). Understanding the distinction is key to working with this codebase.
|
||||
|
||||
## Overview
|
||||
|
||||
| Concept | Purpose | Scope | Persistence |
|
||||
|---|---|---|---|
|
||||
| **App Theme** | Controls colors, fonts, and background of the local UI | Local to the user's browser | localStorage + encrypted NIP-78 sync |
|
||||
| **Profile Theme** | A set of theme values published as a Nostr event | Public, visible to other users | Kind 16767 replaceable event |
|
||||
|
||||
The App Theme and Profile Theme share the same underlying data structure (`ThemeConfig`), and there is an optional bridge between them (`autoShareTheme`), but they are fundamentally independent systems.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: App Theme
|
||||
|
||||
The App Theme controls what the user sees in their own browser. It has no inherent connection to Nostr.
|
||||
|
||||
### Core Concept: 3 Colors Define Everything
|
||||
|
||||
The entire theme is derived from just 3 core colors, defined by the `CoreThemeColors` interface in `src/themes.ts:8`:
|
||||
|
||||
```typescript
|
||||
interface CoreThemeColors {
|
||||
background: string; // HSL string, e.g. "228 20% 10%"
|
||||
text: string; // Text/foreground color
|
||||
primary: string; // Primary accent (buttons, links, focus rings)
|
||||
}
|
||||
```
|
||||
|
||||
From these 3 values, the system auto-derives 19 CSS tokens (the full `ThemeTokens` set) via `deriveTokensFromCore()` in `src/lib/colorUtils.ts:141`. The derivation algorithm:
|
||||
|
||||
- Detects dark/light mode from background luminance (threshold: 0.2)
|
||||
- Derives `card` and `popover` surfaces by slightly lightening the background (dark mode) or using it directly (light mode)
|
||||
- Derives `secondary` and `muted` surfaces by adjusting background lightness
|
||||
- Derives `border` using the primary hue with reduced saturation
|
||||
- Computes `mutedForeground` as a dimmer version of the text color
|
||||
- Sets `accent = primary` and `ring = primary`
|
||||
- Auto-computes `primaryForeground` using WCAG contrast detection (white or dark)
|
||||
- Uses fixed red values for `destructive` / `destructiveForeground`
|
||||
|
||||
### Theme Modes
|
||||
|
||||
The `Theme` type (`src/contexts/AppContext.ts:9`) has four values:
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| `"light"` | Uses the builtin (or configured) light color set |
|
||||
| `"dark"` | Uses the builtin (or configured) dark color set |
|
||||
| `"system"` | Resolves to `"light"` or `"dark"` based on `prefers-color-scheme`, with a live media query listener |
|
||||
| `"custom"` | Uses user-defined colors stored in `config.customTheme` |
|
||||
|
||||
**Builtin themes** are defined in `src/themes.ts:102`:
|
||||
|
||||
```typescript
|
||||
const builtinThemes = {
|
||||
light: { background: '270 50% 97%', text: '270 25% 12%', primary: '270 65% 55%' },
|
||||
dark: { background: '228 20% 10%', text: '210 40% 98%', primary: '258 70% 60%' },
|
||||
};
|
||||
```
|
||||
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
|
||||
### ThemeConfig
|
||||
|
||||
The `ThemeConfig` type (`src/themes.ts:50`) wraps the 3 core colors with optional extras:
|
||||
|
||||
```typescript
|
||||
interface ThemeConfig {
|
||||
title?: string;
|
||||
colors: CoreThemeColors;
|
||||
font?: ThemeFont; // { family: string; url?: string }
|
||||
background?: ThemeBackground; // { url: string; mode?: 'cover' | 'tile'; ... }
|
||||
}
|
||||
```
|
||||
|
||||
This is the canonical type used everywhere: in `AppConfig.customTheme`, in encrypted settings, and in Nostr theme events.
|
||||
|
||||
### Theme Presets
|
||||
|
||||
Named presets are defined in `src/themes.ts:136` (e.g. `pink`, `toxic`, `sunset`). Each preset includes core colors and optionally a font and background image. Applying a preset sets the app theme to `"custom"` and stores the preset's config as `customTheme`.
|
||||
|
||||
### How Themes Apply to the DOM
|
||||
|
||||
The theme pipeline has three stages designed to prevent any flash of wrong colors:
|
||||
|
||||
#### Stage 1: Pre-React Blocking Script (`public/theme.js`)
|
||||
|
||||
A synchronous `<script>` tag in `index.html:43` runs before React mounts. It:
|
||||
|
||||
1. Reads `nostr:app-config` from localStorage
|
||||
2. Resolves `"system"` via `matchMedia`
|
||||
3. Handles legacy presets (`"black"`, `"pink"`)
|
||||
4. Sets `document.documentElement.className` to the theme name
|
||||
5. Sets `document.body.style.background` to the correct background color
|
||||
6. Updates preloader colors (logo and spinner) to match
|
||||
|
||||
This prevents any visible flash between the hardcoded dark defaults in `index.html:32` and the user's actual theme.
|
||||
|
||||
#### Stage 2: React Provider (`src/components/AppProvider.tsx`)
|
||||
|
||||
Three private hooks run during the provider's lifecycle:
|
||||
|
||||
**`useApplyTheme`** (line 91) - Uses `useLayoutEffect` (synchronous before paint) to:
|
||||
- Resolve the theme mode
|
||||
- Build a full CSS string from `CoreThemeColors` via `buildThemeCssFromCore()`
|
||||
- Inject/update a `<style id="theme-vars">` element with all 19 CSS custom properties
|
||||
- Set `document.documentElement.className` to the resolved theme
|
||||
- Remove the inline body style left by `theme.js`
|
||||
- When mode is `"system"`, attach a `matchMedia` change listener
|
||||
|
||||
**`useApplyFonts`** (line 133) - Loads and applies custom fonts via `loadAndApplyFont()` from `src/lib/fontLoader.ts`.
|
||||
|
||||
**`useApplyBackground`** (line 156) - Injects/removes a `<style id="theme-background">` for background images (cover or tile mode).
|
||||
|
||||
#### Stage 3: Theme Switch (`src/hooks/useTheme.ts`)
|
||||
|
||||
The `setTheme()` function (line 52) performs a flicker-free theme switch:
|
||||
|
||||
1. Injects a temporary `<style>` that disables all CSS transitions (`transition: none !important`)
|
||||
2. Synchronously builds and applies CSS vars before React re-renders
|
||||
3. Updates `document.documentElement.className`
|
||||
4. Re-enables transitions after browser paint via `requestAnimationFrame`
|
||||
5. Updates localStorage config
|
||||
6. Debounce-syncs to encrypted NIP-78 storage (1 second delay)
|
||||
|
||||
### How Components Consume Theme Values
|
||||
|
||||
#### CSS Custom Properties to Tailwind
|
||||
|
||||
`tailwind.config.ts` maps all 19 CSS custom properties to Tailwind color utilities:
|
||||
|
||||
```typescript
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
|
||||
// ... (secondary, destructive, muted, accent, popover, card, border, input, ring)
|
||||
}
|
||||
```
|
||||
|
||||
Components use standard Tailwind classes like `bg-primary`, `text-foreground`, `border-border`, etc. These resolve to `hsl(var(--primary))`, which picks up whichever values are currently set on `:root`.
|
||||
|
||||
The `cn()` utility in `src/lib/utils.ts` combines `clsx` (conditional class joining) with `tailwind-merge` (intelligent Tailwind class deduplication).
|
||||
|
||||
#### Static CSS
|
||||
|
||||
`src/index.css` applies base styles using theme tokens:
|
||||
|
||||
```css
|
||||
* { @apply border-border; }
|
||||
body { @apply bg-background text-foreground; }
|
||||
```
|
||||
|
||||
The only static CSS custom property is `--radius: 0.75rem`. All color variables are injected dynamically.
|
||||
|
||||
### ScopedTheme
|
||||
|
||||
The `ScopedTheme` component (`src/components/ScopedTheme.tsx`) applies a different set of theme colors to a DOM subtree by setting CSS variables as inline `style`:
|
||||
|
||||
```tsx
|
||||
<ScopedTheme colors={someColors} className="rounded-lg p-4">
|
||||
{/* Children here see different --background, --primary, etc. */}
|
||||
</ScopedTheme>
|
||||
```
|
||||
|
||||
It also sets `data-theme-mode="dark"` or `"light"` based on background luminance, for CSS targeting.
|
||||
|
||||
### App Theme Persistence
|
||||
|
||||
#### Layer 1: localStorage (immediate)
|
||||
|
||||
The `useLocalStorage` hook (`src/hooks/useLocalStorage.ts`) stores the full `AppConfig` under key `"nostr:app-config"`. This includes `theme`, `customTheme`, `autoShareTheme`, and `themes`. Changes are reflected immediately and support cross-tab sync via `StorageEvent`.
|
||||
|
||||
#### Layer 2: Encrypted NIP-78 Settings (cross-device sync)
|
||||
|
||||
The `useEncryptedSettings` hook (`src/hooks/useEncryptedSettings.ts`) stores theme preferences in a kind 30078 addressable event, encrypted to self via NIP-44. The `EncryptedSettings` interface includes `theme`, `customTheme`, and `autoShareTheme` among other app settings.
|
||||
|
||||
Key behaviors:
|
||||
- Query is delayed 5 seconds after login to avoid competing with feed load
|
||||
- Uses optimistic updates with a `pendingSettings` ref for rapid successive mutations
|
||||
- A `recentlyWritten()` guard returns true for 10 seconds after a local write to prevent `NostrSync` from overwriting the value that was just saved
|
||||
|
||||
#### Sync via NostrSync
|
||||
|
||||
The `NostrSync` component (`src/components/NostrSync.tsx`) runs globally and syncs encrypted settings from Nostr on login. For theme-related fields, it:
|
||||
|
||||
1. Seeds a `lastSyncedTimestamp` ref on first load to prevent stale events from overwriting local config
|
||||
2. Skips application if `recentlyWritten()` is true
|
||||
3. Only applies changes if the remote timestamp is newer
|
||||
4. Handles legacy theme value migration (`"black"`, `"pink"` to `"custom"`)
|
||||
5. Diffs each field individually to avoid unnecessary re-renders
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Profile Theme
|
||||
|
||||
The Profile Theme is a public Nostr event that represents a user's chosen theme. Other clients can read it to style that user's profile page, or users can browse and copy each other's themes.
|
||||
|
||||
### Nostr Event Kinds
|
||||
|
||||
#### Kind 36767: Theme Definition (addressable, multiple per user)
|
||||
|
||||
A shareable, named theme that a user has created. Think of these as "published theme presets." Tags:
|
||||
|
||||
| Tag | Purpose | Example |
|
||||
|---|---|---|
|
||||
| `d` | Identifier (slug) | `["d", "ocean-night"]` |
|
||||
| `c` | Color (hex + role) | `["c", "#1a1a2e", "background"]` |
|
||||
| `f` | Font (family + optional URL) | `["f", "Comfortaa", "https://cdn.jsdelivr.net/..."]` |
|
||||
| `bg` | Background (imeta-style variadic) | `["bg", "url https://...", "mode cover", "m image/jpeg"]` |
|
||||
| `title` | Display name | `["title", "Ocean Night"]` |
|
||||
| `alt` | NIP-31 description | `["alt", "Custom theme: Ocean Night"]` |
|
||||
| `t` | Topic tag | `["t", "theme"]` |
|
||||
| `description` | Optional description | `["description", "A deep blue theme"]` |
|
||||
|
||||
Colors are stored as **hex** in `c` tags (converted to/from HSL internally). The `content` field is empty (legacy events may have JSON in content for backward compatibility).
|
||||
|
||||
#### Kind 16767: Active Profile Theme (replaceable, one per user)
|
||||
|
||||
The user's currently active profile theme. Same tag structure as kind 36767 but without `d` or `description` tags, and with an optional `a` tag referencing the source theme definition:
|
||||
|
||||
| Tag | Purpose |
|
||||
|---|---|
|
||||
| `c` | Color tags (same as 36767) |
|
||||
| `f` | Font tag (same as 36767) |
|
||||
| `bg` | Background tag (same as 36767) |
|
||||
| `alt` | Always `"Active profile theme"` |
|
||||
| `title` | Optional theme name |
|
||||
| `a` | Optional reference to source kind 36767 event |
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | File | Purpose |
|
||||
|---|---|---|
|
||||
| `usePublishTheme` | `src/hooks/usePublishTheme.ts` | Publish/update/delete theme definitions (36767), set/clear active profile theme (16767) |
|
||||
| `useUserThemes` | `src/hooks/useUserThemes.ts` | Query all kind 36767 themes by a user, deduplicated by d-tag, sorted newest first |
|
||||
| `useActiveProfileTheme` | `src/hooks/useActiveProfileTheme.ts` | Query a user's kind 16767 active profile theme |
|
||||
|
||||
### Publishing and Parsing
|
||||
|
||||
All event building and parsing is in `src/lib/themeEvent.ts`:
|
||||
|
||||
- `buildThemeDefinitionTags()` / `parseThemeDefinition()` - Kind 36767
|
||||
- `buildActiveThemeTags()` / `parseActiveProfileTheme()` - Kind 16767
|
||||
- `buildColorTags()` / `parseColorTags()` - HSL-to-hex conversion for `c` tags
|
||||
- `buildFontTag()` / `parseFontTag()` - Font `f` tags
|
||||
- `buildBackgroundTag()` / `parseBackgroundTag()` - Background `bg` tags (imeta-style)
|
||||
- `titleToSlug()` - Generate d-tag identifiers from titles
|
||||
|
||||
Backward compatibility: if `c` tags are missing, the parser falls back to reading legacy JSON from `content` (handling both the old 19-token format and the 4-color format).
|
||||
|
||||
---
|
||||
|
||||
## Part 3: The Bridge Between App Theme and Profile Theme
|
||||
|
||||
The two systems are connected by the **autoShareTheme** setting and the NostrSync component.
|
||||
|
||||
### App Theme -> Profile Theme
|
||||
|
||||
When `autoShareTheme` is enabled (default: `true`) and the user applies a custom theme via `applyCustomTheme()`, the `useTheme` hook automatically publishes the custom theme as a kind 16767 active profile theme, debounced by 2 seconds.
|
||||
|
||||
```
|
||||
User picks a custom theme
|
||||
-> applyCustomTheme() in useTheme.ts:88
|
||||
-> Updates local config (localStorage)
|
||||
-> Syncs to encrypted NIP-78 storage (1s debounce)
|
||||
-> If autoShareTheme: publishes kind 16767 (2s debounce)
|
||||
```
|
||||
|
||||
### Profile Theme -> App Theme
|
||||
|
||||
On page load, if `autoShareTheme` is enabled, `NostrSync` (line 174) fetches the user's kind 16767 event and applies it as `customTheme` **without changing the theme mode**. This means:
|
||||
|
||||
- If the user is on `theme: "dark"`, their profile theme is stored as `customTheme` but the UI stays in dark mode
|
||||
- If the user is on `theme: "custom"`, the profile theme's colors are applied to the UI
|
||||
- This allows the profile theme to stay in sync across devices without forcing the user into custom mode
|
||||
|
||||
### Theme Definitions (Kind 36767)
|
||||
|
||||
Theme definitions are independent of the app theme. Users can create, publish, edit, and delete named themes. Other users can view them in feeds (via `ThemeUpdateCard`) and copy them. These are purely social objects on the Nostr network.
|
||||
|
||||
---
|
||||
|
||||
## Font System
|
||||
|
||||
Fonts are managed by `src/lib/fontLoader.ts` and `src/lib/fonts.ts`.
|
||||
|
||||
### Bundled Fonts
|
||||
|
||||
10 fonts are bundled via `@fontsource` packages with lazy loading (dynamic imports):
|
||||
|
||||
| Category | Fonts |
|
||||
|---|---|
|
||||
| Sans | Inter, DM Sans, Outfit, Montserrat |
|
||||
| Serif | Lora, Merriweather, Playfair Display |
|
||||
| Mono | JetBrains Mono |
|
||||
| Display | Comfortaa |
|
||||
| Handwriting | Comic Relief |
|
||||
|
||||
Each has a `load()` function and a `cdnUrl` for Nostr event publishing.
|
||||
|
||||
### Font Application
|
||||
|
||||
Three `<style>` elements manage fonts:
|
||||
|
||||
| ID | Purpose |
|
||||
|---|---|
|
||||
| `theme-font-faces` | `@font-face` rules for remote fonts |
|
||||
| `theme-font-overrides` | `html { font-family: "CustomFont", "Inter Variable", ... !important; }` |
|
||||
| `theme-vars` | Theme CSS custom properties (not font-specific, but part of the pipeline) |
|
||||
|
||||
The `loadAndApplyFont()` function:
|
||||
1. Tries to load via bundled `@fontsource` package first
|
||||
2. Falls back to injecting a `@font-face` rule from a remote URL
|
||||
3. Applies a global font-family override via `<style id="theme-font-overrides">`
|
||||
4. Passing `undefined` clears the override (reverts to default Inter)
|
||||
|
||||
---
|
||||
|
||||
## Color Utilities
|
||||
|
||||
`src/lib/colorUtils.ts` provides the color math underpinning the theme system:
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `parseHsl` / `formatHsl` | Parse/format HSL strings (`"228 20% 10%"`) |
|
||||
| `hslToRgb` / `rgbToHsl` | HSL-RGB conversion |
|
||||
| `hexToRgb` / `rgbToHex` | Hex-RGB conversion |
|
||||
| `hexToHslString` / `hslStringToHex` | Direct hex-to-HSL-string conversion (used for Nostr `c` tags) |
|
||||
| `getLuminance` | WCAG 2.1 relative luminance |
|
||||
| `getContrastRatio` / `getContrastRatioHsl` | WCAG contrast ratio between two colors |
|
||||
| `isDarkTheme` | Determines if a background is "dark" (luminance < 0.2) |
|
||||
| `deriveTokensFromCore` | The core algorithm: 3 colors -> 19 tokens |
|
||||
| `tokensToCoreColors` | Extract 3 core colors from a legacy 19-token object |
|
||||
|
||||
All colors are stored internally as HSL strings without the `hsl()` wrapper (e.g. `"228 20% 10%"`). The `hsl()` wrapper is added by Tailwind's config (`hsl(var(--background))`).
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
Theme data is validated with Zod schemas in `src/lib/schemas.ts`:
|
||||
|
||||
- `ThemeSchema` - Validates `'dark' | 'light' | 'system' | 'custom'`
|
||||
- `CoreThemeColorsSchema` - Validates the 3 HSL string fields
|
||||
- `ThemeConfigSchema` - Full config with optional font/background
|
||||
- `ThemeConfigCompatSchema` - Accepts both `ThemeConfig` and bare `CoreThemeColors`
|
||||
- `ThemeColorsCompatSchema` - Union of current 3-color, old 4-color, and legacy 19-token formats
|
||||
- `AppConfigSchema` - Full app config including all theme fields
|
||||
- `EncryptedSettingsSchema` - Encrypted settings including theme fields
|
||||
|
||||
The `AppProvider` deserializer (`src/components/AppProvider.tsx:32`) validates each top-level field individually with `safeParse`, so a single invalid field doesn't nuke the entire config.
|
||||
|
||||
---
|
||||
|
||||
## File Index
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/themes.ts` | Core types (`CoreThemeColors`, `ThemeConfig`, `ThemeTokens`), builtin themes, presets, CSS builders |
|
||||
| `src/lib/colorUtils.ts` | Color conversion, contrast detection, token derivation |
|
||||
| `src/lib/themeEvent.ts` | Nostr event kinds (36767, 16767), tag building/parsing |
|
||||
| `src/lib/fontLoader.ts` | Font loading and CSS injection |
|
||||
| `src/lib/fonts.ts` | Bundled font definitions |
|
||||
| `src/lib/schemas.ts` | Zod validation schemas |
|
||||
| `src/contexts/AppContext.ts` | `Theme` type, `AppConfig` interface, React context |
|
||||
| `src/hooks/useTheme.ts` | Primary theme API: `setTheme()`, `applyCustomTheme()`, `setAutoShareTheme()` |
|
||||
| `src/hooks/useAppContext.ts` | Context consumer hook |
|
||||
| `src/hooks/useEncryptedSettings.ts` | NIP-78 encrypted settings (cross-device sync) |
|
||||
| `src/hooks/usePublishTheme.ts` | Publish theme definitions and active profile theme |
|
||||
| `src/hooks/useUserThemes.ts` | Query user's theme definitions |
|
||||
| `src/hooks/useActiveProfileTheme.ts` | Query user's active profile theme |
|
||||
| `src/components/AppProvider.tsx` | Theme application to DOM (`useApplyTheme`, `useApplyFonts`, `useApplyBackground`) |
|
||||
| `src/components/NostrSync.tsx` | Cross-device sync for encrypted settings and profile theme |
|
||||
| `src/components/ScopedTheme.tsx` | Scoped CSS variable overrides for subtrees |
|
||||
| `src/components/ThemeSelector.tsx` | Full settings UI for theme management |
|
||||
| `src/components/SidebarThemeDropdown.tsx` | Compact theme picker dropdown |
|
||||
| `public/theme.js` | Pre-React blocking script for flash prevention |
|
||||
| `index.html` | Hardcoded dark defaults, preloader, blocking script tag |
|
||||
| `tailwind.config.ts` | CSS custom property to Tailwind color mapping |
|
||||
| `src/index.css` | Base styles using theme tokens |
|
||||
@@ -1,254 +0,0 @@
|
||||
# Blobbi Tag Schema
|
||||
|
||||
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
|
||||
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
|
||||
|
||||
## Overview
|
||||
|
||||
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
|
||||
- All valid tags and their purposes
|
||||
- Which tags are required vs optional
|
||||
- Which tags persist across stage transitions
|
||||
- Which tags should be removed during transitions
|
||||
- Deprecated tags that should be filtered out
|
||||
|
||||
---
|
||||
|
||||
## Tag Categories
|
||||
|
||||
### 1. System / Metadata Tags
|
||||
|
||||
Core protocol-level tags required for event identification and ecosystem membership.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
|
||||
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
|
||||
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
|
||||
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
|
||||
|
||||
### 2. Core Identity Tags
|
||||
|
||||
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
|
||||
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
|
||||
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
|
||||
|
||||
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
|
||||
|
||||
### 3. Visual Trait Tags
|
||||
|
||||
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
|
||||
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
|
||||
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
|
||||
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
|
||||
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
|
||||
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
|
||||
|
||||
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
|
||||
|
||||
### 4. Personality / Trait Tags
|
||||
|
||||
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
|
||||
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
|
||||
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
|
||||
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
|
||||
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
|
||||
|
||||
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
|
||||
|
||||
### 5. Stat Tags
|
||||
|
||||
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
|
||||
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
|
||||
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
|
||||
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
|
||||
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
|
||||
|
||||
**Stage Transition Behavior**:
|
||||
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
|
||||
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
|
||||
|
||||
### 6. State / Lifecycle Tags
|
||||
|
||||
Tags that track the Blobbi's current lifecycle state.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
|
||||
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
|
||||
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
|
||||
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
|
||||
|
||||
**State Constraints**:
|
||||
- `incubating` is only valid for `stage: egg`
|
||||
- `evolving` is only valid for `stage: baby`
|
||||
- After hatch/evolve completes, `state` MUST be set to `active`
|
||||
|
||||
### 7. Task System Tags
|
||||
|
||||
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
|
||||
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
|
||||
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
|
||||
|
||||
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
|
||||
|
||||
### 8. Progression Tags
|
||||
|
||||
Long-term progress tracking that persists across all stages.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
|
||||
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
|
||||
|
||||
### 9. Social / Flag Tags
|
||||
|
||||
User preferences and computed flags.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
|
||||
|
||||
### 10. Evolution Tags
|
||||
|
||||
Tags specific to adult Blobbis.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
|
||||
|
||||
### 11. Extension Tags
|
||||
|
||||
Optional tags for themes and crossover features.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
|
||||
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
|
||||
|
||||
---
|
||||
|
||||
## Deprecated Tags
|
||||
|
||||
These tags are from legacy versions and MUST be removed when republishing events.
|
||||
|
||||
| Tag | Reason | Replaced By |
|
||||
|-----|--------|-------------|
|
||||
| `shell_integrity` | Eggs use standard `health` stat | `health` |
|
||||
| `egg_temperature` | Warmth handled via UI props | N/A |
|
||||
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
|
||||
| `egg_status` | Replaced by standard state | `state` |
|
||||
| `fees` | Removed | N/A |
|
||||
| `incubation_time` | Uses state_started_at | `state_started_at` |
|
||||
| `start_incubation` | Uses state_started_at | `state_started_at` |
|
||||
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
|
||||
|
||||
---
|
||||
|
||||
## Stage Transition Rules
|
||||
|
||||
### Hatch (egg → baby)
|
||||
|
||||
**Tags to REMOVE**:
|
||||
- `task`
|
||||
- `task_completed`
|
||||
- `state_started_at`
|
||||
|
||||
**Tags to UPDATE**:
|
||||
- `stage` → `baby`
|
||||
- `state` → `active`
|
||||
- `hunger` → `100`
|
||||
- `happiness` → `100`
|
||||
- `hygiene` → `100`
|
||||
- `energy` → `100`
|
||||
- `health` → (inherited from egg after decay)
|
||||
- `last_interaction` → current timestamp
|
||||
- `last_decay_at` → current timestamp
|
||||
|
||||
**Tags to PRESERVE (all persistent tags)**:
|
||||
- All system tags (`d`, `b`, `t`, `client`)
|
||||
- All identity tags (`name`, `seed`, `generation`)
|
||||
- All visual tags (colors, pattern, size)
|
||||
- All personality tags (if present)
|
||||
- All progression tags (`experience`, `care_streak`)
|
||||
- All social tags (`breeding_ready`)
|
||||
- All extension tags (`theme`, `crossover_app`)
|
||||
|
||||
### Evolve (baby → adult)
|
||||
|
||||
**Tags to REMOVE**:
|
||||
- `task`
|
||||
- `task_completed`
|
||||
- `state_started_at`
|
||||
|
||||
**Tags to UPDATE**:
|
||||
- `stage` → `adult`
|
||||
- `state` → `active`
|
||||
- All stats → (inherited from baby after decay)
|
||||
- `last_interaction` → current timestamp
|
||||
- `last_decay_at` → current timestamp
|
||||
|
||||
**Tags to PRESERVE (all persistent tags)**:
|
||||
- Same as hatch, plus all stats are inherited (not reset)
|
||||
|
||||
**Tags to ADD (optional)**:
|
||||
- `adult_type` → computed based on care history
|
||||
|
||||
---
|
||||
|
||||
## Migration Rules
|
||||
|
||||
When migrating legacy Blobbis to canonical format:
|
||||
|
||||
1. **Always preserve existing values** - Do not regenerate tags that already exist
|
||||
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
|
||||
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
|
||||
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
|
||||
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
A valid Blobbi event MUST have:
|
||||
- `d` tag in canonical format
|
||||
- `b` tag = `blobbi:ecosystem:v1`
|
||||
- `t` tag = `blobbi`
|
||||
- `name` tag (non-empty)
|
||||
- `seed` tag (64 hex chars)
|
||||
- `stage` tag (valid value)
|
||||
- `state` tag (valid value)
|
||||
- `last_interaction` tag (valid timestamp)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When implementing any flow that modifies Blobbi tags:
|
||||
|
||||
- [ ] Start from `canonical.allTags` as the base
|
||||
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
|
||||
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
|
||||
- [ ] Filter out deprecated tags
|
||||
- [ ] Update only the tags that need to change
|
||||
- [ ] Validate required tags are present
|
||||
@@ -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}"],
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<title>Ditto — Your content. Your vibe. Your rules.</title>
|
||||
<title>Agora — Power to the people.</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Agora — a Nostr social client for communities, creativity, and ownership." />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Ditto" />
|
||||
<meta property="og:description" content="Your content. Your vibe. Your rules." />
|
||||
<meta property="og:image" content="https://ditto.pub/og-image.jpg" />
|
||||
<meta property="og:title" content="Agora" />
|
||||
<meta property="og:description" content="Power to the people." />
|
||||
<meta property="og:image" content="https://agora.spot/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://ditto.pub" />
|
||||
<meta property="og:site_name" content="Ditto" />
|
||||
<meta property="og:url" content="https://agora.spot" />
|
||||
<meta property="og:site_name" content="Agora" />
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Ditto" />
|
||||
<meta name="twitter:description" content="Your content. Your vibe. Your rules." />
|
||||
<meta name="twitter:image" content="https://ditto.pub/og-image.jpg" />
|
||||
<meta name="twitter:title" content="Agora" />
|
||||
<meta name="twitter:description" content="Power to the people." />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.png" />
|
||||
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#161b2e" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<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="180x180" href="/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#0a0c14" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ff6600" media="(prefers-color-scheme: light)">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<style>@keyframes ditto-spin{to{transform:rotate(360deg)}}</style>
|
||||
<style>@keyframes agora-spin{to{transform:rotate(360deg)}}</style>
|
||||
</head>
|
||||
<body style="margin:0;background:hsl(228 20% 10%)">
|
||||
<body style="margin:0;background:hsl(0 0% 10%)">
|
||||
<!-- Pre-React loading screen. Lives OUTSIDE #root so React doesn't
|
||||
touch it. Removed by main.tsx once the app has mounted. -->
|
||||
<div id="preloader" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:hsl(228 20% 10%)">
|
||||
<div id="preloader" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:hsl(0 0% 10%)">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:24px">
|
||||
<div data-logo style="width:48px;height:48px;background:hsl(258 70% 60%);-webkit-mask:url(/logo.svg) center/contain no-repeat;mask:url(/logo.svg) center/contain no-repeat"></div>
|
||||
<div data-spinner style="width:24px;height:24px;border:2.5px solid hsl(258 70% 60% / 0.25);border-top-color:hsl(258 70% 60%);border-radius:50%;animation:ditto-spin .7s linear infinite"></div>
|
||||
<div data-logo style="width:48px;height:48px;background:hsl(14 79% 58%);-webkit-mask:url(/logo.svg) center/contain no-repeat;mask:url(/logo.svg) center/contain no-repeat"></div>
|
||||
<div data-spinner style="width:24px;height:24px;border:2.5px solid hsl(14 79% 58% / 0.25);border-top-color:hsl(14 79% 58%);border-radius:50%;animation:agora-spin .7s linear infinite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Blocking script: reads theme from localStorage and applies it
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
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 */; };
|
||||
B1A2C3D40007000100000001 /* NostrPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40007000100000002 /* NostrPoller.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -30,9 +32,11 @@
|
||||
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>"; };
|
||||
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoNotificationPlugin.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40007000100000002 /* NostrPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoller.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -70,12 +74,14 @@
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
B1A2C3D40004000100000002 /* App.entitlements */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */,
|
||||
B1A2C3D40007000100000002 /* NostrPoller.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
@@ -153,6 +159,7 @@
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -164,8 +171,9 @@
|
||||
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 */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -315,9 +323,9 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.3;
|
||||
MARKETING_VERSION = 2.14.4;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -339,8 +347,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>webcredentials:ditto.pub</string>
|
||||
<string>webcredentials:ditto.pub?mode=developer</string>
|
||||
<string>webcredentials:agora.spot</string>
|
||||
<string>webcredentials:agora.spot?mode=developer</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,36 +1,45 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import BackgroundTasks
|
||||
import UserNotifications
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
// Register the background task handler for notification polling.
|
||||
// Must happen before the app finishes launching.
|
||||
DittoNotificationPlugin.registerBackgroundTask()
|
||||
|
||||
// Set ourselves as the notification center delegate so we can:
|
||||
// 1. Show banners even when the app is in the foreground.
|
||||
// 2. Handle notification taps to navigate the WebView.
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Register notification categories with summary formats for iOS grouping.
|
||||
registerNotificationCategories()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
// Trigger an immediate poll when returning to foreground to catch up
|
||||
// on any notifications missed while backgrounded.
|
||||
DittoNotificationPlugin.pollNow()
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
@@ -46,4 +55,66 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
/// Show notification banners even when the app is in the foreground.
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
|
||||
/// Handle notification tap: navigate the Capacitor WebView to /notifications.
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
let path = userInfo["url"] as? String ?? "/notifications"
|
||||
|
||||
// Navigate the Capacitor WebView to the notifications page.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let rootVC = self?.window?.rootViewController as? DittoBridgeViewController else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
let js = "window.location.pathname !== '\(path)' && (window.location.pathname = '\(path)');"
|
||||
rootVC.webView?.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
// MARK: - Notification Categories
|
||||
|
||||
/// Register notification categories with summary formats for native iOS
|
||||
/// notification grouping. When multiple notifications share a thread
|
||||
/// identifier, iOS automatically collapses them and uses the summary
|
||||
/// format to describe the group.
|
||||
private func registerNotificationCategories() {
|
||||
let categories: [UNNotificationCategory] = [
|
||||
makeCategory(id: NostrPoller.categoryReactions, summary: "%u more reactions"),
|
||||
makeCategory(id: NostrPoller.categoryReposts, summary: "%u more reposts"),
|
||||
makeCategory(id: NostrPoller.categoryZaps, summary: "%u more zaps"),
|
||||
makeCategory(id: NostrPoller.categoryMentions, summary: "%u more mentions"),
|
||||
makeCategory(id: NostrPoller.categoryComments, summary: "%u more comments"),
|
||||
makeCategory(id: NostrPoller.categoryBadges, summary: "%u more badge awards"),
|
||||
makeCategory(id: NostrPoller.categoryLetters, summary: "%u more letters"),
|
||||
]
|
||||
UNUserNotificationCenter.current().setNotificationCategories(Set(categories))
|
||||
}
|
||||
|
||||
private func makeCategory(id: String, summary: String) -> UNNotificationCategory {
|
||||
return UNNotificationCategory(
|
||||
identifier: id,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: nil,
|
||||
categorySummaryFormat: summary,
|
||||
options: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 291 KiB |
@@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import BackgroundTasks
|
||||
import UserNotifications
|
||||
|
||||
// MARK: - DittoNotificationPlugin
|
||||
|
||||
/// Capacitor plugin that bridges the JS notification configuration to the
|
||||
/// native iOS background polling system.
|
||||
///
|
||||
/// Mirrors the Android `DittoNotificationPlugin.java` interface:
|
||||
/// - Receives `userPubkey`, `relayUrls`, `enabledKinds`, `authors`, and
|
||||
/// `notificationStyle` from the JS layer via `configure()`.
|
||||
/// - Stores configuration in UserDefaults.
|
||||
/// - Schedules / cancels a `BGAppRefreshTask` to periodically poll relays
|
||||
/// and display local notifications via `NostrPoller`.
|
||||
///
|
||||
/// On iOS the "push" vs "persistent" distinction maps to:
|
||||
/// - **"push"**: No background polling. Relies on Web Push (where supported)
|
||||
/// or in-app polling when the app is open.
|
||||
/// - **"persistent"**: Schedules `BGAppRefreshTask` for periodic relay polling.
|
||||
/// iOS manages the interval (~15 min minimum, adaptive based on app usage).
|
||||
@objc(DittoNotificationPlugin)
|
||||
public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
// MARK: - Capacitor Bridging
|
||||
|
||||
public let identifier = "DittoNotificationPlugin"
|
||||
public let jsName = "DittoNotification"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
static let bgTaskIdentifier = "pub.ditto.app.notification-refresh"
|
||||
private static let prefsKey = "ditto_notification_config"
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
/// Called from JS: `DittoNotification.configure({ ... })`.
|
||||
@objc func configure(_ call: CAPPluginCall) {
|
||||
let userPubkey = call.getString("userPubkey")
|
||||
let notificationStyle = call.getString("notificationStyle") ?? "push"
|
||||
let relayUrls = call.getArray("relayUrls")?.compactMap { $0 as? String }
|
||||
let enabledKinds = call.getArray("enabledKinds")?.compactMap { $0 as? Int }
|
||||
let authors = call.getArray("authors")?.compactMap { $0 as? String }
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let userPubkey, let relayUrls, !relayUrls.isEmpty {
|
||||
// Save configuration.
|
||||
defaults.set(userPubkey, forKey: "\(Self.prefsKey).userPubkey")
|
||||
defaults.set(relayUrls, forKey: "\(Self.prefsKey).relayUrls")
|
||||
defaults.set(notificationStyle, forKey: "\(Self.prefsKey).notificationStyle")
|
||||
if let enabledKinds {
|
||||
defaults.set(enabledKinds, forKey: "\(Self.prefsKey).enabledKinds")
|
||||
}
|
||||
if let authors, !authors.isEmpty {
|
||||
defaults.set(authors, forKey: "\(Self.prefsKey).authors")
|
||||
} else {
|
||||
defaults.removeObject(forKey: "\(Self.prefsKey).authors")
|
||||
}
|
||||
|
||||
let kindsStr = enabledKinds?.map(String.init).joined(separator: ",") ?? "none"
|
||||
NSLog("[DittoNotification] Configured: pubkey=%@..., style=%@, relays=%d, kinds=%@",
|
||||
String(userPubkey.prefix(8)), notificationStyle,
|
||||
relayUrls.count,
|
||||
kindsStr)
|
||||
} else {
|
||||
// Clear configuration (user logged out).
|
||||
for suffix in ["userPubkey", "relayUrls", "notificationStyle", "enabledKinds", "authors"] {
|
||||
defaults.removeObject(forKey: "\(Self.prefsKey).\(suffix)")
|
||||
}
|
||||
NSLog("[DittoNotification] Config cleared (user logged out)")
|
||||
}
|
||||
|
||||
// Schedule or cancel background polling based on style + config.
|
||||
let hasConfig = userPubkey != nil && relayUrls != nil && !(relayUrls?.isEmpty ?? true)
|
||||
Self.manageBackgroundRefresh(style: notificationStyle, hasConfig: hasConfig)
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// MARK: - Background Task Management
|
||||
|
||||
/// Register the BGAppRefreshTask handler. Must be called from
|
||||
/// `application(_:didFinishLaunchingWithOptions:)` before the app
|
||||
/// finishes launching.
|
||||
static func registerBackgroundTask() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: bgTaskIdentifier,
|
||||
using: nil
|
||||
) { task in
|
||||
guard let refreshTask = task as? BGAppRefreshTask else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
Self.handleBackgroundRefresh(task: refreshTask)
|
||||
}
|
||||
NSLog("[DittoNotification] Registered BGAppRefreshTask: %@", bgTaskIdentifier)
|
||||
}
|
||||
|
||||
/// Schedule or cancel the BGAppRefreshTask.
|
||||
/// On iOS both "push" and "persistent" modes use BGAppRefreshTask
|
||||
/// (there is no Web Push in WKWebView and no foreground service concept),
|
||||
/// so we schedule whenever there is a valid config.
|
||||
static func manageBackgroundRefresh(style: String, hasConfig: Bool) {
|
||||
if hasConfig {
|
||||
scheduleBackgroundRefresh()
|
||||
} else {
|
||||
cancelBackgroundRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule the next background refresh. iOS decides the actual timing
|
||||
/// (minimum ~15 minutes, adaptive based on user app usage patterns).
|
||||
static func scheduleBackgroundRefresh() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: bgTaskIdentifier)
|
||||
// Suggest earliest begin date of 8 minutes from now (iOS may defer).
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 8 * 60)
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
NSLog("[DittoNotification] Scheduled background refresh")
|
||||
} catch {
|
||||
NSLog("[DittoNotification] Failed to schedule background refresh: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cancelBackgroundRefresh() {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: bgTaskIdentifier)
|
||||
NSLog("[DittoNotification] Cancelled background refresh")
|
||||
}
|
||||
|
||||
/// Handle a BGAppRefreshTask: read config, poll, reschedule.
|
||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
|
||||
NSLog("[DittoNotification] Background refresh triggered")
|
||||
|
||||
// Read configuration from UserDefaults.
|
||||
let defaults = UserDefaults.standard
|
||||
guard let userPubkey = defaults.string(forKey: "\(prefsKey).userPubkey"),
|
||||
let relayUrls = defaults.stringArray(forKey: "\(prefsKey).relayUrls"),
|
||||
!relayUrls.isEmpty else {
|
||||
NSLog("[DittoNotification] No config, completing task")
|
||||
task.setTaskCompleted(success: true)
|
||||
return
|
||||
}
|
||||
|
||||
let enabledKinds = defaults.array(forKey: "\(prefsKey).enabledKinds") as? [Int] ?? []
|
||||
let authors = defaults.stringArray(forKey: "\(prefsKey).authors")
|
||||
|
||||
guard !enabledKinds.isEmpty else {
|
||||
NSLog("[DittoNotification] No enabled kinds, completing task")
|
||||
task.setTaskCompleted(success: true)
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule the next refresh before starting work (in case we're
|
||||
// terminated mid-task, the next refresh is already queued).
|
||||
scheduleBackgroundRefresh()
|
||||
|
||||
// Run the poll in a detached Task.
|
||||
let pollTask = Task {
|
||||
let poller = NostrPoller()
|
||||
let count = await poller.poll(
|
||||
userPubkey: userPubkey,
|
||||
relayUrls: relayUrls,
|
||||
enabledKinds: enabledKinds,
|
||||
authors: authors
|
||||
)
|
||||
NSLog("[DittoNotification] Background poll complete: %d notifications", count)
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
|
||||
// Handle task expiration (iOS is about to kill us).
|
||||
task.expirationHandler = {
|
||||
NSLog("[DittoNotification] Background task expired")
|
||||
pollTask.cancel()
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Immediate Poll
|
||||
|
||||
/// Trigger an immediate poll (e.g., when the app enters the foreground
|
||||
/// after being backgrounded, to catch up on missed notifications).
|
||||
static func pollNow() {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let userPubkey = defaults.string(forKey: "\(prefsKey).userPubkey"),
|
||||
let relayUrls = defaults.stringArray(forKey: "\(prefsKey).relayUrls"),
|
||||
!relayUrls.isEmpty else { return }
|
||||
|
||||
let enabledKinds = defaults.array(forKey: "\(prefsKey).enabledKinds") as? [Int] ?? []
|
||||
let authors = defaults.stringArray(forKey: "\(prefsKey).authors")
|
||||
|
||||
guard !enabledKinds.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
let poller = NostrPoller()
|
||||
await poller.poll(
|
||||
userPubkey: userPubkey,
|
||||
relayUrls: relayUrls,
|
||||
enabledKinds: enabledKinds,
|
||||
authors: authors
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Ditto</string>
|
||||
<string>Agora</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -48,10 +48,20 @@
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Ditto needs camera access to take photos and videos for your posts.</string>
|
||||
<string>Agora needs camera access to take photos and videos for your posts.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Ditto needs access to your microphone to record voice messages.</string>
|
||||
<string>Agora needs access to your microphone to record voice messages.</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>pub.agora.app.notification-refresh</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
// MARK: - NostrPoller
|
||||
|
||||
/// Polls Nostr relays for notification events and displays native iOS
|
||||
/// notifications with author names, content previews, and iOS thread grouping.
|
||||
///
|
||||
/// Improvements over the Android implementation:
|
||||
/// - Fetches kind 0 metadata so notifications show "Alice reacted" not "Someone reacted"
|
||||
/// - Uses iOS thread identifiers for native notification grouping per category+post
|
||||
/// - Caches author metadata in UserDefaults (24h TTL) to minimise relay queries
|
||||
/// - Designed to complete within the ~30s BGAppRefreshTask budget
|
||||
final class NostrPoller {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private static let prefsKey = "ditto_notifications"
|
||||
private static let lastSeenKey = "nostr:notification-last-seen"
|
||||
private static let metadataCacheKey = "nostr:author-metadata-cache"
|
||||
private static let metadataTTL: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
|
||||
private static let fetchLimit = 5
|
||||
private static let wsTimeout: TimeInterval = 10
|
||||
private static let metadataFetchTimeout: TimeInterval = 5
|
||||
|
||||
// MARK: - Notification Categories (registered by AppDelegate)
|
||||
|
||||
/// Category identifiers used for UNNotificationCategory registration.
|
||||
static let categoryReactions = "reactions"
|
||||
static let categoryReposts = "reposts"
|
||||
static let categoryZaps = "zaps"
|
||||
static let categoryMentions = "mentions"
|
||||
static let categoryComments = "comments"
|
||||
static let categoryBadges = "badges"
|
||||
static let categoryLetters = "letters"
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
/// Minimal parsed Nostr event used during polling.
|
||||
struct NostrEvent {
|
||||
let id: String
|
||||
let pubkey: String
|
||||
let kind: Int
|
||||
let createdAt: Int
|
||||
let content: String
|
||||
let tags: [[String]]
|
||||
|
||||
init?(json: [String: Any]) {
|
||||
guard let id = json["id"] as? String,
|
||||
let pubkey = json["pubkey"] as? String,
|
||||
let kind = json["kind"] as? Int,
|
||||
let createdAt = json["created_at"] as? Int else { return nil }
|
||||
self.id = id
|
||||
self.pubkey = pubkey
|
||||
self.kind = kind
|
||||
self.createdAt = createdAt
|
||||
self.content = json["content"] as? String ?? ""
|
||||
self.tags = (json["tags"] as? [[String]]) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached author display name.
|
||||
private struct AuthorCache: Codable {
|
||||
let name: String
|
||||
let timestamp: TimeInterval
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Run a single poll cycle: fetch events from a relay, resolve metadata,
|
||||
/// and display notifications. Returns the number of notifications shown.
|
||||
@discardableResult
|
||||
func poll(
|
||||
userPubkey: String,
|
||||
relayUrls: [String],
|
||||
enabledKinds: [Int],
|
||||
authors: [String]?
|
||||
) async -> Int {
|
||||
guard !relayUrls.isEmpty, !enabledKinds.isEmpty else { return 0 }
|
||||
|
||||
let since = lastSeenTimestamp
|
||||
let effectiveSince = since > 0 ? since : Int(Date().timeIntervalSince1970) - 300
|
||||
|
||||
if since == 0 {
|
||||
setLastSeenTimestamp(effectiveSince)
|
||||
}
|
||||
|
||||
// Try each relay in order until one succeeds.
|
||||
for relayUrl in relayUrls {
|
||||
guard let events = await fetchEvents(
|
||||
relayUrl: relayUrl,
|
||||
userPubkey: userPubkey,
|
||||
enabledKinds: enabledKinds,
|
||||
authors: authors,
|
||||
since: effectiveSince
|
||||
) else {
|
||||
continue // Try next relay on failure.
|
||||
}
|
||||
|
||||
// Deduplicate + filter self-interactions.
|
||||
var seenIds = Set<String>()
|
||||
let filtered = events.filter { ev in
|
||||
guard ev.pubkey != userPubkey, !seenIds.contains(ev.id) else { return false }
|
||||
seenIds.insert(ev.id)
|
||||
return true
|
||||
}
|
||||
|
||||
guard !filtered.isEmpty else {
|
||||
// Successful fetch but nothing new — update timestamp and return.
|
||||
return 0
|
||||
}
|
||||
|
||||
// Verify referenced events for reactions/reposts/zaps.
|
||||
let notifiable = await verifyReferencedEvents(
|
||||
events: filtered,
|
||||
userPubkey: userPubkey,
|
||||
relayUrl: relayUrl
|
||||
)
|
||||
|
||||
// Update last-seen to newest event in the full filtered set (not
|
||||
// just notifiable) so we don't re-fetch already-seen events.
|
||||
let newestTs = filtered.map(\.createdAt).max() ?? effectiveSince
|
||||
if newestTs > lastSeenTimestamp {
|
||||
setLastSeenTimestamp(newestTs)
|
||||
}
|
||||
|
||||
guard !notifiable.isEmpty else { return 0 }
|
||||
|
||||
// Fetch author metadata for unique pubkeys.
|
||||
let pubkeys = Array(Set(notifiable.map(\.pubkey)))
|
||||
let authorNames = await resolveAuthorNames(pubkeys: pubkeys, relayUrl: relayUrl)
|
||||
|
||||
// Display notifications.
|
||||
await displayNotifications(events: notifiable, authorNames: authorNames)
|
||||
|
||||
return notifiable.count
|
||||
}
|
||||
|
||||
return 0 // All relays failed.
|
||||
}
|
||||
|
||||
// MARK: - Relay Communication
|
||||
|
||||
/// Fetch notification events from a single relay. Returns nil on failure.
|
||||
private func fetchEvents(
|
||||
relayUrl: String,
|
||||
userPubkey: String,
|
||||
enabledKinds: [Int],
|
||||
authors: [String]?,
|
||||
since: Int
|
||||
) async -> [NostrEvent]? {
|
||||
guard let url = URL(string: relayUrl) else { return nil }
|
||||
|
||||
var filter: [String: Any] = [
|
||||
"kinds": enabledKinds,
|
||||
"#p": [userPubkey],
|
||||
"since": since + 1,
|
||||
"limit": Self.fetchLimit,
|
||||
]
|
||||
if let authors, !authors.isEmpty {
|
||||
filter["authors"] = authors
|
||||
}
|
||||
|
||||
return await relayQuery(url: url, filters: [filter])
|
||||
}
|
||||
|
||||
/// Fetch events by IDs from a relay for referenced-event verification.
|
||||
private func fetchEventsByIds(ids: [String], relayUrl: String) async -> [String: NostrEvent] {
|
||||
guard !ids.isEmpty, let url = URL(string: relayUrl) else { return [:] }
|
||||
|
||||
let filter: [String: Any] = [
|
||||
"ids": ids,
|
||||
"limit": ids.count,
|
||||
]
|
||||
|
||||
guard let events = await relayQuery(url: url, filters: [filter], timeout: Self.metadataFetchTimeout) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var map = [String: NostrEvent]()
|
||||
for ev in events {
|
||||
map[ev.id] = ev
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// Fetch kind 0 metadata events for a set of pubkeys.
|
||||
private func fetchMetadata(pubkeys: [String], relayUrl: String) async -> [String: NostrEvent] {
|
||||
guard !pubkeys.isEmpty, let url = URL(string: relayUrl) else { return [:] }
|
||||
|
||||
let filter: [String: Any] = [
|
||||
"kinds": [0],
|
||||
"authors": pubkeys,
|
||||
"limit": pubkeys.count,
|
||||
]
|
||||
|
||||
guard let events = await relayQuery(url: url, filters: [filter], timeout: Self.metadataFetchTimeout) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var map = [String: NostrEvent]()
|
||||
for ev in events {
|
||||
// Keep only the newest kind 0 per pubkey.
|
||||
if let existing = map[ev.pubkey], existing.createdAt > ev.createdAt {
|
||||
continue
|
||||
}
|
||||
map[ev.pubkey] = ev
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// Low-level relay query: open WebSocket, send REQ, collect events until
|
||||
/// EOSE, close. Returns nil on connection/timeout failure.
|
||||
private func relayQuery(
|
||||
url: URL,
|
||||
filters: [[String: Any]],
|
||||
timeout: TimeInterval = wsTimeout
|
||||
) async -> [NostrEvent]? {
|
||||
await withCheckedContinuation { continuation in
|
||||
var events = [NostrEvent]()
|
||||
var resumed = false
|
||||
let subId = "ditto-\(UInt64.random(in: 0...UInt64.max))"
|
||||
|
||||
let session = URLSession(configuration: .default)
|
||||
let task = session.webSocketTask(with: url)
|
||||
task.resume()
|
||||
|
||||
// Build REQ message: ["REQ", subId, filter1, filter2, ...]
|
||||
var reqArray: [Any] = ["REQ", subId]
|
||||
reqArray.append(contentsOf: filters)
|
||||
|
||||
guard let reqData = try? JSONSerialization.data(withJSONObject: reqArray),
|
||||
let reqStr = String(data: reqData, encoding: .utf8) else {
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Timeout guard.
|
||||
let timeoutWork = DispatchWorkItem { [weak task] in
|
||||
guard !resumed else { return }
|
||||
resumed = true
|
||||
task?.cancel(with: .goingAway, reason: nil)
|
||||
session.invalidateAndCancel()
|
||||
continuation.resume(returning: events.isEmpty ? nil : events)
|
||||
}
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
|
||||
|
||||
func finish(result: [NostrEvent]?) {
|
||||
timeoutWork.cancel()
|
||||
guard !resumed else { return }
|
||||
resumed = true
|
||||
// Send CLOSE and disconnect.
|
||||
if let closeData = try? JSONSerialization.data(withJSONObject: ["CLOSE", subId]),
|
||||
let closeStr = String(data: closeData, encoding: .utf8) {
|
||||
task.send(.string(closeStr)) { _ in }
|
||||
}
|
||||
task.cancel(with: .normalClosure, reason: nil)
|
||||
session.invalidateAndCancel()
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
|
||||
func receiveNext() {
|
||||
task.receive { result in
|
||||
switch result {
|
||||
case .success(.string(let text)):
|
||||
guard let data = text.data(using: .utf8),
|
||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
let type = arr.first as? String else {
|
||||
receiveNext()
|
||||
return
|
||||
}
|
||||
|
||||
if type == "EVENT", arr.count >= 3,
|
||||
let evJson = arr[2] as? [String: Any],
|
||||
let ev = NostrEvent(json: evJson) {
|
||||
events.append(ev)
|
||||
receiveNext()
|
||||
} else if type == "EOSE" || type == "CLOSED" {
|
||||
finish(result: events)
|
||||
} else {
|
||||
receiveNext()
|
||||
}
|
||||
|
||||
case .failure:
|
||||
finish(result: nil)
|
||||
|
||||
default:
|
||||
receiveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task.send(.string(reqStr)) { error in
|
||||
if error != nil {
|
||||
finish(result: nil)
|
||||
} else {
|
||||
receiveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Verification
|
||||
|
||||
/// For reactions (7), reposts (6, 16), and zaps (9735), verify that the
|
||||
/// referenced event was authored by the current user. Events that pass
|
||||
/// verification or don't need it are returned.
|
||||
private func verifyReferencedEvents(
|
||||
events: [NostrEvent],
|
||||
userPubkey: String,
|
||||
relayUrl: String
|
||||
) async -> [NostrEvent] {
|
||||
let needsVerification: Set<Int> = [7, 6, 16, 9735]
|
||||
|
||||
// Collect referenced IDs that need verification.
|
||||
var refIdsNeeded = Set<String>()
|
||||
for ev in events where needsVerification.contains(ev.kind) {
|
||||
if let refId = referencedEventId(from: ev) {
|
||||
refIdsNeeded.insert(refId)
|
||||
}
|
||||
}
|
||||
|
||||
let refMap: [String: NostrEvent]
|
||||
if !refIdsNeeded.isEmpty {
|
||||
refMap = await fetchEventsByIds(ids: Array(refIdsNeeded), relayUrl: relayUrl)
|
||||
} else {
|
||||
refMap = [:]
|
||||
}
|
||||
|
||||
return events.filter { ev in
|
||||
guard needsVerification.contains(ev.kind) else { return true }
|
||||
|
||||
// Zaps with #p tag targeting the user are valid (profile zaps have no e tag).
|
||||
if ev.kind == 9735 {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let refId = referencedEventId(from: ev) else { return false }
|
||||
guard let refEvent = refMap[refId] else {
|
||||
// Couldn't fetch — keep the notification rather than silently dropping it.
|
||||
return true
|
||||
}
|
||||
return refEvent.pubkey == userPubkey
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the last `e` tag value from an event's tags.
|
||||
private func referencedEventId(from event: NostrEvent) -> String? {
|
||||
event.tags.last(where: { $0.first == "e" && $0.count > 1 })?[1]
|
||||
}
|
||||
|
||||
// MARK: - Author Metadata Resolution
|
||||
|
||||
/// Resolve display names for a set of pubkeys, using cache where possible.
|
||||
private func resolveAuthorNames(pubkeys: [String], relayUrl: String) async -> [String: String] {
|
||||
var result = [String: String]()
|
||||
var uncached = [String]()
|
||||
|
||||
let cache = loadMetadataCache()
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
for pk in pubkeys {
|
||||
if let cached = cache[pk], now - cached.timestamp < Self.metadataTTL {
|
||||
result[pk] = cached.name
|
||||
} else {
|
||||
uncached.append(pk)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch uncached metadata from the relay.
|
||||
if !uncached.isEmpty {
|
||||
let metadataEvents = await fetchMetadata(pubkeys: uncached, relayUrl: relayUrl)
|
||||
var updatedCache = cache
|
||||
|
||||
for pk in uncached {
|
||||
if let ev = metadataEvents[pk], let name = parseDisplayName(from: ev) {
|
||||
result[pk] = name
|
||||
updatedCache[pk] = AuthorCache(name: name, timestamp: now)
|
||||
} else {
|
||||
// Fall back to truncated npub-style identifier.
|
||||
let fallback = formatPubkey(pk)
|
||||
result[pk] = fallback
|
||||
// Don't cache failures — retry next time.
|
||||
}
|
||||
}
|
||||
|
||||
saveMetadataCache(updatedCache)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Parse display_name or name from a kind 0 event's content JSON.
|
||||
private func parseDisplayName(from event: NostrEvent) -> String? {
|
||||
guard let data = event.content.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
// Prefer display_name, fall back to name.
|
||||
if let displayName = json["display_name"] as? String, !displayName.isEmpty {
|
||||
return displayName
|
||||
}
|
||||
if let name = json["name"] as? String, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Format a hex pubkey as a short identifier: first 8 + "..." + last 4.
|
||||
private func formatPubkey(_ pubkey: String) -> String {
|
||||
guard pubkey.count >= 12 else { return pubkey }
|
||||
let start = pubkey.prefix(8)
|
||||
let end = pubkey.suffix(4)
|
||||
return "\(start)...\(end)"
|
||||
}
|
||||
|
||||
// MARK: - Metadata Cache (UserDefaults)
|
||||
|
||||
private func loadMetadataCache() -> [String: AuthorCache] {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let data = defaults.data(forKey: Self.metadataCacheKey),
|
||||
let cache = try? JSONDecoder().decode([String: AuthorCache].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
private func saveMetadataCache(_ cache: [String: AuthorCache]) {
|
||||
guard let data = try? JSONEncoder().encode(cache) else { return }
|
||||
UserDefaults.standard.set(data, forKey: Self.metadataCacheKey)
|
||||
}
|
||||
|
||||
// MARK: - Notification Display
|
||||
|
||||
/// Display native iOS notifications for a batch of verified events.
|
||||
private func displayNotifications(events: [NostrEvent], authorNames: [String: String]) async {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
|
||||
for event in events {
|
||||
let authorName = authorNames[event.pubkey] ?? formatPubkey(event.pubkey)
|
||||
let (title, body, categoryId, threadId) = notificationContent(
|
||||
event: event,
|
||||
authorName: authorName
|
||||
)
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = categoryId
|
||||
content.threadIdentifier = threadId
|
||||
content.userInfo = ["url": "/notifications"]
|
||||
|
||||
let identifier = "ditto-\(event.id.prefix(16))"
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately.
|
||||
)
|
||||
|
||||
try? await center.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build notification title, body, category ID, and thread identifier for an event.
|
||||
private func notificationContent(
|
||||
event: NostrEvent,
|
||||
authorName: String
|
||||
) -> (title: String, body: String, categoryId: String, threadId: String) {
|
||||
let refId = referencedEventId(from: event) ?? ""
|
||||
|
||||
switch event.kind {
|
||||
case 7:
|
||||
// Reaction — show the reaction content (emoji) if available.
|
||||
let reaction = event.content.isEmpty || event.content == "+" ? "❤️" : event.content
|
||||
return (
|
||||
"\(authorName) reacted \(reaction)",
|
||||
"Reacted to your post",
|
||||
Self.categoryReactions,
|
||||
"reactions:\(refId)"
|
||||
)
|
||||
|
||||
case 6, 16:
|
||||
return (
|
||||
"\(authorName) reposted your note",
|
||||
"",
|
||||
Self.categoryReposts,
|
||||
"reposts:\(refId)"
|
||||
)
|
||||
|
||||
case 9735:
|
||||
let sats = zapAmount(from: event)
|
||||
if sats > 0 {
|
||||
return (
|
||||
"\(formatSats(sats)) sats from \(authorName)",
|
||||
"You received a zap",
|
||||
Self.categoryZaps,
|
||||
"zaps"
|
||||
)
|
||||
}
|
||||
return (
|
||||
"\(authorName) zapped you",
|
||||
"",
|
||||
Self.categoryZaps,
|
||||
"zaps"
|
||||
)
|
||||
|
||||
case 1:
|
||||
let hasETag = event.tags.contains(where: { $0.first == "e" })
|
||||
let preview = contentPreview(event.content, maxLength: 120)
|
||||
if hasETag {
|
||||
return (
|
||||
"\(authorName) replied to you",
|
||||
preview,
|
||||
Self.categoryMentions,
|
||||
"mentions"
|
||||
)
|
||||
}
|
||||
return (
|
||||
"\(authorName) mentioned you",
|
||||
preview,
|
||||
Self.categoryMentions,
|
||||
"mentions"
|
||||
)
|
||||
|
||||
case 1111, 1222, 1244:
|
||||
let preview = contentPreview(event.content, maxLength: 120)
|
||||
// Check if this is a reply to another comment (k tag == "1111").
|
||||
let isReply = event.tags.contains(where: { $0.first == "k" && $0.count > 1 && $0[1] == "1111" })
|
||||
let action = isReply ? "replied to your comment" : "commented on your post"
|
||||
return (
|
||||
"\(authorName) \(action)",
|
||||
preview,
|
||||
Self.categoryComments,
|
||||
"comments:\(refId)"
|
||||
)
|
||||
|
||||
case 8:
|
||||
return (
|
||||
"\(authorName) awarded you a badge",
|
||||
"You received a new badge",
|
||||
Self.categoryBadges,
|
||||
"badges"
|
||||
)
|
||||
|
||||
case 8211:
|
||||
return (
|
||||
"\(authorName) sent you a letter",
|
||||
"You have a new letter waiting for you",
|
||||
Self.categoryLetters,
|
||||
"letters"
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
"\(authorName) interacted with you",
|
||||
"",
|
||||
Self.categoryMentions,
|
||||
"mentions"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate content for notification body preview.
|
||||
private func contentPreview(_ content: String, maxLength: Int) -> String {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// Replace newlines with spaces for a single-line preview.
|
||||
let singleLine = trimmed.replacingOccurrences(
|
||||
of: "\\s*\\n+\\s*",
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
guard singleLine.count > maxLength else { return singleLine }
|
||||
return String(singleLine.prefix(maxLength)) + "…"
|
||||
}
|
||||
|
||||
// MARK: - Zap Amount Extraction
|
||||
|
||||
/// Extract zap amount in sats from a kind 9735 zap receipt event.
|
||||
/// Checks the "amount" tag first (millisats), then falls back to
|
||||
/// parsing the "description" tag's zap request JSON.
|
||||
private func zapAmount(from event: NostrEvent) -> Int {
|
||||
// Check for direct "amount" tag (value in millisats).
|
||||
for tag in event.tags where tag.first == "amount" && tag.count > 1 {
|
||||
if let msats = Int(tag[1]), msats > 0 {
|
||||
return msats / 1000
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to "description" tag (zap request JSON) -> amount tag.
|
||||
for tag in event.tags where tag.first == "description" && tag.count > 1 {
|
||||
guard let data = tag[1].data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let reqTags = json["tags"] as? [[String]] else { continue }
|
||||
for reqTag in reqTags where reqTag.first == "amount" && reqTag.count > 1 {
|
||||
if let msats = Int(reqTag[1]), msats > 0 {
|
||||
return msats / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/// Format sats for compact display: 500 -> "500", 1500 -> "1.5K", 1000000 -> "1M".
|
||||
private func formatSats(_ sats: Int) -> String {
|
||||
if sats >= 1_000_000 {
|
||||
let val = Double(sats) / 1_000_000.0
|
||||
if val == val.rounded(.down) {
|
||||
return "\(Int(val))M"
|
||||
}
|
||||
return String(format: "%.1fM", val).replacingOccurrences(of: ".0M", with: "M")
|
||||
} else if sats >= 1_000 {
|
||||
let val = Double(sats) / 1_000.0
|
||||
if val == val.rounded(.down) {
|
||||
return "\(Int(val))K"
|
||||
}
|
||||
return String(format: "%.1fK", val).replacingOccurrences(of: ".0K", with: "K")
|
||||
}
|
||||
return "\(sats)"
|
||||
}
|
||||
|
||||
// MARK: - Last-Seen Timestamp
|
||||
|
||||
var lastSeenTimestamp: Int {
|
||||
UserDefaults.standard.integer(forKey: Self.lastSeenKey)
|
||||
}
|
||||
|
||||
func setLastSeenTimestamp(_ ts: Int) {
|
||||
UserDefaults.standard.set(ts, forKey: Self.lastSeenKey)
|
||||
}
|
||||
}
|
||||
@@ -1,475 +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: "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 WebView on top of the Capacitor WebView.
|
||||
if let bridge = self.bridge,
|
||||
let webView = bridge.webView {
|
||||
webView.superview?.addSubview(sandbox.webView)
|
||||
}
|
||||
|
||||
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.webView.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.webView.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 {
|
||||
let id: String
|
||||
let webView: WKWebView
|
||||
let schemeHandler: SandboxSchemeHandler
|
||||
private weak var plugin: SandboxPlugin?
|
||||
private let customScheme: String
|
||||
|
||||
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
|
||||
|
||||
self.webView = WKWebView(frame: frame, configuration: config)
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = .white
|
||||
self.webView.scrollView.bounces = false
|
||||
|
||||
super.init()
|
||||
|
||||
// Register the message handler after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
|
||||
// Load the initial page via the custom scheme.
|
||||
let initialURL = URL(string: "\(self.customScheme)://app/index.html")!
|
||||
self.webView.load(URLRequest(url: initialURL))
|
||||
}
|
||||
|
||||
/// 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: - 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"]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,10 @@ let package = Package(
|
||||
.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: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar"),
|
||||
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
|
||||
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
|
||||
],
|
||||
@@ -29,10 +29,10 @@ let package = Package(
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"),
|
||||
.product(name: "CapgoCapacitorAutofillSavePassword", package: "CapgoCapacitorAutofillSavePassword"),
|
||||
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
|
||||
]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
app_identifier("pub.ditto.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/Ditto.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 Ditto 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: "Ditto.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 pub.ditto.app'",
|
||||
"DEVELOPMENT_TEAM=GZLTTH5DLM",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
signingStyle: "manual",
|
||||
teamID: "GZLTTH5DLM",
|
||||
provisioningProfiles: {
|
||||
"pub.ditto.app" => "match AppStore pub.ditto.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(["pub.ditto.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).
|
||||
@@ -0,0 +1,30 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $vite_backend http://vite:8080;
|
||||
proxy_pass $vite_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"name": "agora",
|
||||
"private": true,
|
||||
"version": "2.6.3",
|
||||
"version": "2.8.0",
|
||||
"type": "module",
|
||||
"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,13 +15,15 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.2",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/keyboard": "^8.0.3",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -67,9 +69,11 @@
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@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",
|
||||
@@ -98,6 +102,8 @@
|
||||
"@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",
|
||||
@@ -108,6 +114,8 @@
|
||||
"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",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
@@ -115,9 +123,14 @@
|
||||
"fflate": "^0.8.2",
|
||||
"hls.js": "^1.6.15",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^26.0.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"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",
|
||||
@@ -126,7 +139,9 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.3",
|
||||
"react-router-dom": "^6.26.2",
|
||||
@@ -151,7 +166,9 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"GZLTTH5DLM.pub.ditto.app"
|
||||
"GZLTTH5DLM.pub.agora.app"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
[{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "pub.ditto.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"]
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "pub.agora.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71",
|
||||
"E5:B1:A9:13:C9:37:35:3C:A5:E7:27:89:C0:9D:3D:0D:A5:4F:F5:26:88:06:BD:24:46:21:AB:61:6B:CC:C5:E5"
|
||||
]
|
||||
}
|
||||
}
|
||||
}]
|
||||
]
|
||||
@@ -1,365 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- Manage your interest tabs (hashtags and locations) from the settings page
|
||||
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
|
||||
- Follow packs and follow sets now show author info and action headers in the feed
|
||||
- Posts now show whether they were created or updated, so you can tell when something's been edited
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
|
||||
- Nsite previews now use the same secure sandbox as webxdc apps
|
||||
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
|
||||
|
||||
### Fixed
|
||||
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
|
||||
- Mobile compose box no longer randomly collapses or becomes unclickable
|
||||
- Profile avatar and banner lightbox no longer hides behind the right sidebar
|
||||
- Infinite scroll on custom profile tab feeds no longer reloads the same content
|
||||
- Reaction emoji are now visible on each row in the interactions modal
|
||||
- Missing bottom border on collapsed thread expand button restored
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
|
||||
- Poll votes now appear as activity cards in feeds and on detail pages
|
||||
|
||||
### Fixed
|
||||
- Threads and replies load more reliably by following relay and author hints when fetching parent events
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
|
||||
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
|
||||
|
||||
### Changed
|
||||
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
|
||||
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
|
||||
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
|
||||
|
||||
### Fixed
|
||||
- Notification dot not clearing after marking notifications as read
|
||||
- Followers/following modal staying open after navigating to a profile
|
||||
|
||||
## [2.2.7] - 2026-03-31
|
||||
|
||||
### Fixed
|
||||
- Nushu script in encrypted letters now renders correctly on Android and iOS
|
||||
|
||||
## [2.2.6] - 2026-03-31
|
||||
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
- Badge detail page streamlined with unified tab bar
|
||||
|
||||
### Fixed
|
||||
- Hashtags now support accented and Unicode characters
|
||||
- Letter compose opens correctly from notifications and the letters page
|
||||
- Letter font picker loads fonts so each option previews in the correct typeface
|
||||
- Zap comment positioned inside the right column instead of floating with offset
|
||||
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
|
||||
|
||||
## [2.2.5] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Crash when dragging profile tabs to reorder them
|
||||
|
||||
## [2.2.4] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
|
||||
- Zap moved to the profile overflow menu so it's still one tap away
|
||||
|
||||
### Fixed
|
||||
- Crash on the notifications page caused by malformed badge award tags
|
||||
- Deleting a badge now also deletes all awards you issued for it
|
||||
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
|
||||
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
|
||||
- Profile reactions no longer collapse into a single grouped notification
|
||||
- Oversized reaction emoji in comment context headers
|
||||
|
||||
## [2.2.3] - 2026-03-30
|
||||
|
||||
### Added
|
||||
- Letters now have an overflow menu, reply button, and a grid layout for browsing
|
||||
- Independent feed toggles for comments and generic reposts in content settings
|
||||
- Sidebar items are now visible to logged-out users so newcomers can explore everything
|
||||
|
||||
### Changed
|
||||
- Compose textarea expands smoothly as you type instead of snapping to a new height
|
||||
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
|
||||
|
||||
### Fixed
|
||||
- Feed gaps when replies are disabled no longer cause missing posts
|
||||
- Avatar shape no longer flashes on load
|
||||
- Top bar arc no longer flickers during navigation transitions
|
||||
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
|
||||
- Notification rendering for badges and letters
|
||||
- Duplicate React keys in content settings
|
||||
- Layout rendering warning when switching views
|
||||
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
- Changelog link in the app footer
|
||||
|
||||
### Changed
|
||||
- "Vines" renamed to "Divines" everywhere in the app
|
||||
- Custom emojis appear first in the emoji picker, right after recent
|
||||
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
|
||||
|
||||
### Fixed
|
||||
- Delete post dialog no longer freezes the feed on desktop
|
||||
- Amber login on Android now properly retries when returning from the background
|
||||
- Key downloads on Android save to the correct location
|
||||
- Custom emoji SVGs render correctly in the emoji-mart picker
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
- Inconsistent use of "geocache" vs "treasures" terminology
|
||||
- Search page "N new posts" pill no longer shows unfiltered count
|
||||
- Stale-cache overwrites in replaceable event mutations
|
||||
- Click-through on delete confirmation and note menu items
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
|
||||
- Mobile header no longer shows double-layered backgrounds on notched devices
|
||||
- Pinned tabs stay properly positioned when scrolling on mobile
|
||||
- Signer approval toasts no longer fire in rapid succession on unstable connections
|
||||
- Toasts are easier to swipe away on mobile
|
||||
- Content warnings now blur thumbnails in the media grid
|
||||
|
||||
## [2.2.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
### Changed
|
||||
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
|
||||
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
|
||||
- Upgraded from React 18 to React 19
|
||||
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
|
||||
|
||||
### Fixed
|
||||
- Zapping Primal users no longer produces an error
|
||||
- Hashtag feeds now match case-insensitively for parity with search results
|
||||
- Mobile top bar arc no longer lingers on pages without tabs
|
||||
- Give Badge dialog and profile menu action handlers
|
||||
|
||||
## [2.1.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- Emoji picker and shortcode autocomplete in zap comment box
|
||||
- Zap button on badge detail view
|
||||
- Theme descriptions now display on "updated their theme" posts and detail pages
|
||||
- Badge thumbnail previews in award notifications
|
||||
- Letter notifications with envelope card preview
|
||||
- Kind-specific labels in notification text instead of generic "post"
|
||||
|
||||
### Fixed
|
||||
- Compose modal no longer closes when dismissing emoji picker on mobile
|
||||
- Compose preview overflow is now scrollable in modal
|
||||
- Toast notifications swipe up to dismiss on mobile instead of sideways
|
||||
- File downloads and URL opening work correctly on iOS
|
||||
- Badges page no longer shows infinite skeleton when logged out
|
||||
|
||||
## [2.1.0] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
|
||||
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
|
||||
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
|
||||
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
|
||||
- Letters page added to the sidebar with a custom mailbox icon
|
||||
|
||||
## [2.0.1] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Tap the version number in settings to see what's new
|
||||
|
||||
## [2.0.0] - 2026-03-26
|
||||
|
||||
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
|
||||
- Initial Agora 3 release.
|
||||
|
||||
|
Before Width: | Height: | Size: 5.3 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 |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.6 KiB |