forked from shadowfacts/Tusker
Compare commits
1171 Commits
simple-swi
...
develop
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 2ee34acbad | |
Shadowfacts | 6eee97759e | |
Shadowfacts | f88bf552af | |
Shadowfacts | d2c7664073 | |
Shadowfacts | e91249a876 | |
Shadowfacts | 1eab964c0b | |
Shadowfacts | 2933ac491b | |
Shadowfacts | 2958d2b1ac | |
Shadowfacts | 3262fe002b | |
Shadowfacts | 521e5ad5fc | |
Shadowfacts | 2b651b0bc4 | |
Shadowfacts | 99b3532e64 | |
Shadowfacts | 2ea8e9cf1e | |
Shadowfacts | e8b7446117 | |
Shadowfacts | a47b9c0c75 | |
Shadowfacts | a75862b5cc | |
Shadowfacts | 0738683ee3 | |
Shadowfacts | 155f4036f9 | |
Shadowfacts | 8181090763 | |
Shadowfacts | 6328627a97 | |
Shadowfacts | c6043d60ee | |
Shadowfacts | dd6813c058 | |
Shadowfacts | 2229b332e0 | |
Shadowfacts | 63ed3b6e10 | |
Shadowfacts | ccd1672e72 | |
Shadowfacts | addcc2dacc | |
Shadowfacts | a49e9f2c1f | |
Shadowfacts | b1421767dd | |
Shadowfacts | 8ee916411e | |
Shadowfacts | 9d845bf6c1 | |
Shadowfacts | 9a2c24942a | |
Shadowfacts | cca2a03b2f | |
Shadowfacts | 1a64bfcef8 | |
Shadowfacts | 907810d98a | |
Shadowfacts | 23a4999196 | |
Shadowfacts | 3e0feba273 | |
Shadowfacts | 468a559127 | |
Shadowfacts | c03fc86300 | |
Shadowfacts | a33be0b556 | |
Shadowfacts | 6aee926f00 | |
Shadowfacts | 13640be91d | |
Shadowfacts | 5123cf20c3 | |
Shadowfacts | bf739b9f41 | |
Shadowfacts | 4211806b5f | |
Shadowfacts | 88aada8d35 | |
Shadowfacts | 5623cedab3 | |
Shadowfacts | ccfc8331fb | |
Shadowfacts | 10803408cd | |
Shadowfacts | fb7a7db6e8 | |
Shadowfacts | 78cd1313fe | |
Shadowfacts | db1bbf7148 | |
Shadowfacts | 5f19adf2d0 | |
Shadowfacts | 6f006adbc1 | |
Shadowfacts | 39bff06897 | |
Shadowfacts | 68682ee291 | |
Shadowfacts | 5029b26b40 | |
Shadowfacts | 907cf08400 | |
Shadowfacts | e85d194e5f | |
Shadowfacts | cfeb87d2ba | |
Shadowfacts | e4f3735c9f | |
Shadowfacts | baa9dfe0f1 | |
Shadowfacts | 5e73439e7b | |
Shadowfacts | 4b2776ee81 | |
Shadowfacts | 566df3e285 | |
Shadowfacts | 0653d695d9 | |
Shadowfacts | 4811747790 | |
Shadowfacts | ed2519848c | |
Shadowfacts | b1374b12a3 | |
Shadowfacts | c5a25eecf1 | |
Shadowfacts | a4dbf3ddbb | |
Shadowfacts | be3a61ebc7 | |
Shadowfacts | ababa4b428 | |
Shadowfacts | d75c2558ca | |
Shadowfacts | ac0dedfd3d | |
Shadowfacts | 37563b6afd | |
Shadowfacts | 937afc0dfd | |
Shadowfacts | 94c34e03dd | |
Shadowfacts | 1ad556f9cf | |
Shadowfacts | 019f7d6d6a | |
Shadowfacts | b4384d11f5 | |
Shadowfacts | 2ed8d22899 | |
Shadowfacts | cce6413e2b | |
Shadowfacts | 8fb0fb66e3 | |
Shadowfacts | abe2bbdfd4 | |
Shadowfacts | 1d9efc7fb5 | |
Shadowfacts | b17b7b7a24 | |
Shadowfacts | 18d7917756 | |
Shadowfacts | cc401fce8c | |
Shadowfacts | a5fc35d0b1 | |
Shadowfacts | acd48a6db4 | |
Shadowfacts | b45d3fb80a | |
Shadowfacts | 3ea1ad5622 | |
Shadowfacts | 5898da3234 | |
Shadowfacts | 9dd966f639 | |
Shadowfacts | 48662ef1f3 | |
Shadowfacts | 854d48e54e | |
Shadowfacts | d4c560d7fc | |
Shadowfacts | 91b7ce3008 | |
Shadowfacts | 4dca231a06 | |
Shadowfacts | b81c83a250 | |
Shadowfacts | f9e619d9e7 | |
Shadowfacts | ae7962ae50 | |
Shadowfacts | 5027660b52 | |
Shadowfacts | 358d81b5cf | |
Shadowfacts | 79b9108a8f | |
Shadowfacts | 5ab22e742b | |
Shadowfacts | 4f655bb80a | |
Shadowfacts | e4f1309e2d | |
Shadowfacts | bb40894778 | |
Shadowfacts | 24b3fa1e3f | |
Shadowfacts | 16cd045588 | |
Shadowfacts | 15a7cd5f65 | |
Shadowfacts | e676075d5b | |
Shadowfacts | 967bff063b | |
Shadowfacts | 3cba0bce34 | |
Shadowfacts | 60b182ac18 | |
Shadowfacts | 619878ac85 | |
Shadowfacts | 169f1a0191 | |
Shadowfacts | fa31c28e92 | |
Shadowfacts | f815d4e2e4 | |
Shadowfacts | a3e5b29cfc | |
Shadowfacts | 46cecde014 | |
Shadowfacts | 86143c5887 | |
Shadowfacts | 0a1dc423d4 | |
Shadowfacts | 1cb0f1ae56 | |
Shadowfacts | 9f86158bb7 | |
Shadowfacts | 231b0ea830 | |
Shadowfacts | 4dc108f782 | |
Shadowfacts | 795146cde4 | |
Shadowfacts | 975be17d13 | |
Shadowfacts | 32be76ebee | |
Shadowfacts | d13b517128 | |
Shadowfacts | e0d97cd2a8 | |
Shadowfacts | 8b718ce50b | |
Shadowfacts | ce708e2d16 | |
Shadowfacts | 01467574d0 | |
Shadowfacts | 97a2278634 | |
Shadowfacts | 4b2a263889 | |
Shadowfacts | 1f37a5e7eb | |
Shadowfacts | 77c9fac3ce | |
Shadowfacts | a13d5d5a82 | |
Shadowfacts | 23e4541eb7 | |
Shadowfacts | d4b9f71fd3 | |
Shadowfacts | a9edeaf5b9 | |
Shadowfacts | 1f6074e539 | |
Shadowfacts | df7b62e14b | |
Shadowfacts | cacc8a51cc | |
Shadowfacts | 89ca0629b3 | |
Shadowfacts | 360db07ef2 | |
Shadowfacts | f55a870964 | |
Shadowfacts | 5ee140cdab | |
Shadowfacts | ff4dff1147 | |
Shadowfacts | ba1eed7a85 | |
Shadowfacts | 0c9f6e02bd | |
Shadowfacts | 565d17970f | |
Shadowfacts | dc3c2d027c | |
Shadowfacts | ba2c34fdd6 | |
Shadowfacts | 3691c3f483 | |
Shadowfacts | 9c103103e8 | |
Shadowfacts | 382d8ef2c8 | |
Shadowfacts | 2891f47cb3 | |
Shadowfacts | 3c80ec8b43 | |
Shadowfacts | 478ba3db28 | |
Shadowfacts | f96cd1b5e2 | |
Shadowfacts | 7f4ab57a1d | |
Shadowfacts | 8caf93bf0a | |
Shadowfacts | 9c4b68b09e | |
Shadowfacts | b49e8d0279 | |
Shadowfacts | 71a57e9859 | |
Shadowfacts | 081ef16e5e | |
Shadowfacts | b3ec259ce9 | |
Shadowfacts | 4f48514d1a | |
Shadowfacts | f96acd33f2 | |
Shadowfacts | cde061c77a | |
Shadowfacts | a79b3cfd70 | |
Shadowfacts | 9a35f96c75 | |
Shadowfacts | 60767c6a7e | |
Shadowfacts | 57668886b2 | |
Shadowfacts | ffb5c76f7c | |
Shadowfacts | 00e8dd6345 | |
Shadowfacts | 7904462920 | |
Shadowfacts | 13d649bace | |
Shadowfacts | bebe563e8f | |
Shadowfacts | 4be2258882 | |
Shadowfacts | 40ff8d0a2a | |
Shadowfacts | 0dcb7e71c4 | |
Shadowfacts | 08878f2fb9 | |
Shadowfacts | 3ea7e1057b | |
Shadowfacts | fc8fcb76fd | |
Shadowfacts | eac2a9b19f | |
Shadowfacts | 0ce57d1308 | |
Shadowfacts | 97dec0f9d2 | |
Shadowfacts | b64c748b73 | |
Shadowfacts | 77ab2c3753 | |
Shadowfacts | b90262bfd0 | |
Shadowfacts | 581f4b24bd | |
Shadowfacts | 5f3d9da9f8 | |
Shadowfacts | 41775e5d19 | |
Shadowfacts | 044d34d20f | |
Shadowfacts | f1b1732e5c | |
Shadowfacts | 1da2b17a76 | |
Shadowfacts | e49725e06d | |
Shadowfacts | 669404d6f8 | |
Shadowfacts | 2e21742264 | |
Shadowfacts | 7763d08816 | |
Shadowfacts | 726be85223 | |
Shadowfacts | 19bf6cbf18 | |
Shadowfacts | df07fa85d5 | |
Shadowfacts | e3e55de55b | |
Shadowfacts | 54857a3bf3 | |
Shadowfacts | b28f616e85 | |
Shadowfacts | 97c7104dbc | |
Shadowfacts | 6501343f24 | |
Shadowfacts | fabe339215 | |
Shadowfacts | e1886509d3 | |
Shadowfacts | 8ad48784d9 | |
Shadowfacts | 75e9c9f986 | |
Shadowfacts | a17afe247c | |
Shadowfacts | 81abcfcf7b | |
Shadowfacts | 7e5d8675c2 | |
Shadowfacts | cde3109203 | |
Shadowfacts | fcf95ba8c1 | |
Shadowfacts | f71804f094 | |
Shadowfacts | 83ca7f1321 | |
Shadowfacts | 16a1e4008b | |
Shadowfacts | 518a8eba0a | |
Shadowfacts | 8d56a6450e | |
Shadowfacts | 8896bfbc59 | |
Shadowfacts | 4ca57f8c76 | |
Shadowfacts | c9fa11cc3b | |
Shadowfacts | 0247c50650 | |
Shadowfacts | eca06cb14a | |
Shadowfacts | c07e2cfdd8 | |
Shadowfacts | db7615d26f | |
Shadowfacts | 2f0acad866 | |
Shadowfacts | a2b3fc0628 | |
Shadowfacts | e005b70071 | |
Shadowfacts | b515664db3 | |
Shadowfacts | 948eff1f7e | |
Shadowfacts | f1a39c2faa | |
Shadowfacts | ab8e498cee | |
Shadowfacts | c6da754875 | |
Shadowfacts | 97d5b955a0 | |
Shadowfacts | 80f9800fd6 | |
Shadowfacts | 0485400c1f | |
Shadowfacts | 811aac35d7 | |
Shadowfacts | a77b090435 | |
Shadowfacts | 21874b0966 | |
Shadowfacts | 08c63a2f84 | |
Shadowfacts | 97f00e9d6f | |
Shadowfacts | a97a7e0aea | |
Shadowfacts | cf870916c9 | |
Shadowfacts | 7297566060 | |
Shadowfacts | 4f28fec62a | |
Shadowfacts | c01bc4d840 | |
Shadowfacts | ea6698a2d8 | |
Shadowfacts | 1e950b5ccb | |
Shadowfacts | 3e5a3c81b5 | |
Shadowfacts | a5506aeab6 | |
Shadowfacts | 23b76a7276 | |
Shadowfacts | d8f503351b | |
Shadowfacts | d5887f1f02 | |
Shadowfacts | e04cdd16d6 | |
Shadowfacts | c256fb4cbd | |
Shadowfacts | 21299c8eb8 | |
Shadowfacts | 527706154a | |
Shadowfacts | 07c86b6949 | |
Shadowfacts | 92cf938e99 | |
Shadowfacts | f23d3dfa3f | |
Shadowfacts | 23f9e200dc | |
Shadowfacts | 366834e2e4 | |
Shadowfacts | d409d26478 | |
Shadowfacts | 76fc73de95 | |
Shadowfacts | 40800f964d | |
Shadowfacts | 9f7d16a70e | |
Shadowfacts | c2cb0a0c5a | |
Shadowfacts | 272f35417b | |
Shadowfacts | 848c3dd950 | |
Shadowfacts | dfeb39b31f | |
Shadowfacts | bab5226f2a | |
Shadowfacts | 88cfbfb1f3 | |
Shadowfacts | 49f1d6339f | |
Shadowfacts | 3e7cb443fa | |
Shadowfacts | b5c8a38b9b | |
Shadowfacts | ab19922530 | |
Shadowfacts | 45c844b065 | |
Shadowfacts | 47b838a386 | |
Shadowfacts | 276691efbf | |
Shadowfacts | 0a8d50cc27 | |
Shadowfacts | 11e81acbc1 | |
Shadowfacts | fb2c9b341c | |
Shadowfacts | 810ae71832 | |
Shadowfacts | 001a73af3c | |
Shadowfacts | c8375b742a | |
Shadowfacts | 9feef054fc | |
Shadowfacts | bf87ae7a7d | |
Shadowfacts | f8de6f9e10 | |
Shadowfacts | ab47fa776e | |
Shadowfacts | 7178473f34 | |
Shadowfacts | c8319d8af2 | |
Shadowfacts | 9ff1452c68 | |
Shadowfacts | ce534c4a05 | |
Shadowfacts | 0fddf94292 | |
Shadowfacts | 8276e99d27 | |
Shadowfacts | a5ad8e43b1 | |
Shadowfacts | ce7ce3ac92 | |
Shadowfacts | 99a1c76cb1 | |
Shadowfacts | 603e989879 | |
Shadowfacts | dd82283341 | |
Shadowfacts | af2d9e7eb8 | |
Shadowfacts | 06ad46e639 | |
Shadowfacts | 71f97d41c4 | |
Shadowfacts | df131f32c6 | |
Shadowfacts | 77dece36d0 | |
Shadowfacts | 1a767ff910 | |
Shadowfacts | 220c8050b1 | |
Shadowfacts | d4fa9c96e8 | |
Shadowfacts | 22b5d62ba1 | |
Shadowfacts | b9bdd29986 | |
Shadowfacts | f848bbf7c4 | |
Shadowfacts | 0fe9edfdbc | |
Shadowfacts | 6d2830cf78 | |
Shadowfacts | 7294ff6e1a | |
Shadowfacts | 3fd62552b3 | |
Shadowfacts | fa5abc27f7 | |
Shadowfacts | ccc47e204d | |
Shadowfacts | bf3f735062 | |
Shadowfacts | de0198946e | |
Shadowfacts | 072a77b58e | |
Shadowfacts | eb7fe22863 | |
Shadowfacts | f1511039ef | |
Shadowfacts | 5c479e3bf0 | |
Shadowfacts | 0413f326a0 | |
Shadowfacts | 9d1c3f1410 | |
Shadowfacts | 802a0ac9ba | |
Shadowfacts | 9da986e3b8 | |
Shadowfacts | e6a5b899be | |
Shadowfacts | 60bf3b2e33 | |
Shadowfacts | b465838b71 | |
Shadowfacts | 21bd716844 | |
Shadowfacts | 523fb91b21 | |
Shadowfacts | d8bf770902 | |
Shadowfacts | 10aa32d9cc | |
Shadowfacts | 7474969969 | |
Shadowfacts | 319b5458fc | |
Shadowfacts | f7304a011c | |
Shadowfacts | 94dc5d3177 | |
Shadowfacts | 6d692c2730 | |
Shadowfacts | d0f8691560 | |
Shadowfacts | 9a43ab5a13 | |
Shadowfacts | 01124b76a3 | |
Shadowfacts | 7600954f4b | |
Shadowfacts | 5a5c67e445 | |
Shadowfacts | 68c3affacf | |
Shadowfacts | e40f4faa8e | |
Shadowfacts | b56c6c37ec | |
Shadowfacts | 999118798c | |
Shadowfacts | 84cf755332 | |
Shadowfacts | 5bd7c0ad2b | |
Shadowfacts | 7fe06d42ce | |
Shadowfacts | 20986ba3f0 | |
Shadowfacts | 97a95c435e | |
Shadowfacts | b9555cf7dd | |
Shadowfacts | 590b9f0bcc | |
Shadowfacts | ca2ceaea56 | |
Shadowfacts | 96d8a79d42 | |
Shadowfacts | 11233f7d25 | |
Shadowfacts | a991e0f429 | |
Shadowfacts | bfdce07d81 | |
Shadowfacts | f5953655c5 | |
Shadowfacts | 6bc4993d81 | |
Shadowfacts | 68646c4b4d | |
Shadowfacts | 38b0d57118 | |
Shadowfacts | b38c24b347 | |
Shadowfacts | a6d51cee3c | |
Shadowfacts | 7bdbd9f71a | |
Shadowfacts | b47876dc3d | |
Shadowfacts | 4644475bc7 | |
Shadowfacts | 16ba292afa | |
Shadowfacts | c7f3bac330 | |
Shadowfacts | abb8352c92 | |
Shadowfacts | 59d866aa23 | |
Shadowfacts | ba032412eb | |
Shadowfacts | 5de0c034f4 | |
Shadowfacts | b1d83f2746 | |
Shadowfacts | 658c08010d | |
Shadowfacts | 6a5753fac8 | |
Shadowfacts | 8da89986df | |
Shadowfacts | c7e39cb041 | |
Shadowfacts | b755607895 | |
Shadowfacts | 508eef8c07 | |
Shadowfacts | a18dfc38af | |
Shadowfacts | 95f9fad673 | |
Shadowfacts | 4857b507b1 | |
Shadowfacts | bca7bd3586 | |
Shadowfacts | 9978e392a2 | |
Shadowfacts | cc33cf18f2 | |
Shadowfacts | c5921bc4cb | |
Shadowfacts | 91450ced7c | |
Shadowfacts | 5afd9e83eb | |
Shadowfacts | d05275020f | |
Shadowfacts | c420c236d9 | |
Shadowfacts | d5433e9b91 | |
Shadowfacts | cbbe9ec11f | |
Shadowfacts | 0e06d47687 | |
Shadowfacts | c907b7257a | |
Shadowfacts | 10239d14c9 | |
Shadowfacts | 2344275ff9 | |
Shadowfacts | e0ffa1d9c5 | |
Shadowfacts | 77a6654ff2 | |
Shadowfacts | 43aee0ec67 | |
Shadowfacts | d95ba82e5b | |
Shadowfacts | b6d8232951 | |
Shadowfacts | bb9cef55ea | |
Shadowfacts | 67718d8fe4 | |
Shadowfacts | 71a2029752 | |
Shadowfacts | 6bb1f3b7dc | |
Shadowfacts | 2469d285bc | |
Shadowfacts | 5f410213e2 | |
Shadowfacts | bb3e1b44b1 | |
Shadowfacts | 868df25417 | |
Shadowfacts | 2801f65e67 | |
Shadowfacts | cccde29e6c | |
Shadowfacts | aa0629d202 | |
Shadowfacts | ba209fa4d2 | |
Shadowfacts | d224f47b8c | |
Shadowfacts | ffb0ceba20 | |
Shadowfacts | 22022f5ef6 | |
Shadowfacts | 1ac72bc363 | |
Shadowfacts | dcc8f38f3d | |
Shadowfacts | 8cf217d2ba | |
Shadowfacts | 7d66117fab | |
Shadowfacts | 9c0c1f87f8 | |
Shadowfacts | 7a2d8e78eb | |
Shadowfacts | c15a5fc90f | |
Shadowfacts | 212ce69ffd | |
Shadowfacts | 7470b053c6 | |
Shadowfacts | d1b4b39e86 | |
Shadowfacts | b43f0d5bd9 | |
Shadowfacts | 035034430e | |
Shadowfacts | a703b7cc0a | |
Shadowfacts | e78bec8409 | |
Shadowfacts | 412e4a4dc5 | |
Shadowfacts | 81e10326d3 | |
Shadowfacts | 20f88ef161 | |
Shadowfacts | bce0f8ef18 | |
Shadowfacts | d661870401 | |
Shadowfacts | afa1a733f4 | |
Shadowfacts | 1b186725ce | |
Shadowfacts | 164a8e26c4 | |
Shadowfacts | cadcc1a92a | |
Shadowfacts | bcb3c24027 | |
Shadowfacts | fd6a4ba41c | |
Shadowfacts | 3ab82b2dbb | |
Shadowfacts | 1ed218d5e3 | |
Shadowfacts | 0fee770411 | |
Shadowfacts | 5b116c0d4e | |
Shadowfacts | b7a4f7e30f | |
Shadowfacts | ba1300b1b7 | |
Shadowfacts | 817ef0c2cc | |
Shadowfacts | 18ee621489 | |
Shadowfacts | ddf5094acf | |
Shadowfacts | 133921848d | |
Shadowfacts | 46db70d58b | |
Shadowfacts | 21958eb77f | |
Shadowfacts | b30f149dc9 | |
Shadowfacts | 9b83566482 | |
Shadowfacts | b688631937 | |
Shadowfacts | 4d654358d7 | |
Shadowfacts | 24e90de672 | |
Shadowfacts | 780e8b09b7 | |
Shadowfacts | 2196663d94 | |
Shadowfacts | 7085ac01cb | |
Shadowfacts | 81671d73c7 | |
Shadowfacts | a38c89a17f | |
Shadowfacts | 253fb8d27d | |
Shadowfacts | a682c8f5cc | |
Shadowfacts | d18a4b3c42 | |
Shadowfacts | 426b31d46c | |
Shadowfacts | 5c09b1910f | |
Shadowfacts | fe72d8faec | |
Shadowfacts | b560bcd8dc | |
Shadowfacts | 85ced7ff5f | |
Shadowfacts | 5ac76ef9c4 | |
Shadowfacts | 123a512d3c | |
Shadowfacts | d141ed7d03 | |
Shadowfacts | 95e120afd6 | |
Shadowfacts | ca8a214cf6 | |
Shadowfacts | 7161861d36 | |
Shadowfacts | c6c8f63e39 | |
Shadowfacts | e9962997a6 | |
Shadowfacts | f2ab1778c5 | |
Shadowfacts | 0f71d61b88 | |
Shadowfacts | 80c4fcce82 | |
Shadowfacts | 8f8d50efbd | |
Shadowfacts | 43b4976ed7 | |
Shadowfacts | ff3681627b | |
Shadowfacts | 35d21fb725 | |
Shadowfacts | bbfb3b0a7a | |
Shadowfacts | 8b78a5e7ad | |
Shadowfacts | 66c17006d1 | |
Shadowfacts | 8a911f238b | |
Shadowfacts | 77c44c323f | |
Shadowfacts | c2d1fe45d8 | |
Shadowfacts | 24591cee05 | |
Shadowfacts | 50dd785ef8 | |
Shadowfacts | af2e95ea39 | |
Shadowfacts | 4fa1bd7268 | |
Shadowfacts | ea07e6aef6 | |
Shadowfacts | 5e7a1e5974 | |
Shadowfacts | 9b3cc61dcb | |
Shadowfacts | 0c37b99a68 | |
Shadowfacts | f96d1d780c | |
Shadowfacts | 5a5364ad3b | |
Shadowfacts | 5b70c713b2 | |
Shadowfacts | efb96eddf3 | |
Shadowfacts | 5cb25c8c1f | |
Shadowfacts | 700cc2c67c | |
Shadowfacts | a9e0bffe5f | |
Shadowfacts | 512e0e9053 | |
Shadowfacts | b842389449 | |
Shadowfacts | cc10a13785 | |
Shadowfacts | f9c3ad5921 | |
Shadowfacts | 0960699699 | |
Shadowfacts | c6e06fe9f3 | |
Shadowfacts | 10f6a68065 | |
Shadowfacts | 037b717e60 | |
Shadowfacts | 9fa352d4f8 | |
Shadowfacts | 73345bb927 | |
Shadowfacts | f5385b0a1d | |
Shadowfacts | 46fbbdc99a | |
Shadowfacts | 6ef8c92d09 | |
Shadowfacts | 08b7cf013b | |
Shadowfacts | f702df2f15 | |
Shadowfacts | 92efee6f46 | |
Shadowfacts | facf039f97 | |
Shadowfacts | d7f35cd1e4 | |
Shadowfacts | 332637e0d9 | |
Shadowfacts | 6d6fd3d49d | |
Shadowfacts | b4675a97c7 | |
Shadowfacts | 02e3417c27 | |
Shadowfacts | f5ac2616ad | |
Shadowfacts | 01bb37b0f6 | |
Shadowfacts | a4d43889ce | |
Shadowfacts | 4991da1622 | |
Shadowfacts | f106cc78bb | |
Shadowfacts | 2617d22819 | |
Shadowfacts | dbdf1d39bd | |
Shadowfacts | 54ff3893a6 | |
Shadowfacts | 0168c05259 | |
Shadowfacts | 65e75afa8b | |
Shadowfacts | 90809811c1 | |
Shadowfacts | 0f6e9c97cc | |
Shadowfacts | 98516e3802 | |
Shadowfacts | 68b03838a2 | |
Shadowfacts | 1f0025b101 | |
Shadowfacts | b46f007f64 | |
Shadowfacts | ecab33bdce | |
Shadowfacts | cc0da2ec54 | |
Shadowfacts | a2868739c2 | |
Shadowfacts | 2f75510889 | |
Shadowfacts | 46332cd1b9 | |
Shadowfacts | 21e9ca990d | |
Shadowfacts | 1a02319894 | |
Shadowfacts | 4a95ccccdb | |
Shadowfacts | d3187ce2c4 | |
Shadowfacts | ed0643c4ad | |
Shadowfacts | 1e2947ceba | |
Shadowfacts | ddcb13dd28 | |
Shadowfacts | c71bf3ba23 | |
Shadowfacts | 3e5c441b24 | |
Shadowfacts | 0b6c16b0a6 | |
Shadowfacts | 5f566724bb | |
Shadowfacts | 4a89ae3cfe | |
Shadowfacts | 56a0518c80 | |
Shadowfacts | bf8a294676 | |
Shadowfacts | c069712c22 | |
Shadowfacts | d04957ba41 | |
Shadowfacts | 8cc08cf4c0 | |
Shadowfacts | 1b917f6bed | |
Shadowfacts | 514e569bd5 | |
Shadowfacts | a22059a1a1 | |
Shadowfacts | 2cfefc9432 | |
Shadowfacts | 2f7c7bae5e | |
Shadowfacts | 3f04d74dd6 | |
Shadowfacts | 4dd8c1d692 | |
Shadowfacts | eb9a5aeb42 | |
Shadowfacts | 7465abe0a9 | |
Shadowfacts | 20dab7c77a | |
Shadowfacts | 4e105e0fbc | |
Shadowfacts | d2f1d78aa2 | |
Shadowfacts | 360f52d0cf | |
Shadowfacts | 8c888906c9 | |
Shadowfacts | d611aeb035 | |
Shadowfacts | 0e888d35eb | |
Shadowfacts | 98bb230817 | |
Shadowfacts | 3d6d9b2a91 | |
Shadowfacts | bc9a700383 | |
Shadowfacts | 62c7a30bbc | |
Shadowfacts | abf6ff8115 | |
Shadowfacts | a718721537 | |
Shadowfacts | 4f99d3c6e1 | |
Shadowfacts | a2fc1652d1 | |
Shadowfacts | 77007dcea0 | |
Shadowfacts | dc818524b2 | |
Shadowfacts | d1ba1105b5 | |
Shadowfacts | 89a9bfba47 | |
Shadowfacts | 2798a199aa | |
Shadowfacts | 3d0402c1e0 | |
Shadowfacts | af0c9c92b6 | |
Shadowfacts | 0a7709526f | |
Shadowfacts | 9ec821f6b3 | |
Shadowfacts | 5c4474dc87 | |
Shadowfacts | 829ecf06da | |
Shadowfacts | cb2bb215d3 | |
Shadowfacts | 916c6fba0d | |
Shadowfacts | 8473f32781 | |
Shadowfacts | 240ccf23a4 | |
Shadowfacts | e49859e5ea | |
Shadowfacts | c6d158a8a3 | |
Shadowfacts | 7e90fe2401 | |
Shadowfacts | cab78a4aa4 | |
Shadowfacts | 7da139be4d | |
Shadowfacts | 2444783edf | |
Shadowfacts | 727615a818 | |
Shadowfacts | 6e3089f025 | |
Shadowfacts | e09b0ff4e3 | |
Shadowfacts | 830eea5e95 | |
Shadowfacts | 705fbbe343 | |
Shadowfacts | 12bcf52764 | |
Shadowfacts | f31c909517 | |
Shadowfacts | 781c37fbae | |
Shadowfacts | 930ec7ccff | |
Shadowfacts | de93d6e171 | |
Shadowfacts | 80c79ded3b | |
Shadowfacts | 126b0ae90a | |
Shadowfacts | d6a847bfcc | |
Shadowfacts | 9b33059089 | |
Shadowfacts | 804fdb439d | |
Shadowfacts | 6ba5f70615 | |
Shadowfacts | 54c01be7ff | |
Shadowfacts | 6e964ff601 | |
Shadowfacts | 73d33ae730 | |
Shadowfacts | 434d975767 | |
Shadowfacts | 41a31c23b7 | |
Shadowfacts | 02461ad46c | |
Shadowfacts | 072e68e97b | |
Shadowfacts | 6879acbe02 | |
Shadowfacts | ace503ad3d | |
Shadowfacts | e12a82b476 | |
Shadowfacts | 51cb7c3edf | |
Shadowfacts | 2198e2bf3e | |
Shadowfacts | 6138fc7748 | |
Shadowfacts | dc1eb3d6f0 | |
Shadowfacts | fa1482a152 | |
Shadowfacts | e65ed3e773 | |
Shadowfacts | eca7f31e82 | |
Shadowfacts | 2b22180191 | |
Shadowfacts | 654b5d9c59 | |
Shadowfacts | 777d1f378c | |
Shadowfacts | 3b132ab4dc | |
Shadowfacts | d1083116e0 | |
Shadowfacts | 7b79cec0ed | |
Shadowfacts | 50cbbb86fc | |
Shadowfacts | 5a914ea5a3 | |
Shadowfacts | ca5ac8b826 | |
Shadowfacts | 2b50609e5c | |
Shadowfacts | 57cb0614a9 | |
Shadowfacts | eccb1043db | |
Shadowfacts | 9768097488 | |
Shadowfacts | f5e9f71586 | |
Shadowfacts | 9f8b14d180 | |
Shadowfacts | 10a3cbbe9c | |
Shadowfacts | b917120f17 | |
Shadowfacts | 30ef9cc6d0 | |
Shadowfacts | 948c792e5d | |
Shadowfacts | 2df703ab71 | |
Shadowfacts | 1ec85ca095 | |
Shadowfacts | 5a26739b78 | |
Shadowfacts | 36a78f1a3c | |
Shadowfacts | 1c0291b1dd | |
Shadowfacts | e7d9e3780e | |
Shadowfacts | 83d4af2303 | |
Shadowfacts | 7c5076d01a | |
Shadowfacts | e61823b78f | |
Shadowfacts | 4d52ac4d34 | |
Shadowfacts | aced0a63c9 | |
Shadowfacts | 1e54235ff5 | |
Shadowfacts | e6e5554edf | |
Shadowfacts | 9026f487ec | |
Shadowfacts | c0097ba752 | |
Shadowfacts | f109253bba | |
Shadowfacts | 1fda4248ec | |
Shadowfacts | 7781c5252b | |
Shadowfacts | 7f4bf52050 | |
Shadowfacts | ba0d179de5 | |
Shadowfacts | 71b6f1bdf0 | |
Shadowfacts | 09ec4a920c | |
Shadowfacts | 7edf0fdb93 | |
Shadowfacts | 99e06441f0 | |
Shadowfacts | 85e1e131f6 | |
Shadowfacts | 1d79918a94 | |
Shadowfacts | 340d13b1fa | |
Shadowfacts | cf1000a4df | |
Shadowfacts | b781b56efd | |
Shadowfacts | 10a8a85bfc | |
Shadowfacts | 6d8a014cc7 | |
Shadowfacts | 60c88ded5e | |
Shadowfacts | 1e7a6af0bf | |
Shadowfacts | f8b79ef34f | |
Shadowfacts | 4cf56685b5 | |
Shadowfacts | fdcd2aa540 | |
Shadowfacts | 667d30a710 | |
Shadowfacts | b0f23e46ba | |
Shadowfacts | 9b30b48016 | |
Shadowfacts | bd49683e13 | |
Shadowfacts | c22945b1e7 | |
Shadowfacts | 0a16a2e261 | |
Shadowfacts | b95819cada | |
Shadowfacts | dc1ea1bed9 | |
Shadowfacts | 5f9fe505d5 | |
Shadowfacts | 5b8e97287e | |
Shadowfacts | 49572c1fec | |
Shadowfacts | ebb0770198 | |
Shadowfacts | 27e05cc72d | |
Shadowfacts | 4ca48a5f50 | |
Shadowfacts | 230bd50661 | |
Shadowfacts | 4f2f8d517f | |
Shadowfacts | 130da9d4cc | |
Shadowfacts | 472b9aa5e2 | |
Shadowfacts | 3413dff8f9 | |
Shadowfacts | 66e8fce488 | |
Shadowfacts | aa2d333f4a | |
Shadowfacts | c8a45d8eef | |
Shadowfacts | 40f5be28f6 | |
Shadowfacts | 7c9287543c | |
Shadowfacts | 2a05b6d326 | |
Shadowfacts | 2499d25432 | |
Shadowfacts | 9417872790 | |
Shadowfacts | c02a1bbf74 | |
Shadowfacts | 0a894b219a | |
Shadowfacts | 22803668d2 | |
Shadowfacts | 2f6d1cb069 | |
Shadowfacts | 8889261b6b | |
Shadowfacts | 91f1a5195c | |
Shadowfacts | 1a5b958b1a | |
Shadowfacts | d667f6362c | |
Shadowfacts | ef1db466b9 | |
Shadowfacts | 0566f0ddfa | |
Shadowfacts | f54d4d757f | |
Shadowfacts | fbc5d6eed9 | |
Shadowfacts | 2c4d2ce551 | |
Shadowfacts | bbe260bc9e | |
Shadowfacts | 2fe19a5abe | |
Shadowfacts | feacf576d7 | |
Shadowfacts | ceb58f1d92 | |
Shadowfacts | 806591f5b7 | |
Shadowfacts | 18ce21c2c6 | |
Shadowfacts | 47fb0ea868 | |
Shadowfacts | ffe6450b26 | |
Shadowfacts | b51c1c03cb | |
Shadowfacts | e745d78d67 | |
Shadowfacts | 4c9d5e8465 | |
Shadowfacts | 9ec7177bfa | |
Shadowfacts | 421881d461 | |
Shadowfacts | c78f152670 | |
Shadowfacts | dabcae0905 | |
Shadowfacts | e7e141bd1e | |
Shadowfacts | 8386e9d3c6 | |
Shadowfacts | 21e4828a72 | |
Shadowfacts | 9ab95dfc43 | |
Shadowfacts | c34ce758dd | |
Shadowfacts | 2c9f00d19f | |
Shadowfacts | f7127b84d8 | |
Shadowfacts | fdb21cd1fb | |
Shadowfacts | 9f0c1eece8 | |
Shadowfacts | e18a09f4ac | |
Shadowfacts | 005001b081 | |
Shadowfacts | 90f17693f1 | |
Shadowfacts | 698b045f86 | |
Shadowfacts | 654f84363a | |
Shadowfacts | 4dd510f3af | |
Shadowfacts | 1c36dfcc5f | |
Shadowfacts | b0bd27db31 | |
Shadowfacts | daa1a9eef7 | |
Shadowfacts | c737354ed3 | |
Shadowfacts | 8ea15d3bab | |
Shadowfacts | 13a4221fce | |
Shadowfacts | a896573a5e | |
Shadowfacts | edd89450aa | |
Shadowfacts | 5f5ef8fcea | |
Shadowfacts | a3b59c990b | |
Shadowfacts | 1e7bfac13c | |
Shadowfacts | 6e92633793 | |
Shadowfacts | e4ff632dcb | |
Shadowfacts | b0ebef2cfd | |
Shadowfacts | bbb8707cb7 | |
Shadowfacts | 6a927e4092 | |
Shadowfacts | 13cdb5d8c7 | |
Shadowfacts | 9f0883d0cb | |
Shadowfacts | eba2e17479 | |
Shadowfacts | 5d1c95621b | |
Shadowfacts | 02ba45fa34 | |
Shadowfacts | 9d5c004ec4 | |
Shadowfacts | 37e90229c2 | |
Shadowfacts | 73aceda97f | |
Shadowfacts | 669d55500a | |
Shadowfacts | f44d127110 | |
Shadowfacts | bcc023a127 | |
Shadowfacts | 122cce3bc7 | |
Shadowfacts | 949162bcab | |
Shadowfacts | 4ed862120c | |
Shadowfacts | f9411d706b | |
Shadowfacts | 8f61b0b9a6 | |
Shadowfacts | cdffda5593 | |
Shadowfacts | d1c45a87e6 | |
Shadowfacts | 2761c05a01 | |
Shadowfacts | e7800249af | |
Shadowfacts | 2e88b266d9 | |
Shadowfacts | 0b008489f7 | |
Shadowfacts | de67327f6d | |
Shadowfacts | 04a6fe807e | |
Shadowfacts | 6dee0957ea | |
Shadowfacts | c12d2db258 | |
Shadowfacts | 27b39b79e6 | |
Shadowfacts | d7aa3f1617 | |
Shadowfacts | 69c2faf0e1 | |
Shadowfacts | 678ed4959b | |
Shadowfacts | 0bdcda1b23 | |
Shadowfacts | 74a30d27e8 | |
Shadowfacts | f0e2bb8db6 | |
Shadowfacts | 3fdeb51353 | |
Shadowfacts | a7b2a7df71 | |
Shadowfacts | 41403c84f9 | |
Shadowfacts | e67f6b2ad8 | |
Shadowfacts | 4ac3292183 | |
Shadowfacts | d3c13ee1e6 | |
Shadowfacts | 1b44117891 | |
Shadowfacts | c7b708e62b | |
Shadowfacts | 56b51f944d | |
Shadowfacts | 30297c2390 | |
Shadowfacts | 522c9b2b03 | |
Shadowfacts | 67a029180e | |
Shadowfacts | dfad8740eb | |
Shadowfacts | b45dc19811 | |
Shadowfacts | 80c0d08ec6 | |
Shadowfacts | 2b5ab90cd8 | |
Shadowfacts | 0303c9af9d | |
Shadowfacts | 1e59f663e5 | |
Shadowfacts | 72217cde51 | |
Shadowfacts | 4bccbe254b | |
Shadowfacts | 9e15a84006 | |
Shadowfacts | c19b7ec2c6 | |
Shadowfacts | 59c00b01dc | |
Shadowfacts | 75d26e613b | |
Shadowfacts | 904ff4eecf | |
Shadowfacts | 0249207dcc | |
Shadowfacts | 366378f267 | |
Shadowfacts | 80cca7673a | |
Shadowfacts | fc888b168c | |
Shadowfacts | 348c306858 | |
Shadowfacts | 0a11d2de47 | |
Shadowfacts | 4ac76ab672 | |
Shadowfacts | eb4e6e32f7 | |
Shadowfacts | 89b35fab6d | |
Shadowfacts | d638ff513b | |
Shadowfacts | 93828830a9 | |
Shadowfacts | 39b244384b | |
Shadowfacts | 80b3585b71 | |
Shadowfacts | 5d9f4b8ea8 | |
Shadowfacts | 16b02edf87 | |
Shadowfacts | b8f169d0cd | |
Shadowfacts | 62a9535394 | |
Shadowfacts | 8c4ef3caa6 | |
Shadowfacts | e763d48bf3 | |
Shadowfacts | f841854c5f | |
Shadowfacts | 1c871a12a1 | |
Shadowfacts | 8a528936b8 | |
Shadowfacts | 744329dca2 | |
Shadowfacts | 45ac40b125 | |
Shadowfacts | 2426989161 | |
Shadowfacts | 1439c8b162 | |
Shadowfacts | 5125cc3397 | |
Shadowfacts | 9b949af390 | |
Shadowfacts | 3ff9fdabdb | |
Shadowfacts | a805da9faa | |
Shadowfacts | e0acb0f04a | |
Shadowfacts | 5414f2329c | |
Shadowfacts | 08045dd1e9 | |
Shadowfacts | 288f855e2f | |
Shadowfacts | 7883b04618 | |
Shadowfacts | 0687c040a0 | |
Shadowfacts | 58c6d508ec | |
Shadowfacts | ae272582ac | |
Shadowfacts | 1a4517c43a | |
Shadowfacts | 2cfc0cf28a | |
Shadowfacts | cf63384dce | |
Shadowfacts | 733d50b642 | |
Shadowfacts | 0e60e74a8a | |
Shadowfacts | fd0054addf | |
Shadowfacts | 576e4aa90d | |
Shadowfacts | ea3de4cdda | |
Shadowfacts | 83c7609df5 | |
Shadowfacts | 809584cc54 | |
Shadowfacts | 9b85090884 | |
Shadowfacts | 6965a4c374 | |
Shadowfacts | b6c0c02028 | |
Shadowfacts | 42f9d19ee9 | |
Shadowfacts | b80a61cc95 | |
Shadowfacts | 0d972d987c | |
Shadowfacts | 3e33c8e6f9 | |
Shadowfacts | 3822d536c8 | |
Shadowfacts | 5906c374ba | |
Shadowfacts | ee90b20f7f | |
Shadowfacts | 14e8c11f02 | |
Shadowfacts | 9d9ea565f1 | |
Shadowfacts | 99db842411 | |
Shadowfacts | 184fe49c0f | |
Shadowfacts | 4719342a06 | |
Shadowfacts | 6df5f7fb08 | |
Shadowfacts | 02135aa0de | |
Shadowfacts | be5a4c03a6 | |
Shadowfacts | 2c1ba7926e | |
Shadowfacts | 911e66a159 | |
Shadowfacts | ab4bcfa50f | |
Shadowfacts | b94bfca406 | |
Shadowfacts | 7999ecafd0 | |
Shadowfacts | 1c6e464a4c | |
Shadowfacts | acd01a81cc | |
Shadowfacts | 8ac3deb55a | |
Shadowfacts | 5e9cc430c6 | |
Shadowfacts | 0b6ef6517b | |
Shadowfacts | 34a01094f7 | |
Shadowfacts | 95b215c6b5 | |
Shadowfacts | e21dceb3b3 | |
Shadowfacts | 9534f19262 | |
Shadowfacts | e44ae29775 | |
Shadowfacts | a5b30c4243 | |
Shadowfacts | 479ca23e00 | |
Shadowfacts | 5b03e0cf12 | |
Shadowfacts | 7c4bbfd730 | |
Shadowfacts | e19a6528ad | |
Shadowfacts | f5110c773a | |
Shadowfacts | fe1db72f19 | |
Shadowfacts | b4ddb8f533 | |
Shadowfacts | 9a4ddfea3f | |
Shadowfacts | dd8a196630 | |
Shadowfacts | 3da7aacb35 | |
Shadowfacts | 39c8162931 | |
Shadowfacts | fe95cb9e1a | |
Shadowfacts | ec2d510be2 | |
Shadowfacts | 262aadf807 | |
Shadowfacts | 9dce94c014 | |
Shadowfacts | d008b882cb | |
Shadowfacts | 3d13df87f0 | |
Shadowfacts | f0582739cc | |
Shadowfacts | 4c82b1a341 | |
Shadowfacts | b55a96d649 | |
Shadowfacts | 77ac8cbe40 | |
Shadowfacts | e026c9a6c6 | |
Shadowfacts | 3937dde2bf | |
Shadowfacts | 95ebca04d2 | |
Shadowfacts | 0986fa285e | |
Shadowfacts | 1cd3e6adf9 | |
Shadowfacts | 722b81dad9 | |
Shadowfacts | 059f7307b3 | |
Shadowfacts | ee20c95a5d | |
Shadowfacts | be81ffb61f | |
Shadowfacts | 08e0c3769f | |
Shadowfacts | 6d7c9fd553 | |
Shadowfacts | 9b04b75949 | |
Shadowfacts | 273b74ddfb | |
Shadowfacts | ae055f1ffd | |
Shadowfacts | eef9b96a1a | |
Shadowfacts | 29aed65b99 | |
Shadowfacts | 090746f292 | |
Shadowfacts | af300a3559 | |
Shadowfacts | 79eb23ef5d | |
Shadowfacts | 60565f9625 | |
Shadowfacts | 70bedf17a8 | |
Shadowfacts | 392e51eb3e | |
Shadowfacts | 86d5a73c85 | |
Shadowfacts | eaefa366b7 | |
Shadowfacts | 79b23127e9 | |
Shadowfacts | f9b85c87b4 | |
Shadowfacts | 260bedcf10 | |
Shadowfacts | fe09c5e522 | |
Shadowfacts | 985d30a401 | |
Shadowfacts | 794594805c | |
Shadowfacts | 1c708732f2 | |
Shadowfacts | db30471011 | |
Shadowfacts | 2825345c7e | |
Shadowfacts | f3d01c47c3 | |
Shadowfacts | caab5e357a | |
Shadowfacts | 2916d7a72d | |
Shadowfacts | d190636fbd | |
Shadowfacts | 4e4701ead5 | |
Shadowfacts | b07efc150c | |
Shadowfacts | 19fa12391d | |
Shadowfacts | c55ea2e005 | |
Shadowfacts | 47dc00ab8f | |
Shadowfacts | fdcdbced38 | |
Shadowfacts | e70a84274e | |
Shadowfacts | 641ab765a7 | |
Shadowfacts | 986fc5b833 | |
Shadowfacts | cf5b97d9c8 | |
Shadowfacts | 7f0fd119c5 | |
Shadowfacts | b2c7735256 | |
Shadowfacts | 1d815d6cd6 | |
Shadowfacts | f86d3a0ed1 | |
Shadowfacts | 864fd77ecc | |
Shadowfacts | 78da04162f | |
Shadowfacts | 40a742139b | |
Shadowfacts | 8bbc572fa7 | |
Shadowfacts | 2a8e970738 | |
Shadowfacts | 3abb5972b9 | |
Shadowfacts | 0c06d91f6b | |
Shadowfacts | 6cf6db6a8d | |
Shadowfacts | fb11e36467 | |
Shadowfacts | 0fa87e9177 | |
Shadowfacts | 5cb84e271a | |
Shadowfacts | 50f1a9a7de | |
Shadowfacts | 154fc7cd02 | |
Shadowfacts | 01d765fa45 | |
Shadowfacts | 04aad1252a | |
Shadowfacts | 43779e42df | |
Shadowfacts | a5a2cd147e | |
Shadowfacts | 0e91fc239d | |
Shadowfacts | 0e5aab75df | |
Shadowfacts | c715d11fc2 | |
Shadowfacts | 8010e86711 | |
Shadowfacts | a41d27f18c | |
Shadowfacts | 083add273b | |
Shadowfacts | 64365bdf2b | |
Shadowfacts | 6adcad63b3 | |
Shadowfacts | 393a134648 | |
Shadowfacts | ba3e9e7491 | |
Shadowfacts | 920f926b48 | |
Shadowfacts | 6e27399e10 | |
Shadowfacts | c3c19b1994 | |
Shadowfacts | 1f40cc9928 | |
Shadowfacts | 66020b7847 | |
Shadowfacts | 00bf99334f | |
Shadowfacts | 3aef7d4d93 | |
Shadowfacts | a901af6be9 | |
Shadowfacts | b623e348c2 | |
Shadowfacts | 056346cee9 | |
Shadowfacts | 30c04b49e7 | |
Shadowfacts | 848022ec6e | |
Shadowfacts | 39e847bda8 | |
Shadowfacts | 5d751cd994 | |
Shadowfacts | d27bddb2ca | |
Shadowfacts | 36326e4469 | |
Shadowfacts | 6b7904ed52 | |
Shadowfacts | 61c6d63c67 | |
Shadowfacts | c0316f55ef | |
Shadowfacts | 803ba50f53 | |
Shadowfacts | 5d0c59e863 | |
Shadowfacts | c7b4d00da7 | |
Shadowfacts | f2a8b91769 | |
Shadowfacts | ce464dfb9f | |
Shadowfacts | d4bf289716 | |
Shadowfacts | cf48e4e973 | |
Shadowfacts | 2eaeaf3277 | |
Shadowfacts | d396eb0823 | |
Shadowfacts | 35a510e8ed | |
Shadowfacts | 0582812563 | |
Shadowfacts | e581f384e4 | |
Shadowfacts | c42a48ee12 | |
Shadowfacts | 1c9b1b9ac3 | |
Shadowfacts | 82ad3b9fc4 | |
Shadowfacts | 0a89dd3041 | |
Shadowfacts | 40863ef130 | |
Shadowfacts | cd78287a87 | |
Shadowfacts | 04496aca1d | |
Shadowfacts | 5a098df931 | |
Shadowfacts | 9812d4aff2 | |
Shadowfacts | f4f2a5546c | |
Shadowfacts | b220948e2b | |
Shadowfacts | 866edc472d | |
Shadowfacts | 88e4f52b5d | |
Shadowfacts | 98529ca5af | |
Shadowfacts | 6d8c5f632c | |
Shadowfacts | 4fdafa893e | |
Shadowfacts | 9f75106706 | |
Shadowfacts | bbd7d82620 | |
Shadowfacts | 02088b1f55 | |
Shadowfacts | 1e41c8fa17 | |
Shadowfacts | ebbfc7a132 | |
Shadowfacts | aa625a41f5 | |
Shadowfacts | 7fb92c9ce3 | |
Shadowfacts | 90bc9b91de | |
Shadowfacts | d6c506488b | |
Shadowfacts | 5786c24846 | |
Shadowfacts | 2cba168804 | |
Shadowfacts | 49d00bb1b0 | |
Shadowfacts | ee5e049355 | |
Shadowfacts | f53474ac90 | |
Shadowfacts | fa1daa682f | |
Shadowfacts | 030bee1948 | |
Shadowfacts | ed37b16463 | |
Shadowfacts | 2c8ba878b7 | |
Shadowfacts | a0e95d4577 | |
Shadowfacts | 465aedd43f | |
Shadowfacts | 102fe6ed91 | |
Shadowfacts | 7deb4fc5b4 | |
Shadowfacts | 2a419eb87c | |
Shadowfacts | fcab6818b0 | |
Shadowfacts | 80cf1850dd | |
Shadowfacts | e612964464 | |
Shadowfacts | 49a437583e | |
Shadowfacts | 8a513186aa | |
Shadowfacts | d9517047d7 | |
Shadowfacts | bef3388fe8 | |
Shadowfacts | 2e8241d734 | |
Shadowfacts | c9c001d403 | |
Shadowfacts | 4ce8de280e | |
Shadowfacts | 4018d39312 | |
Shadowfacts | ae416bb604 | |
Shadowfacts | 5e9caf9179 | |
Shadowfacts | 3bbbb05083 | |
Shadowfacts | bd3e74c611 | |
Shadowfacts | 2e8c416e04 | |
Shadowfacts | 955f9e5916 | |
Shadowfacts | 17f15db32d | |
Shadowfacts | 1a11dd2a69 | |
Shadowfacts | b5fa0bceab | |
Shadowfacts | c224d11417 | |
Shadowfacts | bebf47f05c | |
Shadowfacts | e76b719c6a | |
Shadowfacts | 478c7b7a23 | |
Shadowfacts | e3cc0df283 | |
Shadowfacts | 9ed05de3ee | |
Shadowfacts | 64f41ea2b7 | |
Shadowfacts | 9af4118dfc | |
Shadowfacts | 64a8f6d733 | |
Shadowfacts | ca76568c79 | |
Shadowfacts | 18e91feb00 | |
Shadowfacts | c5d2e9af68 | |
Shadowfacts | 0691c3b9d6 | |
Shadowfacts | 1ccb450477 | |
Shadowfacts | 7117ce6320 | |
Shadowfacts | 34dccf1f37 | |
Shadowfacts | a3303dc8fb | |
Shadowfacts | d15fa2199e | |
Shadowfacts | fadddeda7f | |
Shadowfacts | b232bec80f | |
Shadowfacts | 1b19a13b05 | |
Shadowfacts | cd5b4c1145 | |
Shadowfacts | b61418e062 | |
Shadowfacts | c7746d3084 | |
Shadowfacts | 315ea39682 | |
Shadowfacts | 44fbbd6a80 | |
Shadowfacts | fa4b5d3542 | |
Shadowfacts | de02c73957 | |
Shadowfacts | 2cebb6bd7d | |
Shadowfacts | 53707593a6 | |
Shadowfacts | 244659c262 | |
Shadowfacts | d4ca39761e | |
Shadowfacts | f87944b47e | |
Shadowfacts | af821081b0 | |
Shadowfacts | 804636dcbb | |
Shadowfacts | 5bed38f661 | |
Shadowfacts | 56de0ab359 | |
Shadowfacts | 387623a309 | |
Shadowfacts | 70bca052c4 | |
Shadowfacts | d9bae42f81 | |
Shadowfacts | a814ee37cc | |
Shadowfacts | 1a8e84f5fa | |
Shadowfacts | 1f56823a17 | |
Shadowfacts | 65d57df949 |
|
@ -1,3 +1,5 @@
|
||||||
|
Dist.xcconfig
|
||||||
|
Tusker.xcconfig
|
||||||
.DS_Store
|
.DS_Store
|
||||||
MyPlayground.playground/
|
MyPlayground.playground/
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,3 @@
|
||||||
[submodule "SwiftSoup"]
|
|
||||||
path = SwiftSoup
|
|
||||||
url = git://github.com/scinfu/SwiftSoup.git
|
|
||||||
[submodule "Cache"]
|
|
||||||
path = Cache
|
|
||||||
url = git@github.com:hyperoslo/Cache.git
|
|
||||||
[submodule "Gifu"]
|
|
||||||
path = Gifu
|
|
||||||
url = git://github.com/kaishin/Gifu.git
|
|
||||||
[submodule "Embassy"]
|
[submodule "Embassy"]
|
||||||
path = Embassy
|
path = Embassy
|
||||||
url = https://github.com/envoy/Embassy.git
|
url = https://github.com/envoy/Embassy.git
|
||||||
|
|
|
@ -0,0 +1,842 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2023.2 (68)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when inserting present items in empty timeline
|
||||||
|
- Fix extra spacing above content in conversation main status
|
||||||
|
|
||||||
|
## 2023.2 (67)
|
||||||
|
Features/Improvements:
|
||||||
|
- Try to resolve remote statuses and show conversation screen when tapping status links
|
||||||
|
- Add loading indicator to conversation screen
|
||||||
|
- Improve collapse/expand animation on conversation screen
|
||||||
|
|
||||||
|
## 2023.2 (66)
|
||||||
|
Features/Improvements:
|
||||||
|
- Improve design of link preview card
|
||||||
|
- Show loading indicator during timeline state restoration
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- iPadOS/macOS: Fix some keyboard shortcuts not working
|
||||||
|
- Fix crash when restoring timeline state
|
||||||
|
- Fix status collapse button disappearing when navigating away
|
||||||
|
- Fix crash when status swipe action takes too long to complete
|
||||||
|
- Fix tapping expand thread cell not working
|
||||||
|
|
||||||
|
## 2023.1 (64)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add Delete Post action to statuses
|
||||||
|
- Add follower/following counts and lists to profiles
|
||||||
|
- Show better message when opening conversation for deleted status
|
||||||
|
- Add pagination for showing all accounts that favorited/reblogged a status
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix race condition causing crash when syncing timeline position from iCloud
|
||||||
|
- Fix profile header buttons not adjusting to Dynamic Type
|
||||||
|
- Don't show report button for your own posts
|
||||||
|
- Fix avatars on timeline not reverting from grayscale when turning off preference
|
||||||
|
|
||||||
|
## 2023.1 (63)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix status cells being inset too much on iPhones
|
||||||
|
- Fix more things not adjusting to accent color preference
|
||||||
|
- Fix various views not being keyboard focusable
|
||||||
|
- Add more logging around state restoartion crash
|
||||||
|
|
||||||
|
## 2023.1 (62)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add New List action in Add to List menu
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when retrying follow hashtag
|
||||||
|
- Fix separators on timeline not being properly inset
|
||||||
|
- Fix various elements not adjusting to the accent color preference
|
||||||
|
- Prevent all pinned timelines from being removed, which would previously crash
|
||||||
|
- Fix crash when handling search activity
|
||||||
|
|
||||||
|
## 2023.1 (61)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add report UI
|
||||||
|
- Add accent color preference
|
||||||
|
- Start playing videos immediately when gallery opens
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when trying to load deleted status for state restoration/sync
|
||||||
|
- Fix crash when trying to restore state for non-pinned timeline
|
||||||
|
- Fix crash due to relationships being cached longer than their corresponding accounts
|
||||||
|
- Fix crash if preferences change when there are cells that haven't yet been displayed
|
||||||
|
- Fix crash when displaying poll finished notifications
|
||||||
|
|
||||||
|
## 2023.1 (60)
|
||||||
|
Features/Improvements:
|
||||||
|
- Allow sharing gifv attachments
|
||||||
|
- Add email subject when sharing status/account
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when inserting present items after navigating away from and returning to timeline
|
||||||
|
- Fix error decoding statuses from certain instances
|
||||||
|
- When logging out, remove the scene's active account rather than the most recently activated
|
||||||
|
|
||||||
|
## 2022.1 (59)
|
||||||
|
This build is a hotfix for a crash when migrating saved instances to iCloud.
|
||||||
|
|
||||||
|
## 2022.1 (58)
|
||||||
|
Features/Improvements:
|
||||||
|
- Sync timeline positions via iCloud
|
||||||
|
- Add pinned timelines customization (synced via iCloud)
|
||||||
|
- Sync saved hashtags & instances via iCloud
|
||||||
|
- Add filters for hiding reblogs and replies from home timeline
|
||||||
|
- Show uncropped attachments in the timeline for posts that have only one attachment
|
||||||
|
- Add more prominent Follow button to profile pages
|
||||||
|
- Add About and Acknowledgements pages to Preferences
|
||||||
|
- Automatically report certain kinds of errors
|
||||||
|
- iPadOS/macOS: Add window titles to indicate which account is in use
|
||||||
|
- iPadOS: Limit content to readable width
|
||||||
|
- VoiceOver: Improve label for toggle collapse button in conversation view
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix attachments in timeline somtimes being untappable
|
||||||
|
- Fix previewing links in the main status in a conversation activating the link
|
||||||
|
- Don't show reblog swipe action when reblogging is forbidden
|
||||||
|
- Fix unknown notifications appearing in the Mentions view
|
||||||
|
- Fix crash when fetching present items in certain circumstances
|
||||||
|
- Fix relationship (following/blocked/etc.) change breaking profile header layout
|
||||||
|
- Fix crash when restored timeline state includes unloaded statuses
|
||||||
|
- macOS: Fix add attachment buttons not matching system accent color
|
||||||
|
|
||||||
|
## 2022.1 (53)
|
||||||
|
Features/Improvements:
|
||||||
|
- Apply filters to Trending Posts
|
||||||
|
- Don't reload List timelines if Edit screen is closed without changes
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix URLs getting pasted as broken attachments in the Compose screen
|
||||||
|
- Fix monspace fonts not adjusting to Dynamic Type setting
|
||||||
|
- Fix crash when activating account from Preferences when My Profile is opened in a separate window
|
||||||
|
- Fix Preferences showing wrong account as current when multiple windows with different accounts are open
|
||||||
|
|
||||||
|
## 2022.1 (52)
|
||||||
|
Features/Improvements:
|
||||||
|
- Save and restore position for all timelines and all accounts
|
||||||
|
- As a side effect of this change, the first time you launch with this update, the timeline position will be lost
|
||||||
|
- Add preference to never blur attachments
|
||||||
|
- New segmented control for timeline switcher that scrolls when there's not enough space
|
||||||
|
- Copy status expand all setting when viewing More Replies in a converstaion
|
||||||
|
- Include followed hashtags in the Explore screen and iPad sidebar
|
||||||
|
- Allow saving or following hashtag from Add Hashtag screen
|
||||||
|
- Scroll long attachment descriptions in the gallery
|
||||||
|
- VoiceOver: Improve Profile Directory
|
||||||
|
- VoiceOver: Include attachment descriptions in timeline items when not marked as sensitive
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix swipe actions preference not persisting
|
||||||
|
- Fix rich text list bullets/numbers appearing black in dark mode
|
||||||
|
- Fix crash when previewing non-HTTP(S) link
|
||||||
|
- Fix images from Safari pasting as URLs rather than attachments
|
||||||
|
- Fix Trending Posts appearing to reload forever
|
||||||
|
- Fix controls reappearing when swiping between pages in the attachments gallery
|
||||||
|
- Fix reblog confirmation alert actions missing hover effect
|
||||||
|
- Fix status reply/visibility/local icons flashing blue when expanding a status
|
||||||
|
- iPad: Fix My Profile item sidebar item not updating when avatar style changes
|
||||||
|
- iPad: Fix tapping status bar not scrolling to top in single-column navigation mode
|
||||||
|
- VoiceOver: Fix crash when scrolling through local/federated timeline
|
||||||
|
- VoiceOver: Fix escape gesture not working in attachment gallery
|
||||||
|
- VoiceOver: Fix custom emoji not being stripped from display names
|
||||||
|
|
||||||
|
## 2022.1 (51)
|
||||||
|
Features/Improvements:
|
||||||
|
- Clarify text for conversation main status favorite/reblog count preference
|
||||||
|
- Improve timeline refresh behavior
|
||||||
|
- Present posts are inserted automatically, creating a gap
|
||||||
|
- Tapping the Jump to Present bubble scrolls to the top and removes everything below the gap
|
||||||
|
- Add preference to disable timeline state restoration (Preferences -> Behavior)
|
||||||
|
- VoiceOver: Add Jump to Present action on the timeline selector segmented control
|
||||||
|
- VoiceOver: Add accessibility hint for segmented controls when using the group navigation mode
|
||||||
|
- VoiceOver: Improve description of timeline gap and add actions to load in a specific direction
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash due to change cache location on disk
|
||||||
|
- Fix potential crash when trying to restore statuses that aren't available
|
||||||
|
- Fix saving expired filters not re-enabling them
|
||||||
|
- Fix tusker:// URL scheme not working
|
||||||
|
- Fix Trending Posts reloading constantly
|
||||||
|
- Fix crash when timeline tries to refresh while in the background
|
||||||
|
|
||||||
|
## 2022.1 (50)
|
||||||
|
This is a hotfix for Dynamic Type not working in the timeline. The previous build's changelog is included below.
|
||||||
|
|
||||||
|
## 2022.1 (49)
|
||||||
|
The major new feature of this build is filters! Filters are editable by pressing the Filters button in the top-left corner of the Home tab. Filters are currently applied to timelines and profiles (filtering conversations and notifications will be added in a future build).
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Filters
|
||||||
|
- Edit/create filters
|
||||||
|
- Apply filters to timelines and profiles
|
||||||
|
- Add preference to customize swipe actions on status cells (Preferences -> Appearance -> Leading/Trailing Swipe Actions)
|
||||||
|
- Show notifications for edited posts
|
||||||
|
- Add more details to the relationship label on profiles
|
||||||
|
- "Follows you", "You follow", "You follow each other", and "You block"
|
||||||
|
- Add state restoration for Federated and Local timelines
|
||||||
|
- Also fixes an issue where posts from other timelines would appear in the home timeline after a relaunch
|
||||||
|
- Add state restoration for Compose screen
|
||||||
|
- Organize expanded custom emoji picker by category
|
||||||
|
- Add indicator for locked profiles
|
||||||
|
- Add cache size info to Advanced preferences
|
||||||
|
- Indicate when a followed hashtag caused a post to appear in the home timeline
|
||||||
|
- Add hashtag follow/unfollow actions
|
||||||
|
- Completely replace timeline items after Jump to Present used
|
||||||
|
- Fixes infinite scroll not being usable after jumping
|
||||||
|
- iPad/Mac: Add Cmd+Return shortcut for sending post on the Compose screen
|
||||||
|
- VoiceOver: Hide redundant information on Compose screen
|
||||||
|
- VocieOver: Add links/mentions/hashtags to actions rotor on statuses
|
||||||
|
- VoiceOver: Add show profile action to timeline statuses
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix gifv attachments appearing off-center
|
||||||
|
- Fix long status on certain screens not getting collapsed
|
||||||
|
- Fix error when refreshing a timeline with no items
|
||||||
|
- Fix cells in account list and status action account list not being deselected when navigating back
|
||||||
|
- Limit search in Edit List screen to accounts the user follows
|
||||||
|
- Fix attachments disappearing from statuses on the Conversation screen
|
||||||
|
- Fix extra space gap appearing in profile headers below avatar
|
||||||
|
- Fix mute duration options not including 1 week
|
||||||
|
- Hometown: Fix local-only state not being copied from the replied-to post
|
||||||
|
- iPad: Fix timeline statuses not getting deselected when entering split navigation
|
||||||
|
- iPad: Fix Mute screen using pointless two-column layout
|
||||||
|
- iPad: Fix creating a list from the sidebar making the previous tab inaccessible
|
||||||
|
- iPad: Fix creating a list not showing the Edit List screen automatically
|
||||||
|
- iPad: Fix selected sidebar item becoming out-of-sync when deleting a list/hashtag/instance
|
||||||
|
- Mac: Workaround for pressing Cmd+1/2/... crashing
|
||||||
|
- This prevents the crash, but the actions remain unusable due to a macOS bug
|
||||||
|
- VoiceOver: Fix escape gesture not working on Compose screen
|
||||||
|
- VoiceOver: Fix links in conversation main status not being selectable
|
||||||
|
- VoiceOver: Fix profile relationship not being read
|
||||||
|
- VoiceOver: Fix not being able to select profile from conversation main status
|
||||||
|
|
||||||
|
Known Issues:
|
||||||
|
- Filters are not applied to notifications and conversations
|
||||||
|
|
||||||
|
## 2022.1 (48)
|
||||||
|
This build is a hotfix for the CW button in the Compose screen not working. The previous build's changelog is attached below.
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix pressing CW button in Compose not showing the content warning field
|
||||||
|
- Tweak timeline state restoration to try and maintain the scroll position of the middle item on screen, rather than the top one
|
||||||
|
|
||||||
|
## 2022.1 (46)
|
||||||
|
The headlining feature is state restoration and timeline gaps! When you re-open the app after it's been closed for a while, it will remember your position in the timeline and allow you to keep reading from there. It will also let you jump all the way to the present.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Timeline state restoration, timeline gaps, and jump-to-present
|
||||||
|
- Allow posting wide color gamut images on Mastodon 4
|
||||||
|
- Add Add to List menu action to profiles
|
||||||
|
- Improve More Actions button visibility on dark profile header images
|
||||||
|
- Make poll options in the Compose screen reorderable with drag & drop
|
||||||
|
- Embiggen Share/Close controls in the gallery to make them easier to tap
|
||||||
|
- Separate section for Shared Albums in the Compose attachment picker
|
||||||
|
- Indicate verified profile links with a checkmark and popover explaining what it means
|
||||||
|
- Add a preference for using the Twitter-style keyboard with @ and # (Preferences -> Composing -> Show @ and # on Keyboard)
|
||||||
|
- Improve reblog indicator in timeline
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix not being able to select an existing draft to edit it
|
||||||
|
- Fix double-tap to zoom in the gallery not working
|
||||||
|
- Fix crash when toggling collapsed posts in Trending Posts
|
||||||
|
- Fix albums in asset picker not being sorted by name
|
||||||
|
- Fix profile headers getting squished when statuses are loaded while the profile is offscreen
|
||||||
|
- Fix error loading posts when server returns rich cards
|
||||||
|
- Fix Akkoma instnaces not being detected as supporting Pleroma features
|
||||||
|
- Fix crash when launching the app in slow network conditions
|
||||||
|
- Fix lists not updating in the UI when renamed
|
||||||
|
- Fix follow/block/mute actions displaying on the user's own profile
|
||||||
|
- Fix Edit List screen being presented repeatedly when switching tabs back to Explore with a list open
|
||||||
|
- Fix reblog visibility icon getting squished in the reblog confirmation dialog when Dynamic Type is active
|
||||||
|
- Fix toasts not adjusting to Dynamic Type size
|
||||||
|
- Don't show duplicate reply/favorite/reblog actions in the status More Actions menu
|
||||||
|
- iPadOS 15: Fix toolbar in Compose window being obscured by the keyboard
|
||||||
|
|
||||||
|
## 2022.1 (45)
|
||||||
|
Features/Improvements:
|
||||||
|
- iPhone: Temporarily hide the Compose screen by swiping down to access the rest of the applies
|
||||||
|
- Add Block, Domain Block, and Mute actions to accounts
|
||||||
|
- Don't change scroll position when switch sections in the Profile screen
|
||||||
|
- Use URL keyboard in the instance selector and clarify that you can enter any domain
|
||||||
|
- iPad: Add context menu action for deleting lists in sidebar
|
||||||
|
- Tweak conditions in which profile fields are shown in a single column, rather than two
|
||||||
|
- Convert wide color gamut images to sRGB before uploading
|
||||||
|
- The Mastodon backend does not support wide-gamut images and does a poor job of conversion, so the conversion is performed locally
|
||||||
|
- Focus content warning field immediately when CW button is pressed
|
||||||
|
- Move focus to main text field when return key is pressed while editing the content warning
|
||||||
|
- Make GIF attachments animate on the Compose screen
|
||||||
|
- VoiceOver: Make profile fields accessible
|
||||||
|
- VoiceOver: Only read content warning and not content for CW'd posts
|
||||||
|
- VoiceOver: Expand collapsed posts when performing double-tap
|
||||||
|
- VoiceOver: Announce visibility of followers-only & direct posts
|
||||||
|
- VoiceOver: Make Compose toolbar accessible
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix tapping links in profile fields
|
||||||
|
- Fix crash when creating/editing list fails
|
||||||
|
- Fix renaming a list not updating it elsewhere in the UI
|
||||||
|
- Fix instance-local/everywhere scope selector in Profile Directory being flipped
|
||||||
|
- Fix context menu previews of attachments not working
|
||||||
|
- Fix caret not scrolling into view when opening Compose
|
||||||
|
- Fix cells in the Drafts list being too small to tap
|
||||||
|
- Fix refresh failing when initial load failed
|
||||||
|
- Fix video controls in the gallery being too close to the edge of the screen
|
||||||
|
- Fix error when decoding malformed notifications
|
||||||
|
- Fix reblog with visibility not being available on Hometown instances
|
||||||
|
- Fix visibility dropdown being shown in confirm reblog alert even when unavailable
|
||||||
|
- Fix confirm reblog alert not adjusting to Dynamic Type
|
||||||
|
- Fix layout issues with replies on Compose screen
|
||||||
|
- macOS: Fix GIFs dragged from Finder posting static images
|
||||||
|
|
||||||
|
Known Issues:
|
||||||
|
- Drag/drop to add attachments when composting a post does not work
|
||||||
|
|
||||||
|
## 2022.1 (44)
|
||||||
|
Features/Improvements:
|
||||||
|
- Dynamic Type support
|
||||||
|
- Improve performance when displaying statuses with large numbers of custom emojis
|
||||||
|
- Add preference for default reply visibility
|
||||||
|
- Add preference to turn off blurring media in posts with content warnings
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix drawing background flashing between black/white in dark mode
|
||||||
|
- Fix undo scroll-to-top not working in release builds
|
||||||
|
- Fix favorite and reblog menu actions not working
|
||||||
|
- Fix avatar in compose being wrongly aligned on short statuses
|
||||||
|
- Fix posts that are tall but have few characters not getting collapsed
|
||||||
|
- Fix crash when profile screen is closed for the profile loads
|
||||||
|
|
||||||
|
## 2022.1 (43)
|
||||||
|
Features/Improvements:
|
||||||
|
- Re-add undo scroll-to-top by tapping the status bar a second time
|
||||||
|
- Convert hashtag/list/instance timelines to use new timeline implementation
|
||||||
|
- Clarify warning on Post Content Type preference
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when refreshing profile before it loaded
|
||||||
|
- Fix crash when tapping Load More when infinite scrolling is disabled
|
||||||
|
- Fix crash when profile screen is closed before loading finishes
|
||||||
|
- Fix having to tap Cancel twice to dismiss Find Instance screen
|
||||||
|
|
||||||
|
## 2022.1 (42)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add automatic crash reporting
|
||||||
|
- Tweak spacing on timeline statuses
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix status collapse/expand not animating on profiles
|
||||||
|
- Fix crash when opening profile for unloaded account (e.g., by tapping mentions)
|
||||||
|
- macOS: Add workaround for Follow/Unfollow menu item never loading
|
||||||
|
|
||||||
|
## 2022.1 (41)
|
||||||
|
Features/Improvements:
|
||||||
|
- Rewrite profile screens to use new timeline implementation
|
||||||
|
- Disable Infinite Scrolling preference (Preferences -> Digital Wellness) now applies to profiles
|
||||||
|
- Improve behavior when switching tabs on profiles
|
||||||
|
- Improve pointer interaction on timeline status cells
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when loading images in certain circumstances
|
||||||
|
- Fix gallery dismissal leaving status bar hidden and breaking future gallery dismisses
|
||||||
|
- Fix timeline scroll position changing after dismissing gallery
|
||||||
|
- Fix images flickering when switching back to the Home tab
|
||||||
|
- Fix crash reporter being dismissed when sending email is cancelled
|
||||||
|
- Fix crash when long pressing Send Report button in crash reporter on iPad
|
||||||
|
- Fix Live Text controls not hiding when other gallery controls are hidden
|
||||||
|
- Fix replies appearing multiple times in Drafts list
|
||||||
|
- Fix crash when displaying blur hash images on Pleroma
|
||||||
|
|
||||||
|
## 2022.1 (40)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix selecting reblogged statuses in the timeline
|
||||||
|
- Fix links/mentions/hashtags in the timeline not being tappable
|
||||||
|
- Fix mentions from Misskey opening in the browser rather than the profile screen
|
||||||
|
- Fix crash when leaving timeline tab before it finished loading
|
||||||
|
- Fix status cells in the timeline not deselected when tapped in split navigation mode on iPad
|
||||||
|
- Fix keyboard shortcuts not working on iPad
|
||||||
|
|
||||||
|
## 2022.1 (39)
|
||||||
|
This is a(nother) hotfix for the previous build. Their changelogs are included below.
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix instance selector screen crashing on iOS 15
|
||||||
|
|
||||||
|
## 2022.1 (38)
|
||||||
|
This is a hotfix for the previous build. Its changelog is included below.
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix sensitive attachments not being hidden on the timeline
|
||||||
|
- Fix timeline descriptions appearing repeatedly
|
||||||
|
- iPadOS: Fix occasional crash when hovering over text
|
||||||
|
|
||||||
|
## 2022.1 (37)
|
||||||
|
This is the first build with the rewritten/rearchitected timeline screen. In future builds, this will roll out to the notifications and profile screens as well, but for now it's only used in the home tab. If you encounter crashes or errors, please report them. If you see a blue error bubble pop up, you can long-press it to send an error report.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Display error messages when favoriting/reblogging fails
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- iOS 15: (hopefully) fix lock-related crash
|
||||||
|
- Fix crash when loading indicator is shown multiple times
|
||||||
|
|
||||||
|
Known Issues:
|
||||||
|
- Videos played from the timeline do not enter picture-in-picture mode when backgrounding the app
|
||||||
|
- Status expand/collapse animations on other screens do not match timelines
|
||||||
|
|
||||||
|
Other:
|
||||||
|
- X-Callback-URL support has been removed
|
||||||
|
|
||||||
|
## 2022.1 (36)
|
||||||
|
This build is a hotfix for a crash when refreshing on Pixelfed.
|
||||||
|
|
||||||
|
## 2022.1 (35)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add loading indicator to timelines/notifications/profiles
|
||||||
|
- Show status preview in reblog confirmation dialog (Preferences -> Behavior -> Require confirmation Before Reblogging)
|
||||||
|
- Add reblogging with unlisted/private visibility (requires reblog confirmation to be enabled)
|
||||||
|
- Fix controls not hiding on iPhone 14 Pro
|
||||||
|
- Improve account switching animation
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when resizing window on iPad
|
||||||
|
- Fix poll vote count displaying random number
|
||||||
|
- Fix crash when opening emoji picker on instances that have duplicate emojis
|
||||||
|
|
||||||
|
## 2022.1 (33)
|
||||||
|
Features/Improvements:
|
||||||
|
- Show notifications when subscribed to other people's posts
|
||||||
|
- Use context menu for filter/sort in Profile Directory
|
||||||
|
- Enable data detectors (flight numbers, addresses, shippment numbers, phone numbers, currency (iOS 16), and physical units (iOS 16)) for the main status in Conversation
|
||||||
|
- iPadOS: Two column navigation
|
||||||
|
- In potrait orientation with the sidebar hidden, or in landscape, Tusker uses two column navigation on iPad
|
||||||
|
- Selecting something will open a second column in which navigation takes place
|
||||||
|
- The second column can be closed from its top level
|
||||||
|
- You can drill down farther inside the second column
|
||||||
|
- Selecting a different item in the first column replaces the second column
|
||||||
|
- iPadOS: Move Trending Hashtags/Links to Explore screen (formerly Search)
|
||||||
|
- Trending Statuses and Profile Directory will also be moved in a future version
|
||||||
|
- iPadOS: Add context menu action for deleting drafts
|
||||||
|
- iOS 16: Add Live Text to images in the gallery view
|
||||||
|
- iOS 16: Show favorite/reblog context menu actions
|
||||||
|
- iOS 16: Show full size previews when long-pressing attachments on the Compose screen
|
||||||
|
- iOS 16: Show formatting actions in edit menu on Compose screen
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix attachments on Pleroma not being served as the correct content type
|
||||||
|
- Fix not being able to open some hashtags with non-ASCII characters
|
||||||
|
- Fix crash when leaving the app shortly after opening it
|
||||||
|
- Fix crash when loading notifications on Pixelfed
|
||||||
|
- Fix crash due to retain cycle when changing preferences
|
||||||
|
- iPadOS: Fix crash when opening a conversation in a new window
|
||||||
|
|
||||||
|
## 2022.1 (31)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix not being able to post attachments with descriptions
|
||||||
|
- Fix potential crash when displaying certain notifications
|
||||||
|
- More detailed error message when decoding invalid URLs
|
||||||
|
|
||||||
|
## 2022.1 (30)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add fast account switching on iPad
|
||||||
|
- Add "Add Account" option to fast account switcher
|
||||||
|
- Show "# more replies" indicator in conversation in more circumstances
|
||||||
|
- When refreshing notifications, new ones are grouped with existing notifications
|
||||||
|
- Add subtitles to explain post visibility options
|
||||||
|
- Improve error messages when posting a video fails
|
||||||
|
- Display error messages instead of crashing when certain actions fail
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix Shortcuts actions not working in some circumstances
|
||||||
|
- Fix CW field growing wider than the screen
|
||||||
|
- Fix saved hashtags not persisting
|
||||||
|
- Fix not being able to long-press error-message bubbles
|
||||||
|
- Fix follow context menu item not updating after following
|
||||||
|
- Fix not being able to log in to certain Pixelfed instances
|
||||||
|
- Fix crash when closing the app
|
||||||
|
- Fix crash when loading profile screen
|
||||||
|
- Fix crash when refreshing polls
|
||||||
|
- Fix crash when poll voting fails
|
||||||
|
- Fix crash when accepting/rejecting a follow request fails
|
||||||
|
- Fix saved hashtags being sorted with case-sensitivity
|
||||||
|
- Fix multiple lines of text with emojis getting squashed together
|
||||||
|
- iPad: Fix Shortcuts actions showing wrong window type
|
||||||
|
- Mac: Fix Cmd+N shortcut not opening Compose window
|
||||||
|
- iPad/Mac: Fix Send Message action not mentioning account
|
||||||
|
|
||||||
|
## 2022.1 (27)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add emoji picker button to Compose screen toolbar
|
||||||
|
- Preference to disable reply/like/reblog/more buttons on statuses in the timeline
|
||||||
|
- iPad: Sidebar toggle button
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when displaying malformed statuses
|
||||||
|
|
||||||
|
## 2022.1 (26)
|
||||||
|
This build contains hotfixes for several crashes that may occur when logged-in to a Pixelfed account.
|
||||||
|
|
||||||
|
## 2022.1 (25)
|
||||||
|
Features/Improvements:
|
||||||
|
- Improve error reporting for non-crash errors
|
||||||
|
- Long-press on the blue error bubble to send a report
|
||||||
|
- Improve error feedback during login process
|
||||||
|
- Add Trending Post and Trending Links on Mastodon 3.5
|
||||||
|
- Add Digital Wellness preference to disable Discover
|
||||||
|
- Basic support for GotoSocial
|
||||||
|
- Reduce app file size
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix all statues appearing as pinned on Pixelfed
|
||||||
|
- Fix crash when refreshing My Profile
|
||||||
|
- Fix My Profile never loading in some circumstances
|
||||||
|
- Fix crash the first time the attachment picker is opened
|
||||||
|
- Fix crash when closing certain screens
|
||||||
|
- Fix certain links in posts not being detected
|
||||||
|
|
||||||
|
## 2022.1 (24)
|
||||||
|
Features/Improvements:
|
||||||
|
- Local only posts (Glitch/Hometown)
|
||||||
|
- Show indicator for local only posts
|
||||||
|
- Add local only option to Compose screen
|
||||||
|
- Add extend selection button to asset picker when the limited library was used
|
||||||
|
- Improve profile directory UI
|
||||||
|
- Improve scrolling performance with large attachments on older devices
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when closing Preferences
|
||||||
|
- Fix crash when posting attachment fails
|
||||||
|
- Fix scrolling through compose autocomplete suggestions dismissing keyboard
|
||||||
|
- Only show Mute action on your own posts
|
||||||
|
|
||||||
|
## 2021.1 (23)
|
||||||
|
Features/Improvements:
|
||||||
|
- Synchronize GIF playback through animations and in gallery
|
||||||
|
- Known Issue: This does not currently work with GIFVs used by Mastodon instances.
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when a conversation fails to load
|
||||||
|
- Fix gallery dismissal breaking public timelines
|
||||||
|
- Fix gallery dismissal going in the wrong direction when the gesture was started slowly
|
||||||
|
|
||||||
|
## 2021.1 (22)
|
||||||
|
This is the first public beta build of Tusker, so if you're just joining us, welcome! Not too many new features this build, mostly bugfixes, so test everything and generally use the app.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add timeline descriptions the first time you view federated/local
|
||||||
|
- Show messages when loading posts fails or when there are no newer posts
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash after editing lists
|
||||||
|
- Fix crash when refreshing before anything is loaded
|
||||||
|
- Fix crash when fetching recommended instances fails
|
||||||
|
- Fix crash when replying to posts with code formatting
|
||||||
|
- Fix crash when changing preferences after switching accounts
|
||||||
|
|
||||||
|
## 2021.1 (21)
|
||||||
|
This is a quick follow-up to the previous build with fixes for a couple major crashes. Unfortunately, due to a bug in iOS 14, the Disable Infinite Scrolling preference now requires the iOS 15 beta to use. It may return in a future build if I can find a workaround, but it's disabled in the meantime.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- iPadOS 15: Add Open in New Window context menu action to sidebar items
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when editing accounts in a list
|
||||||
|
- Fix crash when refreshing timeline on iOS 14
|
||||||
|
- Fix(ish) crash when opening collapsed status with Disable Infinite Scrolling active on iOS 15
|
||||||
|
|
||||||
|
## 2021.1 (20)
|
||||||
|
This is a big one! In addition to a bunch of fixes for anyone on the iOS 15 beta, there are a couple of big ticket features, including the Open in Tusker action extension and the Disable Infinite Scrolling preference.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add Open in Tusker action extension
|
||||||
|
- Quickly search for any URL in Tusker
|
||||||
|
- In a share sheet, scroll to the bottom, tap "Edit Actions..." and turn on the "Open in Tusker" action
|
||||||
|
- Add Digital Wellness preference to disable infinite scrolling
|
||||||
|
- Add fast account switching indicator to My Profile tab
|
||||||
|
- Improve VoiceOver accessibility of polls and timeline statuses
|
||||||
|
- iPadOS: Create multiple main windows for different accounts by dragging from an account in Preferences
|
||||||
|
- iPadOS: Delete attachments on Compose screen by right-clicking and selecting Delete
|
||||||
|
- iPadOS 15: Add Open in New Window context menu action to most things
|
||||||
|
- iPadOS 15: Allow dragging the Compose sheet into a separate window
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix being unable to commit previewed account from timeline status
|
||||||
|
- Fix crash when searching fails
|
||||||
|
- Fix poll option percentages being cut off
|
||||||
|
- Fix polls not collapsing inside CWs
|
||||||
|
- Fix More button on profiles not being accessible with VoiceOver
|
||||||
|
- Fix VoiceOver reading profile fields in incorrect order
|
||||||
|
- Fix gallery animations jittering on devices with square screens (iPads, non-notched iPhones)
|
||||||
|
- Fix CW text jumping around post collapse animation
|
||||||
|
- iOS 15: Fix crash due when showing Draw Something screen in Compose
|
||||||
|
- iPadOS 14/iOS 15: Fix navigation bar turning transparent after opening the attachment gallery
|
||||||
|
- iPadOS 14/iOS 15: Fix drag-selecting poll options initiating a status cell drag interaction
|
||||||
|
- iPadOS: Fix crash when loading a previously-opened conversation window
|
||||||
|
- iPadOS 15: Fix showing Compose screen when keyboard focus moves through the sidebar
|
||||||
|
|
||||||
|
Known Issues:
|
||||||
|
- Disable Infinite Scrolling preference only affects timelines, not notifications or profiles
|
||||||
|
- iPadOS 15: The Compose sheet cannot be dismissed by swiping down
|
||||||
|
- iPadOS 15: Keyboard focus is stuck in the sidebar
|
||||||
|
|
||||||
|
## 2021.1 (19)
|
||||||
|
This is an emergency fix for Tusker breaking when connecting to Mastodon instances on 3.4.0rc1.
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when connecting to Mastodon 3.4.0rc1
|
||||||
|
- Fix crash when loading notifications fails
|
||||||
|
|
||||||
|
## 2021.1 (18)
|
||||||
|
Polls! They're finally here. There will likely be another build in the next several weeks to polish some things off before WWDC, so if you've encountered any issues, now's the time to let me know :)
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Show polls on posts
|
||||||
|
- Add authoring polls to Compose screen
|
||||||
|
- Add poll completed notifications
|
||||||
|
- Add preference for requiring confirmation before reblogging
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix cursor movement not working in Compse text field when an emoji was entered
|
||||||
|
- Fix several crashes related to network requests failing
|
||||||
|
- Show assets in attachment picker immediately after permissions is initially granted
|
||||||
|
- Fix crash when tapping non-HTTP(S) link with the In-App Safari preference enabled
|
||||||
|
|
||||||
|
Known Issues:
|
||||||
|
- Polls with between 23h30m and 24h left show as "0 weeks remaining"
|
||||||
|
|
||||||
|
## 2021.1 (17)
|
||||||
|
The main improvement this build is a complete overhaul of the Conversation screen, along with fixes for a few different crashes.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Group replies by thread on Conversation screen
|
||||||
|
- Adding Trending Hashtags and Profile Directory to Explore screen (Mastodon only)
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when editing List members
|
||||||
|
- Fix crash when re-opening Preferences after switching accounts
|
||||||
|
- Fix crash when refreshing profiles in some circumstances
|
||||||
|
|
||||||
|
## 2021.1 (16)
|
||||||
|
This build fixes a number of crashes and significantly improves performance on older devices.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Significantly improve performance when scrolling through timelines
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when timeline or profile went offscreen
|
||||||
|
- Fix crash when refreshing profile too quickly
|
||||||
|
- iPadOS: Fix secondary windows not respecting theme preference
|
||||||
|
- Fix refreshes breaking after a refresh which did not return new results
|
||||||
|
|
||||||
|
## 2020.1 (15)
|
||||||
|
There are a whole bunch of new features in this release, in addition to a slew of bugfixes. The big ticket items are multi-window support on iPadOS and fast account switching on iPhone (fast account switching will be supported on iPads in a future build).
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add fast account switching on iPhone
|
||||||
|
- Long-press on the My Profile tab and drag up to select another account
|
||||||
|
- Add account switching animation
|
||||||
|
- iPadOS: Add multiple windows
|
||||||
|
- Drag and drop statuses and accounts on timelines into auxiliary windows
|
||||||
|
- Drag and drop sidebar items into new windows
|
||||||
|
- Drag and drop Compose drafts into new windows
|
||||||
|
- Add key commands
|
||||||
|
- ⌘R to refresh timelines
|
||||||
|
- ⌘N to Compose a post
|
||||||
|
- ⌘B/⌘I text formatting on Compose screen
|
||||||
|
- ⇧⌘[/⇧⌘] to switch between sub-tabs
|
||||||
|
- iPadOS: ⌘1/⌘2/⌘3/⌘4/⌘5 to select sidebar items
|
||||||
|
- Show link cards on statuses
|
||||||
|
- Add Grayscale Images preference to Digital Wellness
|
||||||
|
- Add preference for applying opposite collapse behavior for specific keywords
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix statuses not appearing on My Profile until scrolling
|
||||||
|
- Fix crashes when scrolling through timelines
|
||||||
|
- Fix logging into PixelFed instances
|
||||||
|
- Interacting with PixelFed instances remains only partially supported
|
||||||
|
- Enlarge expand/collapse button tap area
|
||||||
|
- Fix cursor disappearing behind keyboard when typing attachment descriptions
|
||||||
|
- Fix crash when viewing custom emojis with spaces in their URLs
|
||||||
|
- Fix split view not working in landscape on Plus/Max iPhones
|
||||||
|
- Minor performance improvements
|
||||||
|
|
||||||
|
## 2020.1 (13)
|
||||||
|
This is another quick build to fix a couple of severe issues on the Compose screen.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- When composing posts, ensure the cursor is always visible and does not scroll below the keyboard while typing
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix builtin iOS keyboard suggestions not working in text fields on the Compose screen
|
||||||
|
- Fix crash when ending dictation in the CW field
|
||||||
|
- Fix broken layout on the Compose when replying to certain posts
|
||||||
|
|
||||||
|
## 2020.1 (12)
|
||||||
|
This build is a hotfix for the issue of being unable to login to certain instances. The changelog for the previous build is included below.
|
||||||
|
|
||||||
|
## 2020.1 (11)
|
||||||
|
This release is primarily focused on bug fixes with the one key feature of autocomplete suggestions when typing in the Compose screen. It also fixes an issue on the various new sizes of iPhone 12, so if you're getting a new device, make sure to update.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add autocompletion on Compose screen
|
||||||
|
- Autocomplete provides suggestions for @-mentions, hashtags, and emojis as you're typing in the post body
|
||||||
|
- Provides suggestions for emojis as you're typing in the CW field
|
||||||
|
- Type a colon and expand the emoji suggestions to view all custom emoji on your instance
|
||||||
|
- Hashtag suggestions prioritize trending and saved hashtags, in addition to searching all hashtags on your instance
|
||||||
|
- Account suggestions prioritize accounts that you follow or that follow you, as well as searching all accounts known to your instance
|
||||||
|
- Show custom emojis in users' display names in follow, favorite, and reblog notifications
|
||||||
|
- Enable picture-in-picture playback of video attachments
|
||||||
|
- iOS 14: Automatically enter picture-in-picture when closing the app while a video is playing
|
||||||
|
- Correctly positiong gallery controls on iPhone 12-family devices
|
||||||
|
- Round corners of the avatar on the My Profile tab icon
|
||||||
|
- Remove extraneous U+FFFC characters inserted by dictation when posting
|
||||||
|
- Add swipe to remove accounts in Preferences
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix not being able to tap placeholders in Compose
|
||||||
|
- Fix broken layout on Compose screen when replying to very long posts
|
||||||
|
- Fix crash when opening Compose or My Profile too quickly after launch
|
||||||
|
- Upload photos taken with the in-app camera as JPEGs instead of PNGs
|
||||||
|
- Fixes an issue where Mastodon would incorrectly believe the file size to be too large
|
||||||
|
- Fix crash when using home screen shortcuts
|
||||||
|
- Disable rotating into landscape on iPhone on iOS 14
|
||||||
|
- Fix assorted other crashes and memory leaks
|
||||||
|
|
||||||
|
## 2020.1 (10)
|
||||||
|
This build is a hotfix for a couple pressing issues. The changelog for the previous build is included below.
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when opening Preferences while signed in with a deleted account
|
||||||
|
- Fix visibility and content warning not being copied when replying to a post
|
||||||
|
|
||||||
|
## 2020.1 (9)
|
||||||
|
The marquee feature of this build is the new and improved Compose screen. It's been rewritten to use SwiftUI, is significantly more resilient to data loss, and now shows the toolbar when the main text field is not focused. It also turns out Apple is surprise-releasing iOS 14 very soon (or possibly already has, depending when you're reading this). For those who were not already on the beta train, iOS 14 brings a number of new features including a sidebar on iPadOS and lots and lots of context menus (a home screen widget is coming Soon™).
|
||||||
|
|
||||||
|
Known Issues:
|
||||||
|
- Pasting images to create attachments when composing a post is not currently supported due to an iOS bug (#109)
|
||||||
|
- Full-size previews do not display in context menus for attachments on the Compose screen due to an iOS issue (#110)
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Rewrite Compose screen using SwiftUI
|
||||||
|
- Prevent draft posts being lost if the app crahes or is killed by the system while composing
|
||||||
|
- Show toolbar while post content is not being edited
|
||||||
|
- Save post visibility in drafts
|
||||||
|
- Move Draw Something action out of the context menu
|
||||||
|
- iOS 14: Use context menus for setting post visibility
|
||||||
|
- Show BlurHash previews for attachments on Mastodon
|
||||||
|
- Add Expand All Content Warnings preference (Preferences -> Behavior)
|
||||||
|
- Add Collapse Long Posts preference (Preferences -> Behavior)
|
||||||
|
- Improve image gallery opening animation
|
||||||
|
- Use fade in/out animations for opening/closing gallery and attachment picker when the Reduce Motion system setting is enabled
|
||||||
|
- iOS 14: Also requires the "Prefer Cross Fade" setting be enabled
|
||||||
|
- Slightly reduce default status font sizes
|
||||||
|
- Add "Direct Message" context menu action to Compose button on profile screen
|
||||||
|
- Allow viewing attachments and navigating through posts/accounts on instance public timelines
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix errors when uploading attachments not displaying
|
||||||
|
- Fix attachments not posting in the correct, user-specified order
|
||||||
|
- Fix accounts displaying with outdated information (avatars, display names, etc.)
|
||||||
|
- Fix Compose not showing button on profile screen
|
||||||
|
- Fix navigation title not being set on profile screen
|
||||||
|
- Fix follow notifications not showing names for users without display names set
|
||||||
|
- iPadOS 14: Fix crash when resizing app in split view mode
|
||||||
|
|
||||||
|
## 2020.1 (8)
|
||||||
|
This is just an emergency build to fix crashes on iOS 13 when selecting attachments. The changelog of the previous build is included below.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Enlarge tap targets on status reply/favorite/reblog/more buttons
|
||||||
|
- Disable automatic GIF playback when Low Power Mode is enabled
|
||||||
|
- Show custom emoji in user profile field names
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when attempting to add attachments on iOS 13
|
||||||
|
- Fix potential crashes
|
||||||
|
|
||||||
|
## 2020.1 (7)
|
||||||
|
This is the first update since WWDC and the introduction of iOS 14. As such, most of the focus has been on fixing iOS 14-specific problems. However, there are still a couple new features, both for those on the iOS 14 beta and those not.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add toggle between Posts, Posts and Replies, and Media on user profiles
|
||||||
|
- Remove 'Show Replies in Profiles' preference
|
||||||
|
- Limit link preview animation to only link text
|
||||||
|
- Add additional context menu actions for statuses, accounts, and hashtags
|
||||||
|
- Add semi-translucent background to image descriptions, so they're legible against light images
|
||||||
|
- iPadOS 14: Add sidebar
|
||||||
|
- When using multitasking on iPad and switching in and out of "compact" mode, the active tab as well as the navigation history for all tabs will be transferred between the sidebar and tab bar modes.
|
||||||
|
- iOS 14: Use context menus on status/account '...' buttons
|
||||||
|
- iOS 14: Replace 'More' status swipe action with 'Share'
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when attempting to change post visibility on iPad
|
||||||
|
- Fix attachment view corners not being rounded
|
||||||
|
- Fix crash when viewing instance public timelines
|
||||||
|
- Fix Preferences button not appearing on My Profile tab
|
||||||
|
- Fix tapping current tab bar item not scrolling to top
|
||||||
|
- Fix crash showing audio attachments on Mastodon
|
||||||
|
- Fix timeline refreshing forever
|
||||||
|
- Set app category (fixes usage not being categorized correctly under Screen Time)
|
||||||
|
- iOS 14: Fix crash when searching for instances
|
||||||
|
- iOS 14: Fix crash when displaying accounts with no pinned posts
|
||||||
|
- iOS 14: Fix crash when displaying search results
|
||||||
|
|
||||||
|
## 2020.1 (6)
|
||||||
|
This is the pre-WWDC update with lots of bugfixes and some small features. There will likely be another build this week to fix any pressing issues that arise from iOS 14.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add mute/unmute conversation status action
|
||||||
|
- iPadOS: Add pointer interactions to remove attachment button, gallery view share/dismiss buttons
|
||||||
|
- Disable reblog button for direct/followers-only posts
|
||||||
|
- On Pleroma, the reblog button is still enabled for your own followers-only posts to match Pleroma's "Boost to original audience" feature.
|
||||||
|
- Add preference to always display status visibilities below account avatars
|
||||||
|
- Add preference to show reply indicators for statuses in timelines
|
||||||
|
- Show share/dismiss controls and image description for gifv attachments
|
||||||
|
- 'Share' is currently disabled for gifv attachments, it will be enabled in a future build
|
||||||
|
- Add crash report helper
|
||||||
|
- If the app detects that it crashed the last time it was running, it will allow you to review the crash report and email it to me
|
||||||
|
- Add Recognize Text context menu option for images on the Compose screen
|
||||||
|
- This uses iOS' builtin Vision framework to perform on-device OCR and generate an image description from the recognized text
|
||||||
|
- Tweak attachment previews to always have a 16:9 aspect ratio
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix account/status More actions not working
|
||||||
|
- Improve share sheet loading speed
|
||||||
|
- Fix crash when loading bookmarks
|
||||||
|
- Prompt for Photos access before showing photo picker. Prevents empty sheet displaying.
|
||||||
|
- Fix profile fields not displaying and improve layout
|
||||||
|
- Fix profile header image not displaying the first time an account is loaded
|
||||||
|
- Don't show Follow action for your own account
|
||||||
|
- Fix attachments on the Compose screen being cut-off above the home indicator on iPhone X-style devices
|
||||||
|
- Fix audio being played by other apps pausing when displaying a gifv attachment on Mastodon
|
||||||
|
|
||||||
|
|
||||||
|
## 2020.1 (5)
|
||||||
|
The main focus of this update has been switching to using CoreData internally to cache/synchronize the most up-to-date versions of all statuses. Currently, this does not provide any new functionality, however, it lays the groundwork for several significant features coming in the future, including multiple window support on iPadOS and state restoration/persistence between launches.
|
||||||
|
|
||||||
|
Even though there aren't a huge number of new features in this build, a great deal has changed under the hood. As such, this build may suffer somewhat in the stability department. Please bear with me and report any issues you encounter; you can send me a message on the fediverse, email me at me@shadowfacts.net, or file an issue on the project issue tracker at https://git.shadowfacts.net/shadowfacts/Tusker/issues. Thank you!
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- iPadOS: Add pointer interactions to status action buttons and profile header button
|
||||||
|
- iPadOS: Allow scrolling w/ trackpad/magic mouse to dismiss attachment gallery
|
||||||
|
- iPadOS: Enable interactive push gesture with trackpad/magic mouse
|
||||||
|
- Add drawing attachments using PencilKit
|
||||||
|
- Long-press to open context menu on the 'Add Attachment' button on the Compose screen, select 'Draw Something'
|
||||||
|
- Supports Apple Pencil on iPad, including tilt and pressure sensitivity
|
||||||
|
- Add avatar and instance domain in accounts switcher in Preferences
|
||||||
|
- Show gifv attachments on Mastodon
|
||||||
|
- Currently doesn't show attachment description or share/close buttons
|
||||||
|
- Add 'Clear Cache' option to Preferences -> Advanced for debugging
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix size of attachment previews in context menu
|
||||||
|
- Fix previewing audio/video attachments
|
||||||
|
- Fix incorrect image size during attachment expand/shrink animation
|
||||||
|
- Prevent avatars in grouped action notification from overflowing the cell and hiding the timestamp
|
||||||
|
- Fix text in conversation main statuses not being de-selectable
|
||||||
|
- Fix scroll-to-top sometimes not scrolling all the way to the top
|
||||||
|
- Fix account profile descriptions being squashed in the follow notification account list
|
||||||
|
|
||||||
|
|
1
Cache
1
Cache
|
@ -1 +0,0 @@
|
||||||
Subproject commit 8c42c575cf28b2ff0e780c9728721e9a8891c92e
|
|
|
@ -1,355 +0,0 @@
|
||||||
# X-Callback-URLs in Tusker
|
|
||||||
|
|
||||||
Tusker supports inter-app-communication using the [X-Callback-URL standard](http://x-callback-url.com/).
|
|
||||||
|
|
||||||
In short, requests are performed by opening the URL `tusker://x-callback-url/[request]` (where `[request]` is one of the requests listed below) with a variety of parameters.
|
|
||||||
|
|
||||||
## Callbacks
|
|
||||||
|
|
||||||
X-Callback-URLs support three types of callbacks: on success, on cancellation, and on error. Callbacks are specified as query parameters whose keys identify which callback (`x-success`, `x-cancel`, and `x-error`) and whose values are other URLs that should be opened to run the callback.
|
|
||||||
|
|
||||||
Data is passed to callbacks by adding additional query parameters to the callback URL. The `x-error` callback always returns a description of the error in the `error` parameter. Other data is provided depending on the request.
|
|
||||||
|
|
||||||
### JSON Responses
|
|
||||||
|
|
||||||
By default, callback data is included in URL query parameters of the callback URL. If the `json=true` parameter is provided, the response data will be encoded as JSON, converted to [Base64](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding), and provided in the `response` query parameter of the callback.
|
|
||||||
|
|
||||||
## Silent Requests
|
|
||||||
|
|
||||||
Tusker X-Callback-URL requests can be performed silently, without user confirmation. Each source app requires user permission on the first attempted silent action.
|
|
||||||
|
|
||||||
To perform a silent request:
|
|
||||||
|
|
||||||
1. Provide the `silent=true` URL query parameter in the request.
|
|
||||||
|
|
||||||
2. Specify the `x-source` parameter. It must be a (human interpretable) name of the source application/service. If `x-source` is not specified, the error callback will be invoked with the error message:
|
|
||||||
|
|
||||||
```
|
|
||||||
Cannot perform silent action without source app, x-source parameter must be specified.
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Depending on the current permission state of the source app, one of several things will happen:
|
|
||||||
1. If the permission is **undecided** (i.e. the user has neither accepted nor rejected the silent action request), an alert will be displayed notifying the user that the source app has requested permission to silently perform actions. After the user either accepts or rejects the request, execution will continue with that permission state.
|
|
||||||
2. **Accepted**: the request will be carried out silently and the appropriate callback executed.
|
|
||||||
3. **Rejected**: the request will be performed with the confirmation UI, as if the `silent` parameter had been false/unprovided.
|
|
||||||
|
|
||||||
The silent actions permission state of a given source app is not exposed in the callback.
|
|
||||||
|
|
||||||
## Other Notes
|
|
||||||
|
|
||||||
#### Instance-Local IDs
|
|
||||||
|
|
||||||
Instance-local IDs are provided for many responses and accept in place of URLs/URIs/qualified names in many requests. When possible, instance-local IDs should be preferred requests using them can often be performed faster because there's no need to perform a search query or make requests to remote instances.
|
|
||||||
|
|
||||||
#### Qualified Usernames
|
|
||||||
|
|
||||||
Qualified username refers to the domain-qualified identifier of an account. For example, `shadowfacts@social.shadowfacts.net`. They do not include a leading `@`.
|
|
||||||
|
|
||||||
#### Dates
|
|
||||||
|
|
||||||
Dates in responses are encoded as Unix timestamps.
|
|
||||||
|
|
||||||
## Requests
|
|
||||||
|
|
||||||
- [Accounts](#accounts)
|
|
||||||
- [`showAccount`](#showaccount)
|
|
||||||
- [`getCurrentUser`](#getcurrentuser)
|
|
||||||
- [`getAccount`](#getaccount)
|
|
||||||
- [`followUser`](#followuser)
|
|
||||||
- [Statuses](#statuses)
|
|
||||||
- [`showStatus`](#showstatus)
|
|
||||||
- [`getStatus`](#getstatus)
|
|
||||||
- [`postStatus`](#poststatus)
|
|
||||||
- [`favoriteStatus`](#favoritestatus)
|
|
||||||
- [`reblogStatus`](#reblogstatus)
|
|
||||||
- [Notifications](#notifications)
|
|
||||||
- [`getNotification`](#getnotification)
|
|
||||||
- [`getNotifications`](#getnotifications)
|
|
||||||
- [`dismissNotification`](#dismissnotification)
|
|
||||||
- [`dismissAllNotifications`](#dismissallnotifications)
|
|
||||||
- [Instances](#instances)
|
|
||||||
- [`getCurrentInstance`](#getcurrentinstance)
|
|
||||||
- [Misc](#misc)
|
|
||||||
- [`search`](#search)
|
|
||||||
|
|
||||||
### Accounts
|
|
||||||
|
|
||||||
#### `showAccount`
|
|
||||||
|
|
||||||
Presents the given account in Tusker.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
|
||||||
| `accountID` (string) | The instance-local ID of the account | Yes |
|
|
||||||
| `accountURL` (URL) | The URL of the remote account | Yes |
|
|
||||||
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
No data if successful.
|
|
||||||
|
|
||||||
#### `getCurrentUser`
|
|
||||||
|
|
||||||
Retrieves the currently logged-in user.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
No parameters.
|
|
||||||
|
|
||||||
##### Response:
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ---------------------- | --------------------------------------------- | -------- |
|
|
||||||
| `username` (string) | The [qualified username](#qualifiedusernames) | No |
|
|
||||||
| `displayName` (string) | The display name | No |
|
|
||||||
| `locked` (bool) | Whether the user's account is locked | No |
|
|
||||||
| `followers` (int) | The number of followers the user has | No |
|
|
||||||
| `following` (int) | The number of accounts user is following | No |
|
|
||||||
| `url` (URL) | The URL of the user's account | No |
|
|
||||||
| `avatarURL` (URL) | The URL of the user's avatar image | No |
|
|
||||||
| `headerURL` (URL) | The URL of the user's header image | No |
|
|
||||||
|
|
||||||
#### `getAccount`
|
|
||||||
|
|
||||||
Retrieves the given account details. One of `accountID`, `accountURL`, or `acct` must be provided.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
|
||||||
| `accountID` (string) | The instance-local ID of the account | Yes |
|
|
||||||
| `accountURL` (URL) | The URL/URI of the account | Yes |
|
|
||||||
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ---------------------- | ------------------------------------------- | -------- |
|
|
||||||
| `username` (string) | The qualified username | No |
|
|
||||||
| `displayName` (string) | The display name | No |
|
|
||||||
| `locked` (bool) | Whether the account is locked | No |
|
|
||||||
| `followers` (int) | The number of followers the account has | No |
|
|
||||||
| `following` (int) | The number of accounts account is following | No |
|
|
||||||
| `url` (URL) | The URL of the account | No |
|
|
||||||
| `avatarURL` (URL) | The URL of the account's avatar image | No |
|
|
||||||
| `headerURL` (URL) | The URL of the account's header image | No |
|
|
||||||
|
|
||||||
#### `followUser`
|
|
||||||
|
|
||||||
Follows the given account from the logged-in user's account. One of `accountID`, `accountURL`, or `acct` must be provided.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
|
||||||
| `accountID` (string) | The instance-local ID of the account | Yes |
|
|
||||||
| `accountURL` (URL) | The URL/URI of the account | Yes |
|
|
||||||
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ---------------- | ------------------------------- | -------- |
|
|
||||||
| `url` (URL) | The URL of the followed account | No |
|
|
||||||
|
|
||||||
### Statuses
|
|
||||||
|
|
||||||
#### `showStatus`
|
|
||||||
|
|
||||||
Presents the given status in Tusker.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ------------------- | ----------------------------------- | -------- |
|
|
||||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
|
||||||
| `statusURL` (URL) | The URL of a remote status | Yes |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
No data if successful.
|
|
||||||
|
|
||||||
#### `getStatus`
|
|
||||||
|
|
||||||
Retrieves the given status details. One of `statusID` or `statusURL` must be provided.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ------------------- | ------------------------------------------------------------ | -------- |
|
|
||||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
|
||||||
| `statusURL` (URL) | The URL/URI of the status | Yes |
|
|
||||||
| `html` (bool) | Whether to return the content as HTML or plain-text only. Default: `false` (plain-text). | Yes |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
|
||||||
| `url` (URL) | The URL of the status | Yes |
|
|
||||||
| `uri` (string) | The URI of the status | No |
|
|
||||||
| `id` (string) | The instance-local ID of the status | |
|
|
||||||
| `account` (string) | The [qualified username](#qualifiedusernames) of the account that posted (or reblogged if `reblog` is present) the status | No |
|
|
||||||
| `inReplyTo` (string) | The instance-local ID of the status that this status is a reply to | Yes |
|
|
||||||
| `posted` (date) | The date the status was posted | No |
|
|
||||||
| `content` (string) | The content of the status (HTML if the `html` parameter was true, plain-text otherwise) | No |
|
|
||||||
| `reblog` (string) | The **instance-local** ID of the status that this is a reblog of. If not present, this status was not a reblog. | Yes |
|
|
||||||
|
|
||||||
#### `postStatus`
|
|
||||||
|
|
||||||
Posts a status from the logged-in user's account.
|
|
||||||
|
|
||||||
Can be performed silently.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ------------------- | ------------------------------------------------------------ | -------- |
|
|
||||||
| `mentioning` (bool) | The [qualified username](#qualifiedusernames) to mention in the status | Yes |
|
|
||||||
| `text` (string) | The text to post/pre-fill the status text field with | Yes |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| -------------------- | ---------------------------- | -------- |
|
|
||||||
| `statusURL` (URL) | The URL of the posted status | Yes |
|
|
||||||
| `statusURI` (string) | The URI of the posted status | No |
|
|
||||||
|
|
||||||
#### `favoriteStatus`
|
|
||||||
|
|
||||||
Favorites the given status from the logged-in user's account. One of `statusID` or `statusURL` must be provided.
|
|
||||||
|
|
||||||
Can be performed silently.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ------------------- | ----------------------------------- | -------- |
|
|
||||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
|
||||||
| `statusURL` (URL) | The URL/URI of a status | Yes |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| -------------------- | ------------------------------- | -------- |
|
|
||||||
| `statusURL` (URL) | The URL of the favorited status | Yes |
|
|
||||||
| `statusURI` (string) | The URI of the favorited status | No |
|
|
||||||
|
|
||||||
#### `reblogStatus`
|
|
||||||
|
|
||||||
Reblogs the given status from the logged-in user's account. One of `statusID` or `statusURL` must be provided.
|
|
||||||
|
|
||||||
Can be performed silently.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ------------------- | ----------------------------------- | -------- |
|
|
||||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
|
||||||
| `statusURL` (URL) | The URL/URI of a status | Yes |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| -------------------- | ------------------------------- | -------- |
|
|
||||||
| `statusURL` (URL) | The URL of the reblogged status | Yes |
|
|
||||||
| `statusURI` (string) | The URI of the reblogged status | No |
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
|
|
||||||
#### `getNotification`
|
|
||||||
|
|
||||||
Retrieves the given notification details.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ------------------------- | ----------------------------------------- | -------- |
|
|
||||||
| `notificationID` (string) | The instance-local ID of the notification | No |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
|
||||||
| `kind` (string) | One of `mention`, `reblog`, `favourite`, or `follow` | No |
|
|
||||||
| `date` (date) | The date the notification was created. | No |
|
|
||||||
| `accountID` (string) | The instance-local ID of the account that sent the notification | No |
|
|
||||||
| `statusID` (string) | The instance-local ID of the status associated with the notification. Not applicable for `kind=follow`. | Yes |
|
|
||||||
|
|
||||||
#### `getNotifications`
|
|
||||||
|
|
||||||
Retrieves the most recent notifications.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ---------------- | ---------------------------------------------------- | -------- |
|
|
||||||
| `count` (int) | The number of notifications to retrieve. Default: 20 | Yes |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ------------------------ | ---------------------------------------------------------- | -------- |
|
|
||||||
| `notifications` (string) | A comma-delimited array of instance-local notification IDs | No |
|
|
||||||
|
|
||||||
#### `dismissNotification`
|
|
||||||
|
|
||||||
Dismisses the given notification.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ----------------------- | ----------------------------------------- | -------- |
|
|
||||||
| `notification` (string) | The instance-local ID of the notification | No |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
No response data if successful.
|
|
||||||
|
|
||||||
#### `dismissAllNotifications`
|
|
||||||
|
|
||||||
Dismisses all notifications.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
No parameters.
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
No data if successful.
|
|
||||||
|
|
||||||
### Instances
|
|
||||||
|
|
||||||
#### `getCurrentInstance`
|
|
||||||
|
|
||||||
Retrieves the current instance details.
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
No parameters.
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ------------------------- | ------------------------------------------------------- | -------- |
|
|
||||||
| `uri` (string) | The instance URI | No |
|
|
||||||
| `name` (string) | The instance name | No |
|
|
||||||
| `description` (string) | The instance description | No |
|
|
||||||
| `contactAccount` (string) | The instance-local ID of the instance's contact account | No |
|
|
||||||
|
|
||||||
|
|
||||||
### Misc
|
|
||||||
|
|
||||||
#### `search`
|
|
||||||
Performs a search in Tusker with the given query
|
|
||||||
|
|
||||||
##### Request
|
|
||||||
|
|
||||||
| Parameter (type) | Description | Optional |
|
|
||||||
| ---------------- | ------------------------ |--------- |
|
|
||||||
| `query` (string) | The search query to use. | No |
|
|
||||||
|
|
||||||
##### Response
|
|
||||||
|
|
||||||
No data if successful.
|
|
1
Gifu
1
Gifu
|
@ -1 +0,0 @@
|
||||||
Subproject commit ed572f53ce58b8e23499abeb3a926033cbe480f7
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// Action.js
|
||||||
|
// OpenInTusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/22/21.
|
||||||
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
var Action = function() {};
|
||||||
|
|
||||||
|
Action.prototype = {
|
||||||
|
|
||||||
|
run: function(arguments) {
|
||||||
|
const results = {
|
||||||
|
url: window.location.href,
|
||||||
|
};
|
||||||
|
const el = document.querySelector('link[rel=alternate][type="application/activity+json"]');
|
||||||
|
if (el) {
|
||||||
|
results.activityPubURL = el.href;
|
||||||
|
}
|
||||||
|
arguments.completionFunction(results);
|
||||||
|
},
|
||||||
|
|
||||||
|
finalize: function(arguments) {
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
var ExtensionPreprocessingJS = new Action();
|
|
@ -0,0 +1,100 @@
|
||||||
|
//
|
||||||
|
// ActionViewController.swift
|
||||||
|
// OpenInTusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/23/21.
|
||||||
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import MobileCoreServices
|
||||||
|
|
||||||
|
class ActionViewController: UIViewController {
|
||||||
|
|
||||||
|
@IBOutlet weak var imageView: UIImageView!
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
findURLFromWebPage { (components) in
|
||||||
|
if let components = components {
|
||||||
|
self.searchForURLInApp(components)
|
||||||
|
} else {
|
||||||
|
self.findURLItem { (components) in
|
||||||
|
if let components = components {
|
||||||
|
self.searchForURLInApp(components)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findURLFromWebPage(completion: @escaping (URLComponents?) -> Void) {
|
||||||
|
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||||
|
for provider in item.attachments! {
|
||||||
|
guard provider.hasItemConformingToTypeIdentifier(kUTTypePropertyList as String) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
provider.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil) { (result, error) in
|
||||||
|
guard let result = result as? [String: Any],
|
||||||
|
let jsResult = result[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
|
||||||
|
let urlString = jsResult["activityPubURL"] as? String ?? jsResult["url"] as? String,
|
||||||
|
let components = URLComponents(string: urlString) else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(components)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findURLItem(completion: @escaping (URLComponents?) -> Void) {
|
||||||
|
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||||
|
for provider in item.attachments! {
|
||||||
|
guard provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { (result, error) in
|
||||||
|
guard let result = result as? URL,
|
||||||
|
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(components)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchForURLInApp(_ components: URLComponents) {
|
||||||
|
var components = components
|
||||||
|
components.scheme = "tusker"
|
||||||
|
self.openURL(components.url!)
|
||||||
|
self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openURL(_ url: URL) {
|
||||||
|
var responder: UIResponder = self
|
||||||
|
while let parent = responder.next {
|
||||||
|
if let application = parent as? UIApplication {
|
||||||
|
application.perform(#selector(openURL(_:)), with: url)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
responder = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBAction func done() {
|
||||||
|
extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="ObA-dk-sSI">
|
||||||
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Image-->
|
||||||
|
<scene sceneID="7MM-of-jgj">
|
||||||
|
<objects>
|
||||||
|
<viewController title="Image" id="ObA-dk-sSI" customClass="ActionViewController" customModule="OpenInTusker" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="zMn-AG-sqS">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="320" height="528"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<navigationBar contentMode="scaleToFill" horizontalCompressionResistancePriority="751" verticalCompressionResistancePriority="751" translatesAutoresizingMaskIntoConstraints="NO" id="NOA-Dm-cuz">
|
||||||
|
<rect key="frame" x="0.0" y="44" width="320" height="44"/>
|
||||||
|
<items>
|
||||||
|
<navigationItem id="3HJ-uW-3hn">
|
||||||
|
<barButtonItem key="leftBarButtonItem" title="Done" style="done" id="WYi-yp-eM6">
|
||||||
|
<connections>
|
||||||
|
<action selector="done" destination="ObA-dk-sSI" id="Qdu-qn-U6V"/>
|
||||||
|
</connections>
|
||||||
|
</barButtonItem>
|
||||||
|
</navigationItem>
|
||||||
|
</items>
|
||||||
|
</navigationBar>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Unable to find Mastodon link on this page." textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yho-gp-VyR">
|
||||||
|
<rect key="frame" x="0.0" y="254" width="320" height="20.5"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
<viewLayoutGuide key="safeArea" id="VVe-Uw-JpX"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="VVe-Uw-JpX" firstAttribute="trailing" secondItem="NOA-Dm-cuz" secondAttribute="trailing" id="A05-Pj-hrr"/>
|
||||||
|
<constraint firstItem="NOA-Dm-cuz" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="HxO-8t-aoh"/>
|
||||||
|
<constraint firstItem="Yho-gp-VyR" firstAttribute="centerY" secondItem="zMn-AG-sqS" secondAttribute="centerY" id="R7q-OB-hhA"/>
|
||||||
|
<constraint firstItem="Yho-gp-VyR" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="TEy-zi-dP7"/>
|
||||||
|
<constraint firstItem="Yho-gp-VyR" firstAttribute="trailing" secondItem="VVe-Uw-JpX" secondAttribute="trailing" id="Uvn-0x-Y6N"/>
|
||||||
|
<constraint firstItem="NOA-Dm-cuz" firstAttribute="top" secondItem="VVe-Uw-JpX" secondAttribute="top" id="we0-1t-bgp"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||||
|
<size key="freeformSize" width="320" height="528"/>
|
||||||
|
<connections>
|
||||||
|
<outlet property="view" destination="zMn-AG-sqS" id="Qma-de-2ek"/>
|
||||||
|
</connections>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="X47-rx-isc" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="-61" y="-57"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<systemColor name="systemBackgroundColor">
|
||||||
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</systemColor>
|
||||||
|
</resources>
|
||||||
|
</document>
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Open in Tusker</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||||
|
<string>Action</string>
|
||||||
|
<key>NSExtensionServiceAllowsFinderPreviewItem</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExtensionServiceAllowsTouchBarItem</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExtensionServiceFinderPreviewIconName</key>
|
||||||
|
<string>NSActionTemplate</string>
|
||||||
|
<key>NSExtensionServiceTouchBarBezelColorName</key>
|
||||||
|
<string>TouchBarBezel</string>
|
||||||
|
<key>NSExtensionServiceTouchBarIconName</key>
|
||||||
|
<string>NSActionTemplate</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionMainStoryboard</key>
|
||||||
|
<string>MainInterface</string>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.ui-services</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
Binary file not shown.
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 900 B |
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
|
@ -0,0 +1,103 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "60x60@2x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "60x60@3x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "76x76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "76x76@2x.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "76x76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "83.5x83.5@2x.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "83.5x83.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "1024x1024@1x.png",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
},
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"color" : {
|
||||||
|
"reference" : "systemPurpleColor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -2,7 +2,9 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>IDEDidComputeMac32BitWarning</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
|
@ -1,39 +0,0 @@
|
||||||
//
|
|
||||||
// ClientModel.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
protocol ClientModel {
|
|
||||||
var client: Client! { get set }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array where Element == ClientModel {
|
|
||||||
var client: Client! {
|
|
||||||
get {
|
|
||||||
return first?.client
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
for var el in self {
|
|
||||||
el.client = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array where Element: ClientModel {
|
|
||||||
var client: Client! {
|
|
||||||
get {
|
|
||||||
return first?.client
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
for var el in self {
|
|
||||||
el.client = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>$(PRODUCT_NAME)</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>FMWK</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
|
@ -1,48 +0,0 @@
|
||||||
//
|
|
||||||
// Card.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Card: Decodable {
|
|
||||||
public let url: URL
|
|
||||||
public let title: String
|
|
||||||
public let description: String
|
|
||||||
public let image: URL?
|
|
||||||
public let kind: Kind
|
|
||||||
public let authorName: String?
|
|
||||||
public let authorURL: URL?
|
|
||||||
public let providerName: String?
|
|
||||||
public let providerURL: URL?
|
|
||||||
public let html: String?
|
|
||||||
public let width: Int?
|
|
||||||
public let height: Int?
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case url
|
|
||||||
case title
|
|
||||||
case description
|
|
||||||
case image
|
|
||||||
case kind = "type"
|
|
||||||
case authorName = "author_name"
|
|
||||||
case authorURL = "author_url"
|
|
||||||
case providerName = "provider_name"
|
|
||||||
case providerURL = "provider_url"
|
|
||||||
case html
|
|
||||||
case width
|
|
||||||
case height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Card {
|
|
||||||
public enum Kind: String, Decodable {
|
|
||||||
case link
|
|
||||||
case photo
|
|
||||||
case video
|
|
||||||
case rich
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
//
|
|
||||||
// Emoji.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/8/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Emoji: Decodable {
|
|
||||||
public let shortcode: String
|
|
||||||
public let url: URL
|
|
||||||
public let staticURL: URL
|
|
||||||
public let visibleInPicker: Bool
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case shortcode
|
|
||||||
case url
|
|
||||||
case staticURL = "static_url"
|
|
||||||
case visibleInPicker = "visible_in_picker"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Emoji: CustomDebugStringConvertible {
|
|
||||||
public var debugDescription: String {
|
|
||||||
return ":\(shortcode):"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,84 +0,0 @@
|
||||||
//
|
|
||||||
// Hashtag.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Hashtag: Codable {
|
|
||||||
public let name: String
|
|
||||||
public let url: URL
|
|
||||||
public let history: [History]?
|
|
||||||
|
|
||||||
public init(name: String, url: URL) {
|
|
||||||
self.name = name
|
|
||||||
self.url = url
|
|
||||||
self.history = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case name
|
|
||||||
case url
|
|
||||||
case history
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Hashtag {
|
|
||||||
public class History: Codable {
|
|
||||||
public let day: Date
|
|
||||||
public let uses: Int
|
|
||||||
public let accounts: Int
|
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
if let day = try? container.decode(Date.self, forKey: .day) {
|
|
||||||
self.day = day
|
|
||||||
} else if let unixTimestamp = try? container.decode(Double.self, forKey: .day) {
|
|
||||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
|
||||||
} else if let str = try? container.decode(String.self, forKey: .day),
|
|
||||||
let unixTimestamp = Double(str) {
|
|
||||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .day, in: container, debugDescription: "day must be either date or UNIX timestamp")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let uses = try? container.decode(Int.self, forKey: .uses) {
|
|
||||||
self.uses = uses
|
|
||||||
} else if let str = try? container.decode(String.self, forKey: .uses),
|
|
||||||
let uses = Int(str) {
|
|
||||||
self.uses = uses
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .uses, in: container, debugDescription: "uses must either be int or string containing int")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let accounts = try? container.decode(Int.self, forKey: .accounts) {
|
|
||||||
self.accounts = accounts
|
|
||||||
} else if let str = try? container.decode(String.self, forKey: .accounts),
|
|
||||||
let accounts = Int(str) {
|
|
||||||
self.accounts = accounts
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .accounts, in: container, debugDescription: "accounts must either be int or string containing int")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case day
|
|
||||||
case uses
|
|
||||||
case accounts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Hashtag: Equatable, Hashable {
|
|
||||||
public static func ==(lhs: Hashtag, rhs: Hashtag) -> Bool {
|
|
||||||
return lhs.name == rhs.name
|
|
||||||
}
|
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(url)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,87 +0,0 @@
|
||||||
//
|
|
||||||
// Instance.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Instance: Decodable {
|
|
||||||
public let uri: String
|
|
||||||
public let title: String
|
|
||||||
public let description: String
|
|
||||||
public let email: String?
|
|
||||||
public let version: String
|
|
||||||
public let urls: [String: URL]
|
|
||||||
public let thumbnail: URL?
|
|
||||||
public let languages: [String]?
|
|
||||||
public let stats: Stats?
|
|
||||||
|
|
||||||
// pleroma doesn't currently implement these
|
|
||||||
public let contactAccount: Account?
|
|
||||||
|
|
||||||
// MARK: Unofficial additions to the Mastodon API.
|
|
||||||
public let maxStatusCharacters: Int?
|
|
||||||
|
|
||||||
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
self.uri = try container.decode(String.self, forKey: .uri)
|
|
||||||
self.title = try container.decode(String.self, forKey: .title)
|
|
||||||
self.description = try container.decode(String.self, forKey: .description)
|
|
||||||
self.email = try container.decodeIfPresent(String.self, forKey: .email)
|
|
||||||
self.version = try container.decode(String.self, forKey: .version)
|
|
||||||
if let urls = try? container.decodeIfPresent([String: URL].self, forKey: .urls) {
|
|
||||||
self.urls = urls
|
|
||||||
} else {
|
|
||||||
self.urls = [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
self.languages = try? container.decodeIfPresent([String].self, forKey: .languages)
|
|
||||||
self.contactAccount = try? container.decodeIfPresent(Account.self, forKey: .contactAccount)
|
|
||||||
|
|
||||||
self.stats = try? container.decodeIfPresent(Stats.self, forKey: .stats)
|
|
||||||
self.thumbnail = try? container.decodeIfPresent(URL.self, forKey: .thumbnail)
|
|
||||||
if let maxStatusCharacters = try? container.decodeIfPresent(Int.self, forKey: .maxStatusCharacters) {
|
|
||||||
self.maxStatusCharacters = maxStatusCharacters
|
|
||||||
} else if let str = try? container.decodeIfPresent(String.self, forKey: .maxStatusCharacters),
|
|
||||||
let maxStatusCharacters = Int(str, radix: 10) {
|
|
||||||
self.maxStatusCharacters = maxStatusCharacters
|
|
||||||
} else {
|
|
||||||
self.maxStatusCharacters = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case uri
|
|
||||||
case title
|
|
||||||
case description
|
|
||||||
case email
|
|
||||||
case version
|
|
||||||
case urls
|
|
||||||
case thumbnail
|
|
||||||
case languages
|
|
||||||
case stats
|
|
||||||
|
|
||||||
case contactAccount = "contact_account"
|
|
||||||
|
|
||||||
case maxStatusCharacters = "max_toot_chars"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Instance {
|
|
||||||
public class Stats: Decodable {
|
|
||||||
public let domainCount: Int?
|
|
||||||
public let statusCount: Int?
|
|
||||||
public let userCount: Int?
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case domainCount = "domain_count"
|
|
||||||
case statusCount = "status_count"
|
|
||||||
case userCount = "user_count"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
//
|
|
||||||
// Mention.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Mention: Decodable {
|
|
||||||
public let url: URL
|
|
||||||
public let username: String
|
|
||||||
public let acct: String
|
|
||||||
public let id: String
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case url
|
|
||||||
case username
|
|
||||||
case acct
|
|
||||||
case id
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
//
|
|
||||||
// RegisteredApplication.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class RegisteredApplication: Decodable {
|
|
||||||
public let id: String
|
|
||||||
public let clientID: String
|
|
||||||
public let clientSecret: String
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case id
|
|
||||||
case clientID = "client_id"
|
|
||||||
case clientSecret = "client_secret"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
//
|
|
||||||
// Pachyderm.h
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/8/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
//! Project version number for Pachyderm.
|
|
||||||
FOUNDATION_EXPORT double PachydermVersionNumber;
|
|
||||||
|
|
||||||
//! Project version string for Pachyderm.
|
|
||||||
FOUNDATION_EXPORT const unsigned char PachydermVersionString[];
|
|
||||||
|
|
||||||
// In this header, you should import all the public headers of your framework using statements like #import <Pachyderm/PublicHeader.h>
|
|
||||||
|
|
||||||
|
|
|
@ -1,170 +0,0 @@
|
||||||
//
|
|
||||||
// Promise.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 2/14/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Promise<Result> {
|
|
||||||
private var handlers: [(Result) -> Void] = []
|
|
||||||
private var result: Result?
|
|
||||||
private var catchers: [(Error) -> Void] = []
|
|
||||||
private var error: Error?
|
|
||||||
|
|
||||||
func resolve(_ result: Result) {
|
|
||||||
self.result = result
|
|
||||||
self.handlers.forEach { $0(result) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func reject(_ error: Error) {
|
|
||||||
self.error = error
|
|
||||||
self.catchers.forEach { $0(error) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func addHandler(_ handler: @escaping (Result) -> Void) {
|
|
||||||
if let result = result {
|
|
||||||
handler(result)
|
|
||||||
} else {
|
|
||||||
handlers.append(handler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addCatcher(_ catcher: @escaping (Error) -> Void) {
|
|
||||||
if let error = error {
|
|
||||||
catcher(error)
|
|
||||||
} else {
|
|
||||||
catchers.append(catcher)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Promise {
|
|
||||||
static func resolve<Result>(_ value: Result) -> Promise<Result> {
|
|
||||||
let promise = Promise<Result>()
|
|
||||||
promise.resolve(value)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
static func reject<Result>(_ error: Error) -> Promise<Result> {
|
|
||||||
let promise = Promise<Result>()
|
|
||||||
promise.reject(error)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
static func all<Result>(_ promises: [Promise<Result>], queue: DispatchQueue = .main) -> Promise<[Result]> {
|
|
||||||
let group = DispatchGroup()
|
|
||||||
|
|
||||||
var results = [Result?](repeating: nil, count: promises.count)
|
|
||||||
var firstError: Error?
|
|
||||||
|
|
||||||
for (index, promise) in promises.enumerated() {
|
|
||||||
group.enter()
|
|
||||||
promise.then { (res) in
|
|
||||||
queue.async {
|
|
||||||
results[index] = res
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
}.catch { (err) -> Void in
|
|
||||||
if firstError == nil {
|
|
||||||
firstError = err
|
|
||||||
}
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise<[Result]> { (resolve, reject) in
|
|
||||||
group.notify(queue: queue) {
|
|
||||||
if let firstError = firstError {
|
|
||||||
reject(firstError)
|
|
||||||
} else {
|
|
||||||
resolve(results.compactMap { $0 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
convenience init(resultProvider: @escaping (_ resolve: @escaping (Result) -> Void, _ reject: @escaping (Error) -> Void) -> Void) {
|
|
||||||
self.init()
|
|
||||||
resultProvider(self.resolve, self.reject)
|
|
||||||
}
|
|
||||||
|
|
||||||
convenience init<ErrorType>(_ resultProvider: @escaping ((Swift.Result<Result, ErrorType>) -> Void) -> Void) {
|
|
||||||
self.init { (resolve, reject) in
|
|
||||||
resultProvider { (result) in
|
|
||||||
switch result {
|
|
||||||
case let .success(res):
|
|
||||||
resolve(res)
|
|
||||||
case let .failure(error):
|
|
||||||
reject(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func then(_ func: @escaping (Result) -> Void) -> Promise<Result> {
|
|
||||||
addHandler(`func`)
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
|
|
||||||
func then<Next>(_ mapper: @escaping (Result) -> Promise<Next>) -> Promise<Next> {
|
|
||||||
let next = Promise<Next>()
|
|
||||||
addHandler { (parentResult) in
|
|
||||||
let newPromise = mapper(parentResult)
|
|
||||||
newPromise.addHandler(next.resolve)
|
|
||||||
newPromise.addCatcher(next.reject)
|
|
||||||
}
|
|
||||||
addCatcher(next.reject)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
func then<Next>(_ mapper: @escaping (Result) -> Next) -> Promise<Next> {
|
|
||||||
let next = Promise<Next>()
|
|
||||||
addHandler { (parentResult) in
|
|
||||||
let newResult = mapper(parentResult)
|
|
||||||
next.resolve(newResult)
|
|
||||||
}
|
|
||||||
addCatcher(next.reject)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func `catch`(_ catcher: @escaping (Error) -> Void) -> Promise<Result> {
|
|
||||||
addCatcher(catcher)
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
|
|
||||||
func `catch`(_ catcher: @escaping (Error) -> Promise<Result>) -> Promise<Result> {
|
|
||||||
let next = Promise<Result>()
|
|
||||||
addHandler(next.resolve)
|
|
||||||
addCatcher { (error) in
|
|
||||||
let newPromise = catcher(error)
|
|
||||||
newPromise.addHandler(next.resolve)
|
|
||||||
newPromise.addCatcher(next.reject)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
func `catch`(_ catcher: @escaping (Error) -> Result) -> Promise<Result> {
|
|
||||||
let next = Promise<Result>()
|
|
||||||
addHandler(next.resolve)
|
|
||||||
addCatcher { (error) in
|
|
||||||
let newResult = catcher(error)
|
|
||||||
next.resolve(newResult)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
func handle(on queue: DispatchQueue) -> Promise<Result> {
|
|
||||||
return self.then { (result) in
|
|
||||||
return Promise { (resolve, reject) in
|
|
||||||
queue.async {
|
|
||||||
resolve(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
//
|
|
||||||
// Body.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/8/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum Body {
|
|
||||||
case parameters([Parameter]?)
|
|
||||||
case formData([Parameter]?, FormAttachment?)
|
|
||||||
case empty
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Body {
|
|
||||||
private static let boundary: String = "PachydermBoundary"
|
|
||||||
|
|
||||||
var data: Data? {
|
|
||||||
switch self {
|
|
||||||
case let .parameters(parameters):
|
|
||||||
return parameters?.urlEncoded.data(using: .utf8)
|
|
||||||
case let .formData(parameters, attachment):
|
|
||||||
var data = Data()
|
|
||||||
parameters?.forEach { param in
|
|
||||||
guard let value = param.value else { return }
|
|
||||||
data.append("--\(Body.boundary)\r\n")
|
|
||||||
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
|
|
||||||
data.append("\(value)\r\n")
|
|
||||||
}
|
|
||||||
if let attachment = attachment {
|
|
||||||
data.append("--\(Body.boundary)\r\n")
|
|
||||||
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
|
|
||||||
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
|
|
||||||
data.append(attachment.data)
|
|
||||||
data.append("\r\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
data.append("--\(Body.boundary)--\r\n")
|
|
||||||
return data
|
|
||||||
case .empty:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var mimeType: String? {
|
|
||||||
switch self {
|
|
||||||
case let .parameters(parameters):
|
|
||||||
if parameters == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return "application/x-www-form-urlencoded; charset=utf-8"
|
|
||||||
case let .formData(parameters, attachment):
|
|
||||||
if parameters == nil && attachment == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return "multipart/form-data; boundary=\(Body.boundary)"
|
|
||||||
case .empty:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
//
|
|
||||||
// InstanceType.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/11/19.
|
|
||||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum InstanceType {
|
|
||||||
case mastodon, pleroma
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Instance {
|
|
||||||
var instanceType: InstanceType {
|
|
||||||
let lowercased = version.lowercased()
|
|
||||||
if lowercased.contains("pleroma") {
|
|
||||||
return .pleroma
|
|
||||||
} else {
|
|
||||||
return .mastodon
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
//
|
|
||||||
// NotificationGroup.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/5/19.
|
|
||||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class NotificationGroup {
|
|
||||||
public let notificationIDs: [String]
|
|
||||||
public let id: String
|
|
||||||
public let kind: Notification.Kind
|
|
||||||
public let statusState: StatusState?
|
|
||||||
|
|
||||||
init?(notifications: [Notification]) {
|
|
||||||
guard !notifications.isEmpty else { return nil }
|
|
||||||
self.notificationIDs = notifications.map { $0.id }
|
|
||||||
self.id = notifications.first!.id
|
|
||||||
self.kind = notifications.first!.kind
|
|
||||||
if kind == .mention {
|
|
||||||
self.statusState = .unknown
|
|
||||||
} else {
|
|
||||||
self.statusState = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
|
||||||
return notifications.reduce(into: [[Notification]]()) { (groups, notification) in
|
|
||||||
if allowedTypes.contains(notification.kind),
|
|
||||||
let lastGroup = groups.last,
|
|
||||||
let firstStatus = lastGroup.first,
|
|
||||||
firstStatus.kind == notification.kind,
|
|
||||||
firstStatus.status?.id == notification.status?.id {
|
|
||||||
|
|
||||||
groups[groups.count - 1].append(notification)
|
|
||||||
} else {
|
|
||||||
groups.append([notification])
|
|
||||||
}
|
|
||||||
}.map {
|
|
||||||
NotificationGroup(notifications: $0)!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NotificationGroup: Identifiable {}
|
|
|
@ -1,22 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>$(PRODUCT_NAME)</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>BNDL</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>1</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
|
@ -1,34 +0,0 @@
|
||||||
//
|
|
||||||
// PachydermTests.swift
|
|
||||||
// PachydermTests
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/8/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import Pachyderm
|
|
||||||
|
|
||||||
class PachydermTests: XCTestCase {
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
|
||||||
}
|
|
||||||
|
|
||||||
func testExample() {
|
|
||||||
// This is an example of a functional test case.
|
|
||||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPerformanceExample() {
|
|
||||||
// This is an example of a performance test case.
|
|
||||||
self.measure {
|
|
||||||
// Put the code you want to measure the time of here.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,126 +0,0 @@
|
||||||
//
|
|
||||||
// PromiseTests.swift
|
|
||||||
// PachydermTests
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 2/14/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import Pachyderm
|
|
||||||
|
|
||||||
class PromiseTests: XCTestCase {
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
|
||||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertResultEqual<Result: Equatable>(_ promise: Promise<Result>, _ value: Result, message: String? = nil) {
|
|
||||||
let expectation = self.expectation(description: message ?? "promise result assertion")
|
|
||||||
promise.then {
|
|
||||||
XCTAssertEqual($0, value)
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
self.waitForExpectations(timeout: 2) { (error) in
|
|
||||||
if let error = error {
|
|
||||||
XCTFail("didn't resolve promise: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testResolveImmediate() {
|
|
||||||
assertResultEqual(Promise<String>.resolve("blah"), "blah")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testResolveImmediateMapped() {
|
|
||||||
let promise = Promise<String>.resolve("foo").then {
|
|
||||||
"test \($0)"
|
|
||||||
}.then {
|
|
||||||
Promise<String>.resolve("\($0) bar")
|
|
||||||
}
|
|
||||||
assertResultEqual(promise, "test foo bar")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testContinueAfterReject() {
|
|
||||||
let promise = Promise<String>.reject(TestError()).then { (res) in
|
|
||||||
XCTFail("then on rejected promise is unreachable")
|
|
||||||
}.catch { (error) -> String in
|
|
||||||
XCTAssertTrue(error is TestError)
|
|
||||||
return "caught"
|
|
||||||
}.then {
|
|
||||||
"\($0) error"
|
|
||||||
}
|
|
||||||
assertResultEqual(promise, "caught error")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testResolveDelayed() {
|
|
||||||
let promise = Promise<String> { (resolve, reject) in
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
||||||
resolve("blah")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertResultEqual(promise, "blah")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testResolveMappedDelayed() {
|
|
||||||
let promise = Promise<String> { (resolve, reject) in
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
||||||
resolve("foo")
|
|
||||||
}
|
|
||||||
}.then {
|
|
||||||
"\($0) bar"
|
|
||||||
}.then { (result) in
|
|
||||||
Promise<String> { (resolve, reject) in
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
resolve("\(result) baz")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertResultEqual(promise, "foo bar baz")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testResolveAll() {
|
|
||||||
let promise = Promise<[String]>.all([
|
|
||||||
Promise<String>.resolve("a"),
|
|
||||||
Promise<String>.resolve("b"),
|
|
||||||
Promise<String>.resolve("c"),
|
|
||||||
])
|
|
||||||
assertResultEqual(promise, ["a", "b", "c"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testIntermediateReject() {
|
|
||||||
let promise = Promise<String>.resolve("foo").then { (_) -> Promise<String> in
|
|
||||||
Promise<String>.reject(TestError())
|
|
||||||
}.catch { (error) -> String in
|
|
||||||
XCTAssertTrue(error is TestError)
|
|
||||||
return "caught"
|
|
||||||
}.then { (result) -> String in
|
|
||||||
"\(result) error"
|
|
||||||
}
|
|
||||||
assertResultEqual(promise, "caught error")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testResultHelper() {
|
|
||||||
let success = Promise<String> { (handler) in
|
|
||||||
handler(Result<String, Never>.success("asdf"))
|
|
||||||
}
|
|
||||||
assertResultEqual(success, "asdf")
|
|
||||||
let failure = Promise<String> { (handler) in
|
|
||||||
handler(Result<String, TestError>.failure(TestError()))
|
|
||||||
}.catch { (error) -> String in
|
|
||||||
"blah"
|
|
||||||
}
|
|
||||||
assertResultEqual(failure, "blah")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TestError: Error {
|
|
||||||
var localizedDescription: String {
|
|
||||||
"test error"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -0,0 +1,31 @@
|
||||||
|
// swift-tools-version: 5.7
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Duckable",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "Duckable",
|
||||||
|
targets: ["Duckable"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
// Dependencies declare other packages that this package depends on.
|
||||||
|
// .package(url: /* package url */, from: "1.0.0"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
|
.target(
|
||||||
|
name: "Duckable",
|
||||||
|
dependencies: []),
|
||||||
|
.testTarget(
|
||||||
|
name: "DuckableTests",
|
||||||
|
dependencies: ["Duckable"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Duckable
|
||||||
|
|
||||||
|
A package that allows modally-presented view controllers to be 'ducked' to make the content behind them accessible (à la Mail.app).
|
|
@ -0,0 +1,44 @@
|
||||||
|
//
|
||||||
|
// API.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public protocol DuckableViewController: UIViewController {
|
||||||
|
var duckableDelegate: DuckableViewControllerDelegate? { get set }
|
||||||
|
|
||||||
|
func duckableViewControllerMayAttemptToDuck()
|
||||||
|
|
||||||
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
|
||||||
|
|
||||||
|
func duckableViewControllerDidFinishAnimatingDuck()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuckableViewController {
|
||||||
|
public func duckableViewControllerMayAttemptToDuck() {}
|
||||||
|
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
|
||||||
|
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol DuckableViewControllerDelegate: AnyObject {
|
||||||
|
func duckableViewControllerWillDismiss(animated: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIViewController {
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
||||||
|
var cur: UIViewController? = self
|
||||||
|
while let vc = cur {
|
||||||
|
if let container = vc as? DuckableContainerViewController {
|
||||||
|
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
cur = vc.parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
//
|
||||||
|
// DetentIdentifier.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UISheetPresentationController.Detent.Identifier {
|
||||||
|
static let bottom = Self("\(Bundle.main.bundleIdentifier!).bottom")
|
||||||
|
}
|
|
@ -0,0 +1,89 @@
|
||||||
|
//
|
||||||
|
// DuckAnimationController.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
|
let owner: DuckableContainerViewController
|
||||||
|
let needsShrinkAnimation: Bool
|
||||||
|
|
||||||
|
init(owner: DuckableContainerViewController, needsShrinkAnimation: Bool) {
|
||||||
|
self.owner = owner
|
||||||
|
self.needsShrinkAnimation = needsShrinkAnimation
|
||||||
|
}
|
||||||
|
|
||||||
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
|
return 0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
guard case .ducked(let duckable, placeholder: let placeholder) = owner.state,
|
||||||
|
let presented = transitionContext.viewController(forKey: .from) else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard transitionContext.isAnimated else {
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let container = transitionContext.containerView
|
||||||
|
|
||||||
|
|
||||||
|
if needsShrinkAnimation {
|
||||||
|
|
||||||
|
duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0.2)
|
||||||
|
|
||||||
|
let presentedFrameInContainer = container.convert(presented.view.bounds, from: presented.view)
|
||||||
|
let heightToSlide = container.bounds.height - container.safeAreaInsets.bottom - detentHeight - presentedFrameInContainer.minY
|
||||||
|
|
||||||
|
let slideAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1)
|
||||||
|
slideAnimator.addAnimations {
|
||||||
|
container.transform = CGAffineTransform(translationX: 0, y: heightToSlide + 10)
|
||||||
|
}
|
||||||
|
slideAnimator.addCompletion { _ in
|
||||||
|
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
}
|
||||||
|
slideAnimator.startAnimation()
|
||||||
|
|
||||||
|
placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10)
|
||||||
|
let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
|
placeholder.view.transform = .identity
|
||||||
|
container.transform = CGAffineTransform(translationX: 0, y: heightToSlide)
|
||||||
|
}
|
||||||
|
bounceAnimator.startAnimation(afterDelay: 0.3)
|
||||||
|
|
||||||
|
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
|
presented.view.layer.opacity = 0
|
||||||
|
}
|
||||||
|
fadeAnimator.startAnimation(afterDelay: 0.3)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0)
|
||||||
|
|
||||||
|
placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10)
|
||||||
|
let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
|
placeholder.view.transform = .identity
|
||||||
|
container.transform = CGAffineTransform(translationX: 0, y: -10)
|
||||||
|
}
|
||||||
|
bounceAnimator.startAnimation(afterDelay: 0.2)
|
||||||
|
|
||||||
|
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
|
presented.view.layer.opacity = 0
|
||||||
|
}
|
||||||
|
fadeAnimator.addCompletion { _ in
|
||||||
|
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
}
|
||||||
|
fadeAnimator.startAnimation(afterDelay: 0.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,239 @@
|
||||||
|
//
|
||||||
|
// DuckableContainerViewController.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
let duckedCornerRadius: CGFloat = 10
|
||||||
|
let detentHeight: CGFloat = 44
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate {
|
||||||
|
|
||||||
|
public let child: UIViewController
|
||||||
|
private var bottomConstraint: NSLayoutConstraint!
|
||||||
|
private(set) var state = State.idle
|
||||||
|
|
||||||
|
public var duckedViewController: DuckableViewController? {
|
||||||
|
if case .ducked(let vc, placeholder: _) = state {
|
||||||
|
return vc
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(child: UIViewController) {
|
||||||
|
self.child = child
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
swizzleSheetController()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
child.beginAppearanceTransition(true, animated: false)
|
||||||
|
addChild(child)
|
||||||
|
child.didMove(toParent: self)
|
||||||
|
child.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(child.view)
|
||||||
|
child.endAppearanceTransition()
|
||||||
|
|
||||||
|
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
child.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
bottomConstraint,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
|
||||||
|
guard case .idle = state else {
|
||||||
|
if animated,
|
||||||
|
case .ducked(_, placeholder: let placeholder) = state {
|
||||||
|
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||||
|
let origConstant = placeholder.topConstraint.constant
|
||||||
|
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
|
||||||
|
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||||
|
placeholder.topConstraint.constant = origConstant - 20
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
|
||||||
|
placeholder.topConstraint.constant = origConstant
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isDucked {
|
||||||
|
state = .ducked(viewController, placeholder: createPlaceholderForDuckedViewController(viewController))
|
||||||
|
configureChildForDuckedPlaceholder()
|
||||||
|
} else {
|
||||||
|
state = .presentingDucked(viewController, isFirstPresentation: true)
|
||||||
|
doPresentDuckable(viewController, animated: animated, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||||
|
viewController.duckableDelegate = self
|
||||||
|
let nav = UINavigationController(rootViewController: viewController)
|
||||||
|
nav.modalPresentationStyle = .custom
|
||||||
|
nav.transitioningDelegate = self
|
||||||
|
present(nav, animated: animated) {
|
||||||
|
self.configureChildForDuckedPlaceholder()
|
||||||
|
completion?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func duckableViewControllerWillDismiss(animated: Bool) {
|
||||||
|
state = .idle
|
||||||
|
bottomConstraint.isActive = false
|
||||||
|
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
|
bottomConstraint.isActive = true
|
||||||
|
child.view.layer.cornerRadius = 0
|
||||||
|
setOverrideTraitCollection(nil, forChild: child)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPlaceholderForDuckedViewController(_ viewController: DuckableViewController) -> DuckedPlaceholderViewController {
|
||||||
|
let placeholder = DuckedPlaceholderViewController(for: viewController, owner: self)
|
||||||
|
placeholder.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
placeholder.beginAppearanceTransition(true, animated: false)
|
||||||
|
self.addChild(placeholder)
|
||||||
|
placeholder.didMove(toParent: self)
|
||||||
|
self.view.addSubview(placeholder.view)
|
||||||
|
placeholder.endAppearanceTransition()
|
||||||
|
|
||||||
|
let placeholderTopConstraint = placeholder.view.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight)
|
||||||
|
placeholder.topConstraint = placeholderTopConstraint
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
placeholder.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
|
||||||
|
placeholder.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
|
||||||
|
placeholder.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
|
||||||
|
placeholderTopConstraint
|
||||||
|
])
|
||||||
|
|
||||||
|
// otherwise the layout changes get lumped in with the system animation
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
return placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
func duckViewController() {
|
||||||
|
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
||||||
|
state = .ducked(viewController, placeholder: placeholder)
|
||||||
|
configureChildForDuckedPlaceholder()
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureChildForDuckedPlaceholder() {
|
||||||
|
bottomConstraint.isActive = false
|
||||||
|
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
|
||||||
|
bottomConstraint.isActive = true
|
||||||
|
|
||||||
|
child.view.layer.cornerRadius = duckedCornerRadius
|
||||||
|
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||||
|
child.view.layer.masksToBounds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func unduckViewController() {
|
||||||
|
guard case .ducked(let viewController, placeholder: let placeholder) = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .presentingDucked(viewController, isFirstPresentation: false)
|
||||||
|
doPresentDuckable(viewController, animated: true) {
|
||||||
|
placeholder.view.removeFromSuperview()
|
||||||
|
placeholder.willMove(toParent: nil)
|
||||||
|
placeholder.removeFromParent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sheetOffsetDidChange() {
|
||||||
|
if case .presentingDucked(let duckable, isFirstPresentation: _) = state {
|
||||||
|
duckable.duckableViewControllerMayAttemptToDuck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
case idle
|
||||||
|
case presentingDucked(DuckableViewController, isFirstPresentation: Bool)
|
||||||
|
case ducked(DuckableViewController, placeholder: DuckedPlaceholderViewController)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
|
||||||
|
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
||||||
|
let controller = UISheetPresentationController(presentedViewController: presented, presenting: presenting)
|
||||||
|
controller.delegate = self
|
||||||
|
controller.prefersGrabberVisible = true
|
||||||
|
controller.selectedDetentIdentifier = .large
|
||||||
|
controller.largestUndimmedDetentIdentifier = .bottom
|
||||||
|
controller.detents = [
|
||||||
|
.custom(identifier: .bottom, resolver: { context in
|
||||||
|
return detentHeight
|
||||||
|
}),
|
||||||
|
.large(),
|
||||||
|
]
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
if case .ducked(_, placeholder: _) = state {
|
||||||
|
return DuckAnimationController(
|
||||||
|
owner: self,
|
||||||
|
needsShrinkAnimation: isDetentChangingDueToGrabberAction
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
|
||||||
|
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
|
||||||
|
guard let snapshot = child.view.snapshotView(afterScreenUpdates: false) else {
|
||||||
|
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshot.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.view.addSubview(snapshot)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
snapshot.leadingAnchor.constraint(equalTo: child.view.leadingAnchor),
|
||||||
|
snapshot.trailingAnchor.constraint(equalTo: child.view.trailingAnchor),
|
||||||
|
snapshot.topAnchor.constraint(equalTo: child.view.topAnchor),
|
||||||
|
snapshot.bottomAnchor.constraint(equalTo: child.view.bottomAnchor),
|
||||||
|
])
|
||||||
|
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
|
||||||
|
transitionCoordinator!.animate { context in
|
||||||
|
snapshot.layer.opacity = 0
|
||||||
|
} completion: { _ in
|
||||||
|
snapshot.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
|
||||||
|
if sheetPresentationController.selectedDetentIdentifier == .bottom {
|
||||||
|
duckViewController()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
//
|
||||||
|
// DuckedPlaceholderView.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
class DuckedPlaceholderViewController: UIViewController {
|
||||||
|
private unowned let owner: DuckableContainerViewController
|
||||||
|
private let navBar = UINavigationBar()
|
||||||
|
|
||||||
|
var topConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
|
init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) {
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
let item = UINavigationItem()
|
||||||
|
item.title = duckableViewController.navigationItem.title
|
||||||
|
item.titleView = duckableViewController.navigationItem.titleView
|
||||||
|
navBar.setItems([item], animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
setBackgroundColor()
|
||||||
|
view.layer.cornerRadius = duckedCornerRadius
|
||||||
|
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
view.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
view.layer.shadowOpacity = 0.05
|
||||||
|
|
||||||
|
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(placeholderTapped)))
|
||||||
|
|
||||||
|
let appearance = UINavigationBarAppearance()
|
||||||
|
appearance.configureWithTransparentBackground()
|
||||||
|
navBar.standardAppearance = appearance
|
||||||
|
navBar.isUserInteractionEnabled = false
|
||||||
|
navBar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(navBar)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
navBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
navBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
navBar.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
|
||||||
|
setBackgroundColor()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setBackgroundColor() {
|
||||||
|
// when just using .systemBackground and setting the override trait collection for the placeholder VC,
|
||||||
|
// the color doesn't change until after the dismiss animation occurs (but only when tapping the grabber to duck, not when swiping)
|
||||||
|
view.backgroundColor = .systemBackground.resolvedColor(with: UITraitCollection(traitsFrom: [traitCollection, UITraitCollection(userInterfaceLevel: .elevated)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func placeholderTapped() {
|
||||||
|
owner.unduckViewController()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// Swizzler.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
private var hasInitialized = false
|
||||||
|
var isDetentChangingDueToGrabberAction = false
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
func swizzleSheetController() {
|
||||||
|
guard !hasInitialized else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasInitialized = true
|
||||||
|
|
||||||
|
var originalIMP: IMP?
|
||||||
|
let imp = imp_implementationWithBlock({ (self: UISheetPresentationController, param: AnyObject) in
|
||||||
|
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UISheetPresentationController, AnyObject) -> Void).self)
|
||||||
|
isDetentChangingDueToGrabberAction = true
|
||||||
|
original(self, param)
|
||||||
|
isDetentChangingDueToGrabberAction = false
|
||||||
|
} as @convention(block) (UISheetPresentationController, AnyObject) -> Void)
|
||||||
|
let sel = [":", "PrimaryAction", "GrabberDidTrigger", "dropShadowView", "_"].reversed().joined()
|
||||||
|
originalIMP = class_replaceMethod(UISheetPresentationController.self, Selector(sel), imp, "v@:@")
|
||||||
|
if originalIMP == nil {
|
||||||
|
os_log(.fault, log: .default, "Unable to initialize Duckable grabber tap hook")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// swift-tools-version: 5.6
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Pachyderm",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v14),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "Pachyderm",
|
||||||
|
targets: ["Pachyderm"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
// Dependencies declare other packages that this package depends on.
|
||||||
|
.package(url: "https://github.com/karwa/swift-url.git", branch: "main"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
|
.target(
|
||||||
|
name: "Pachyderm",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "WebURL", package: "swift-url"),
|
||||||
|
.product(name: "WebURLFoundationExtras", package: "swift-url"),
|
||||||
|
]),
|
||||||
|
.testTarget(
|
||||||
|
name: "PachydermTests",
|
||||||
|
dependencies: ["Pachyderm"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Pachyderm
|
||||||
|
|
||||||
|
A description of this package.
|
|
@ -7,7 +7,6 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The base Mastodon API client.
|
The base Mastodon API client.
|
||||||
|
@ -27,106 +26,95 @@ public class Client {
|
||||||
|
|
||||||
public var timeoutInterval: TimeInterval = 60
|
public var timeoutInterval: TimeInterval = 60
|
||||||
|
|
||||||
lazy var decoder: JSONDecoder = {
|
static let decoder: JSONDecoder = {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
decoder.dateDecodingStrategy = .formatted(formatter)
|
let iso8601 = ISO8601DateFormatter()
|
||||||
|
decoder.dateDecodingStrategy = .custom({ (decoder) in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let str = try container.decode(String.self)
|
||||||
|
// for the next time mastodon accidentally changes date formats >.>
|
||||||
|
if let date = formatter.date(from: str) {
|
||||||
|
return date
|
||||||
|
} else if let date = iso8601.date(from: str) {
|
||||||
|
return date
|
||||||
|
} else {
|
||||||
|
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return decoder
|
return decoder
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
static let encoder: JSONEncoder = {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||||
|
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
encoder.dateEncodingStrategy = .formatted(formatter)
|
||||||
|
return encoder
|
||||||
|
}()
|
||||||
|
|
||||||
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.accessToken = accessToken
|
self.accessToken = accessToken
|
||||||
self.session = session
|
self.session = session
|
||||||
}
|
}
|
||||||
|
|
||||||
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) {
|
@discardableResult
|
||||||
guard let request = createURLRequest(request: request) else {
|
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> URLSessionTask? {
|
||||||
completion(.failure(Error.invalidRequest))
|
guard let urlRequest = createURLRequest(request: request) else {
|
||||||
return
|
completion(.failure(Error(request: request, type: .invalidRequest)))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = session.dataTask(with: request) { data, response, error in
|
let task = session.dataTask(with: urlRequest) { data, response, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(Error(request: request, type: .networkError(error))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data,
|
guard let data = data,
|
||||||
let response = response as? HTTPURLResponse else {
|
let response = response as? HTTPURLResponse else {
|
||||||
completion(.failure(Error.invalidResponse))
|
completion(.failure(Error(request: request, type: .invalidResponse)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard response.statusCode == 200 else {
|
guard response.statusCode == 200 else {
|
||||||
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
|
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
||||||
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
|
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
|
||||||
completion(.failure(error))
|
completion(.failure(Error(request: request, type: type)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let result = try? self.decoder.decode(Result.self, from: data) else {
|
let result: Result
|
||||||
completion(.failure(Error.invalidModel))
|
do {
|
||||||
|
result = try Client.decoder.decode(Result.self, from: data)
|
||||||
|
} catch {
|
||||||
|
completion(.failure(Error(request: request, type: .invalidModel(error))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if var result = result as? ClientModel {
|
|
||||||
result.client = self
|
|
||||||
} else if var result = result as? [ClientModel] {
|
|
||||||
result.client = self
|
|
||||||
}
|
|
||||||
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
||||||
|
|
||||||
completion(.success(result, pagination))
|
completion(.success(result, pagination))
|
||||||
}
|
}
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
return task
|
||||||
|
|
||||||
public func run<Result>(_ request: Request<Result>) -> Promise<(Result, Pagination?)> {
|
|
||||||
return Promise { (resolve, reject) in
|
|
||||||
self.run(request) { (response) in
|
|
||||||
switch response {
|
|
||||||
case let .success(result, pagination):
|
|
||||||
resolve((result, pagination))
|
|
||||||
case let .failure(error):
|
|
||||||
reject(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func run<Result: Decodable>(_ request: Request<Result>) -> AnyPublisher<(Result, Pagination?), Swift.Error> {
|
|
||||||
guard let request = createURLRequest(request: request) else {
|
|
||||||
return Fail(error: Error.invalidRequest).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
return session.dataTaskPublisher(for: request)
|
|
||||||
.mapError { Error.urlError($0) }
|
|
||||||
.tryMap {
|
|
||||||
guard let response = $0.response as? HTTPURLResponse else {
|
|
||||||
throw Error.invalidResponse
|
|
||||||
}
|
|
||||||
guard response.statusCode == 200 else {
|
|
||||||
if let mastodonError = try? self.decoder.decode(MastodonError.self, from: $0.data) {
|
|
||||||
throw Error.mastodonError(mastodonError.description)
|
|
||||||
} else {
|
|
||||||
throw Error.unknownError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let result = try self.decoder.decode(Result.self, from: $0.data)
|
|
||||||
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
|
||||||
return (result, pagination)
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||||
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
||||||
components.path = request.path
|
components.path = request.endpoint.path
|
||||||
components.queryItems = request.queryParameters.queryItems
|
components.queryItems = request.queryParameters.isEmpty ? nil : request.queryParameters.queryItems
|
||||||
guard let url = components.url else { return nil }
|
guard let url = components.url else { return nil }
|
||||||
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
||||||
urlRequest.httpMethod = request.method.name
|
urlRequest.httpMethod = request.method.name
|
||||||
urlRequest.httpBody = request.body.data
|
urlRequest.httpBody = request.body.data
|
||||||
urlRequest.setValue(request.body.mimeType, forHTTPHeaderField: "Content-Type")
|
if let mimeType = request.body.mimeType {
|
||||||
|
urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
if let accessToken = accessToken {
|
if let accessToken = accessToken {
|
||||||
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
}
|
}
|
||||||
|
@ -135,7 +123,7 @@ public class Client {
|
||||||
|
|
||||||
// MARK: - Authorization
|
// MARK: - Authorization
|
||||||
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
|
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
|
||||||
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
|
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
|
||||||
"client_name" => name,
|
"client_name" => name,
|
||||||
"redirect_uris" => redirectURI,
|
"redirect_uris" => redirectURI,
|
||||||
"scopes" => scopes.scopeString,
|
"scopes" => scopes.scopeString,
|
||||||
|
@ -152,7 +140,7 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
||||||
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
|
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
|
||||||
"client_id" => clientID,
|
"client_id" => clientID,
|
||||||
"client_secret" => clientSecret,
|
"client_secret" => clientSecret,
|
||||||
"grant_type" => "authorization_code",
|
"grant_type" => "authorization_code",
|
||||||
|
@ -167,6 +155,24 @@ public class Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
|
||||||
|
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
||||||
|
run(wellKnown) { result in
|
||||||
|
switch result {
|
||||||
|
case let .failure(error):
|
||||||
|
completion(.failure(error))
|
||||||
|
|
||||||
|
case let .success(wellKnown, _):
|
||||||
|
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||||
|
let components = URLComponents(string: url.href),
|
||||||
|
components.host == self.baseURL.host {
|
||||||
|
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: components.path))
|
||||||
|
self.run(nodeInfo, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Self
|
// MARK: - Self
|
||||||
public static func getSelfAccount() -> Request<Account> {
|
public static func getSelfAccount() -> Request<Account> {
|
||||||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||||
|
@ -211,33 +217,37 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func block(domain: String) -> Request<Empty> {
|
public static func block(domain: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
|
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||||
"domain" => domain
|
"domain" => domain
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unblock(domain: String) -> Request<Empty> {
|
public static func unblock(domain: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
|
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||||
"domain" => domain
|
"domain" => domain
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filters
|
// MARK: - Filters
|
||||||
public static func getFilters() -> Request<[Filter]> {
|
public static func getFiltersV1() -> Request<[FilterV1]> {
|
||||||
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
|
return Request<[FilterV1]>(method: .get, path: "/api/v1/filters")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
public static func createFilterV1(phrase: String, context: [FilterV1.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresIn: TimeInterval? = nil) -> Request<FilterV1> {
|
||||||
return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
|
return Request<FilterV1>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
||||||
"phrase" => phrase,
|
"phrase" => phrase,
|
||||||
"irreversible" => irreversible,
|
"irreversible" => irreversible,
|
||||||
"whole_word" => wholeWord,
|
"whole_word" => wholeWord,
|
||||||
"expires_at" => expiresAt
|
"expires_in" => expiresIn,
|
||||||
] + "context" => context.contextStrings))
|
] + "context" => context.contextStrings))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFilter(id: String) -> Request<Filter> {
|
public static func getFilterV1(id: String) -> Request<FilterV1> {
|
||||||
return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
|
return Request<FilterV1>(method: .get, path: "/api/v1/filters/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getFiltersV2() -> Request<[FilterV2]> {
|
||||||
|
return Request(method: .get, path: "/api/v2/filters")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Follows
|
// MARK: - Follows
|
||||||
|
@ -252,7 +262,11 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func followRemote(acct: String) -> Request<Account> {
|
public static func followRemote(acct: String) -> Request<Account> {
|
||||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
|
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getFollowedHashtags() -> Request<[Hashtag]> {
|
||||||
|
return Request(method: .get, path: "/api/v1/followed_tags")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Lists
|
// MARK: - Lists
|
||||||
|
@ -265,12 +279,12 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createList(title: String) -> Request<List> {
|
public static func createList(title: String) -> Request<List> {
|
||||||
return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
|
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Media
|
// MARK: - Media
|
||||||
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
||||||
return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
|
return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
|
||||||
"description" => description,
|
"description" => description,
|
||||||
"focus" => focus
|
"focus" => focus
|
||||||
], attachment))
|
], attachment))
|
||||||
|
@ -284,9 +298,17 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
public static func getNotifications(excludeTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||||
"exclude_types" => excludeTypes.map { $0.rawValue }
|
"types" => allowedTypes.map { $0.rawValue }
|
||||||
|
)
|
||||||
|
request.range = range
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getNotifications(excludedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||||
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||||
|
"exclude_types" => excludedTypes.map { $0.rawValue }
|
||||||
)
|
)
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
|
@ -301,20 +323,30 @@ public class Client {
|
||||||
return Request<[Report]>(method: .get, path: "/api/v1/reports")
|
return Request<[Report]>(method: .get, path: "/api/v1/reports")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
|
public static func report(
|
||||||
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
|
account: String,
|
||||||
"account_id" => account.id,
|
statuses: [String],
|
||||||
"comment" => comment
|
comment: String,
|
||||||
] + "status_ids" => statuses.map { $0.id }))
|
forward: Bool,
|
||||||
|
category: String,
|
||||||
|
ruleIDs: [String]
|
||||||
|
) -> Request<Report> {
|
||||||
|
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
|
||||||
|
"account_id" => account,
|
||||||
|
"comment" => comment,
|
||||||
|
"forward" => forward,
|
||||||
|
"category" => category,
|
||||||
|
] + "status_ids" => statuses + "rule_ids" => ruleIDs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Search
|
// MARK: - Search
|
||||||
public static func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
|
public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil, following: Bool? = nil) -> Request<SearchResults> {
|
||||||
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
||||||
"q" => query,
|
"q" => query,
|
||||||
"resolve" => resolve,
|
"resolve" => resolve,
|
||||||
"limit" => limit
|
"limit" => limit,
|
||||||
])
|
"following" => following,
|
||||||
|
] + "types" => types?.map { $0.rawValue })
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Statuses
|
// MARK: - Statuses
|
||||||
|
@ -329,16 +361,23 @@ public class Client {
|
||||||
sensitive: Bool? = nil,
|
sensitive: Bool? = nil,
|
||||||
spoilerText: String? = nil,
|
spoilerText: String? = nil,
|
||||||
visibility: Status.Visibility? = nil,
|
visibility: Status.Visibility? = nil,
|
||||||
language: String? = nil) -> Request<Status> {
|
language: String? = nil,
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
|
pollOptions: [String]? = nil,
|
||||||
|
pollExpiresIn: Int? = nil,
|
||||||
|
pollMultiple: Bool? = nil,
|
||||||
|
localOnly: Bool? = nil /* hometown only, not glitch */) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
||||||
"status" => text,
|
"status" => text,
|
||||||
"content_type" => contentType.mimeType,
|
"content_type" => contentType.mimeType,
|
||||||
"in_reply_to_id" => inReplyTo,
|
"in_reply_to_id" => inReplyTo,
|
||||||
"sensitive" => sensitive,
|
"sensitive" => sensitive,
|
||||||
"spoiler_text" => spoilerText,
|
"spoiler_text" => spoilerText,
|
||||||
"visibility" => visibility?.rawValue,
|
"visibility" => visibility?.rawValue,
|
||||||
"language" => language
|
"language" => language,
|
||||||
] + "media_ids" => media?.map { $0.id }))
|
"poll[expires_in]" => pollExpiresIn,
|
||||||
|
"poll[multiple]" => pollMultiple,
|
||||||
|
"local_only" => localOnly,
|
||||||
|
] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timelines
|
// MARK: - Timelines
|
||||||
|
@ -347,22 +386,108 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: Bookmarks
|
// MARK: - Bookmarks
|
||||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Instance
|
||||||
|
public static func getTrendingHashtags(limit: Int? = nil) -> Request<[Hashtag]> {
|
||||||
|
let parameters: [Parameter]
|
||||||
|
if let limit = limit {
|
||||||
|
parameters = ["limit" => limit]
|
||||||
|
} else {
|
||||||
|
parameters = []
|
||||||
|
}
|
||||||
|
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getTrendingStatuses(limit: Int? = nil) -> Request<[Status]> {
|
||||||
|
let parameters: [Parameter]
|
||||||
|
if let limit = limit {
|
||||||
|
parameters = ["limit" => limit]
|
||||||
|
} else {
|
||||||
|
parameters = []
|
||||||
|
}
|
||||||
|
return Request(method: .get, path: "/api/v1/trends/statuses", queryParameters: parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getTrendingLinks(limit: Int? = nil) -> Request<[Card]> {
|
||||||
|
let parameters: [Parameter]
|
||||||
|
if let limit = limit {
|
||||||
|
parameters = ["limit" => limit]
|
||||||
|
} else {
|
||||||
|
parameters = []
|
||||||
|
}
|
||||||
|
return Request(method: .get, path: "/api/v1/trends/links", queryParameters: parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getFeaturedProfiles(local: Bool, order: DirectoryOrder, offset: Int? = nil, limit: Int? = nil) -> Request<[Account]> {
|
||||||
|
var parameters = [
|
||||||
|
"order" => order.rawValue,
|
||||||
|
"local" => local,
|
||||||
|
]
|
||||||
|
if let offset = offset {
|
||||||
|
parameters.append("offset" => offset)
|
||||||
|
}
|
||||||
|
if let limit = limit {
|
||||||
|
parameters.append("limit" => limit)
|
||||||
|
}
|
||||||
|
return Request<[Account]>(method: .get, path: "/api/v1/directory", queryParameters: parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getSuggestions(limit: Int?) -> Request<[Suggestion]> {
|
||||||
|
return Request(method: .get, path: "/api/v2/suggestions", queryParameters: [
|
||||||
|
"limit" => limit,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Client {
|
extension Client {
|
||||||
public enum Error: Swift.Error {
|
public struct Error: LocalizedError {
|
||||||
case unknownError
|
public let requestMethod: Method
|
||||||
|
public let requestEndpoint: Endpoint
|
||||||
|
public let type: ErrorType
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
public static let debug = Error(request: Client.getStatuses(timeline: .home), type: .invalidResponse)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init<ResultType: Decodable>(request: Request<ResultType>, type: ErrorType) {
|
||||||
|
self.requestMethod = request.method
|
||||||
|
self.requestEndpoint = request.endpoint
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
public var localizedDescription: String {
|
||||||
|
switch type {
|
||||||
|
case .networkError(let error):
|
||||||
|
return "Network Error: \(error.localizedDescription)"
|
||||||
|
// todo: support more status codes
|
||||||
|
case .unexpectedStatus(413):
|
||||||
|
return "HTTP 413: Payload Too Large"
|
||||||
|
case .unexpectedStatus(let code):
|
||||||
|
return "HTTP Code \(code)"
|
||||||
|
case .invalidRequest:
|
||||||
|
return "Invalid Request"
|
||||||
|
case .invalidResponse:
|
||||||
|
return "Invalid Response"
|
||||||
|
case .invalidModel(_):
|
||||||
|
return "Invalid Model"
|
||||||
|
case .mastodonError(let code, let error):
|
||||||
|
return "Server Error (\(code)): \(error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public enum ErrorType: LocalizedError {
|
||||||
|
case networkError(Swift.Error)
|
||||||
|
case unexpectedStatus(Int)
|
||||||
case invalidRequest
|
case invalidRequest
|
||||||
case invalidResponse
|
case invalidResponse
|
||||||
case invalidModel
|
case invalidModel(Swift.Error)
|
||||||
case mastodonError(String)
|
case mastodonError(Int, String)
|
||||||
case urlError(URLError)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Account: Decodable {
|
public final class Account: AccountProtocol, Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let username: String
|
public let username: String
|
||||||
public let acct: String
|
public let acct: String
|
||||||
|
@ -20,14 +20,15 @@ public class Account: Decodable {
|
||||||
public let statusesCount: Int
|
public let statusesCount: Int
|
||||||
public let note: String
|
public let note: String
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let avatar: URL
|
// required on mastodon, but optional on gotosocial
|
||||||
public let avatarStatic: URL
|
public let avatar: URL?
|
||||||
public let header: URL
|
public let avatarStatic: URL?
|
||||||
public let headerStatic: URL
|
public let header: URL?
|
||||||
|
public let headerStatic: URL?
|
||||||
public private(set) var emojis: [Emoji]
|
public private(set) var emojis: [Emoji]
|
||||||
public let moved: Bool?
|
public let moved: Bool?
|
||||||
public let movedTo: Account?
|
public let movedTo: Account?
|
||||||
public let fields: [Field]?
|
public let fields: [Field]
|
||||||
public let bot: Bool?
|
public let bot: Bool?
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
public required init(from decoder: Decoder) throws {
|
||||||
|
@ -44,12 +45,17 @@ public class Account: Decodable {
|
||||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||||
self.note = try container.decode(String.self, forKey: .note)
|
self.note = try container.decode(String.self, forKey: .note)
|
||||||
self.url = try container.decode(URL.self, forKey: .url)
|
self.url = try container.decode(URL.self, forKey: .url)
|
||||||
self.avatar = try container.decode(URL.self, forKey: .avatar)
|
self.avatar = try? container.decode(URL.self, forKey: .avatar)
|
||||||
self.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic)
|
self.avatarStatic = try? container.decode(URL.self, forKey: .avatarStatic)
|
||||||
self.header = try container.decode(URL.self, forKey: .header)
|
self.header = try? container.decode(URL.self, forKey: .header)
|
||||||
self.headerStatic = try container.decode(URL.self, forKey: .url)
|
self.headerStatic = try? container.decode(URL.self, forKey: .headerStatic)
|
||||||
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
|
// even up-to-date pixelfed instances sometimes lack this, for reasons unclear
|
||||||
self.fields = try? container.decode([Field].self, forKey: .fields)
|
if let emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) {
|
||||||
|
self.emojis = emojis
|
||||||
|
} else {
|
||||||
|
self.emojis = []
|
||||||
|
}
|
||||||
|
self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? []
|
||||||
self.bot = try? container.decode(Bool.self, forKey: .bot)
|
self.bot = try? container.decode(Bool.self, forKey: .bot)
|
||||||
|
|
||||||
if let moved = try? container.decode(Bool.self, forKey: .moved) {
|
if let moved = try? container.decode(Bool.self, forKey: .moved) {
|
||||||
|
@ -76,23 +82,24 @@ public class Account: Decodable {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFollowers(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getFollowers(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/followers")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/followers")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFollowing(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getFollowing(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/following")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/following")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil) -> Request<[Status]> {
|
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||||
"only_media" => onlyMedia,
|
"only_media" => onlyMedia,
|
||||||
"pinned" => pinned,
|
"pinned" => pinned,
|
||||||
"exclude_replies" => excludeReplies
|
"exclude_replies" => excludeReplies,
|
||||||
|
"exclude_reblogs" => excludeReblogs,
|
||||||
])
|
])
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
|
@ -106,22 +113,22 @@ public class Account: Decodable {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func block(_ account: Account) -> Request<Relationship> {
|
public static func block(_ accountID: String) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/block")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/block")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unblock(_ account: Account) -> Request<Relationship> {
|
public static func unblock(_ accountID: String) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unblock")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unblock")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
|
public static func mute(_ accountID: String, notifications: Bool? = nil) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: .parameters([
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/mute", body: ParametersBody([
|
||||||
"notifications" => notifications
|
"notifications" => notifications
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unmute(_ account: Account) -> Request<Relationship> {
|
public static func unmute(_ accountID: String) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unmute")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unmute")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getLists(_ account: Account) -> Request<[List]> {
|
public static func getLists(_ account: Account) -> Request<[List]> {
|
||||||
|
@ -161,5 +168,12 @@ extension Account {
|
||||||
public struct Field: Codable {
|
public struct Field: Codable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let value: String
|
public let value: String
|
||||||
|
public let verifiedAt: Date?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case value
|
||||||
|
case verifiedAt = "verified_at"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -8,18 +8,18 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Attachment: Decodable {
|
public class Attachment: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Kind
|
public let kind: Kind
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let remoteURL: URL?
|
public let remoteURL: URL?
|
||||||
public let previewURL: URL
|
public let previewURL: URL?
|
||||||
public let textURL: URL?
|
|
||||||
public let meta: Metadata?
|
public let meta: Metadata?
|
||||||
public let description: String?
|
public let description: String?
|
||||||
|
public let blurHash: String?
|
||||||
|
|
||||||
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> {
|
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> {
|
||||||
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: .formData([
|
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: FormDataBody([
|
||||||
"description" => (description ?? attachment.description),
|
"description" => (description ?? attachment.description),
|
||||||
"focus" => focus
|
"focus" => focus
|
||||||
], nil))
|
], nil))
|
||||||
|
@ -29,20 +29,12 @@ public class Attachment: Decodable {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||||
self.url = URL(lenient: try container.decode(String.self, forKey: .url))!
|
self.url = try container.decode(URL.self, forKey: .url)
|
||||||
if let remote = try? container.decode(String.self, forKey: .remoteURL) {
|
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL)
|
||||||
self.remoteURL = URL(lenient: remote.replacingOccurrences(of: " ", with: "%20"))
|
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL)
|
||||||
} else {
|
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
|
||||||
self.remoteURL = nil
|
self.description = try? container.decode(String?.self, forKey: .description)
|
||||||
}
|
self.blurHash = try? container.decode(String?.self, forKey: .blurHash)
|
||||||
self.previewURL = URL(lenient: try container.decode(String.self, forKey: .previewURL).replacingOccurrences(of: " ", with: "%20"))!
|
|
||||||
if let text = try? container.decode(String.self, forKey: .textURL) {
|
|
||||||
self.textURL = URL(lenient: text.replacingOccurrences(of: " ", with: "%20"))
|
|
||||||
} else {
|
|
||||||
self.textURL = nil
|
|
||||||
}
|
|
||||||
self.meta = try? container.decode(Metadata.self, forKey: .meta)
|
|
||||||
self.description = try? container.decode(String.self, forKey: .description)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -51,24 +43,41 @@ public class Attachment: Decodable {
|
||||||
case url
|
case url
|
||||||
case remoteURL = "remote_url"
|
case remoteURL = "remote_url"
|
||||||
case previewURL = "preview_url"
|
case previewURL = "preview_url"
|
||||||
case textURL = "text_url"
|
|
||||||
case meta
|
case meta
|
||||||
case description
|
case description
|
||||||
|
case blurHash = "blurhash"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Attachment {
|
extension Attachment {
|
||||||
public enum Kind: String, Decodable {
|
public enum Kind: String, Codable {
|
||||||
case image
|
case image
|
||||||
case video
|
case video
|
||||||
case gifv
|
case gifv
|
||||||
case audio
|
case audio
|
||||||
case unknown
|
case unknown
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
switch try container.decode(String.self) {
|
||||||
|
// gotosocial uses "gif" for gif images
|
||||||
|
case "image", "gif":
|
||||||
|
self = .image
|
||||||
|
case "video":
|
||||||
|
self = .video
|
||||||
|
case "gifv":
|
||||||
|
self = .gifv
|
||||||
|
case "audio":
|
||||||
|
self = .audio
|
||||||
|
default:
|
||||||
|
self = .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Attachment {
|
extension Attachment {
|
||||||
public class Metadata: Decodable {
|
public struct Metadata: Codable {
|
||||||
public let length: String?
|
public let length: String?
|
||||||
public let duration: Float?
|
public let duration: Float?
|
||||||
public let audioEncoding: String?
|
public let audioEncoding: String?
|
||||||
|
@ -99,7 +108,7 @@ extension Attachment {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImageMetadata: Decodable {
|
public struct ImageMetadata: Codable {
|
||||||
public let width: Int?
|
public let width: Int?
|
||||||
public let height: Int?
|
public let height: Int?
|
||||||
public let size: String?
|
public let size: String?
|
||||||
|
@ -113,14 +122,3 @@ extension Attachment {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate extension URL {
|
|
||||||
private static let allowedChars = CharacterSet.urlHostAllowed.union(.urlPathAllowed).union(.urlQueryAllowed)
|
|
||||||
|
|
||||||
init?(lenient string: String) {
|
|
||||||
guard let escaped = string.addingPercentEncoding(withAllowedCharacters: URL.allowedChars) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.init(string: escaped)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
//
|
||||||
|
// Card.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
|
public class Card: Codable {
|
||||||
|
public let url: WebURL
|
||||||
|
public let title: String
|
||||||
|
public let description: String
|
||||||
|
public let image: WebURL?
|
||||||
|
public let kind: Kind
|
||||||
|
public let authorName: String?
|
||||||
|
public let authorURL: WebURL?
|
||||||
|
public let providerName: String?
|
||||||
|
public let providerURL: WebURL?
|
||||||
|
public let html: String?
|
||||||
|
public let width: Int?
|
||||||
|
public let height: Int?
|
||||||
|
public let blurhash: String?
|
||||||
|
/// Only present when returned from the trending links endpoint
|
||||||
|
public let history: [History]?
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
|
self.title = try container.decode(String.self, forKey: .title)
|
||||||
|
self.description = try container.decode(String.self, forKey: .description)
|
||||||
|
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||||
|
self.image = try? container.decodeIfPresent(WebURL.self, forKey: .image)
|
||||||
|
self.authorName = try? container.decodeIfPresent(String.self, forKey: .authorName)
|
||||||
|
self.authorURL = try? container.decodeIfPresent(WebURL.self, forKey: .authorURL)
|
||||||
|
self.providerName = try? container.decodeIfPresent(String.self, forKey: .providerName)
|
||||||
|
self.providerURL = try? container.decodeIfPresent(WebURL.self, forKey: .providerURL)
|
||||||
|
self.html = try? container.decodeIfPresent(String.self, forKey: .html)
|
||||||
|
self.width = try? container.decodeIfPresent(Int.self, forKey: .width)
|
||||||
|
self.height = try? container.decodeIfPresent(Int.self, forKey: .height)
|
||||||
|
self.blurhash = try? container.decodeIfPresent(String.self, forKey: .blurhash)
|
||||||
|
self.history = try? container.decodeIfPresent([History].self, forKey: .history)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
try container.encode(url, forKey: .url)
|
||||||
|
try container.encode(title, forKey: .title)
|
||||||
|
try container.encode(description, forKey: .description)
|
||||||
|
try container.encode(kind, forKey: .kind)
|
||||||
|
try container.encode(image, forKey: .image)
|
||||||
|
try container.encode(blurhash, forKey: .blurhash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case url
|
||||||
|
case title
|
||||||
|
case description
|
||||||
|
case image
|
||||||
|
case kind = "type"
|
||||||
|
case authorName = "author_name"
|
||||||
|
case authorURL = "author_url"
|
||||||
|
case providerName = "provider_name"
|
||||||
|
case providerURL = "provider_url"
|
||||||
|
case html
|
||||||
|
case width
|
||||||
|
case height
|
||||||
|
case blurhash
|
||||||
|
case history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Card {
|
||||||
|
public enum Kind: String, Codable {
|
||||||
|
case link
|
||||||
|
case photo
|
||||||
|
case video
|
||||||
|
case rich
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
//
|
||||||
|
// DirectoryOrder.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 2/6/21.
|
||||||
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum DirectoryOrder: String, CaseIterable {
|
||||||
|
case active
|
||||||
|
case new
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
//
|
||||||
|
// Emoji.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/8/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
|
public class Emoji: Codable {
|
||||||
|
public let shortcode: String
|
||||||
|
// these shouldn't need to be WebURLs as they're not external resources,
|
||||||
|
// but some instances (pleroma?) has emoji urls that Foundation considers malformed so we use WebURL to be more lenient
|
||||||
|
public let url: WebURL
|
||||||
|
public let staticURL: WebURL
|
||||||
|
public let visibleInPicker: Bool
|
||||||
|
public let category: String?
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
self.shortcode = try container.decode(String.self, forKey: .shortcode)
|
||||||
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
|
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
|
||||||
|
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
|
||||||
|
self.category = try container.decodeIfPresent(String.self, forKey: .category)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case shortcode
|
||||||
|
case url
|
||||||
|
case staticURL = "static_url"
|
||||||
|
case visibleInPicker = "visible_in_picker"
|
||||||
|
case category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Emoji: CustomDebugStringConvertible {
|
||||||
|
public var debugDescription: String {
|
||||||
|
return ":\(shortcode):"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Emoji: Equatable {
|
||||||
|
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
|
||||||
|
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// Filter.swift
|
// FilterV1.swift
|
||||||
// Pachyderm
|
// Pachyderm
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/9/18.
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Filter: Decodable {
|
public struct FilterV1: Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let phrase: String
|
public let phrase: String
|
||||||
private let context: [String]
|
private let context: [String]
|
||||||
|
@ -22,17 +22,16 @@ public class Filter: Decodable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
public static func update(_ filterID: String, phrase: String, context: [Context], irreversible: Bool, wholeWord: Bool, expiresIn: TimeInterval?) -> Request<FilterV1> {
|
||||||
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([
|
return Request<FilterV1>(method: .put, path: "/api/v1/filters/\(filterID)", body: ParametersBody([
|
||||||
"phrase" => (phrase ?? filter.phrase),
|
"phrase" => phrase,
|
||||||
"irreversible" => (irreversible ?? filter.irreversible),
|
"whole_word" => wholeWord,
|
||||||
"whole_word" => (wholeWord ?? filter.wholeWord),
|
"expires_in" => expiresIn,
|
||||||
"expires_at" => (expiresAt ?? filter.expiresAt)
|
] + "context" => context.contextStrings))
|
||||||
] + "context" => (context?.contextStrings ?? filter.context)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ filter: Filter) -> Request<Empty> {
|
public static func delete(_ filterID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filter.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filterID)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -45,16 +44,17 @@ public class Filter: Decodable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Filter {
|
extension FilterV1 {
|
||||||
public enum Context: String, Decodable {
|
public enum Context: String, Decodable, CaseIterable {
|
||||||
case home
|
case home
|
||||||
case notifications
|
case notifications
|
||||||
case `public`
|
case `public`
|
||||||
case thread
|
case thread
|
||||||
|
case account
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == Filter.Context {
|
extension Array where Element == FilterV1.Context {
|
||||||
var contextStrings: [String] {
|
var contextStrings: [String] {
|
||||||
return map { $0.rawValue }
|
return map { $0.rawValue }
|
||||||
}
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
//
|
||||||
|
// FilterV2.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/2/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct FilterV2: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let title: String
|
||||||
|
public let context: [FilterV1.Context]
|
||||||
|
public let expiresAt: Date?
|
||||||
|
public let action: Action
|
||||||
|
public let keywords: [Keyword]
|
||||||
|
|
||||||
|
public static func update(
|
||||||
|
_ filterID: String,
|
||||||
|
title: String,
|
||||||
|
context: [FilterV1.Context],
|
||||||
|
expiresIn: TimeInterval?,
|
||||||
|
action: Action,
|
||||||
|
keywords keywordUpdates: [KeywordUpdate]
|
||||||
|
) -> Request<FilterV2> {
|
||||||
|
var keywordsParams = [Parameter]()
|
||||||
|
for (index, update) in keywordUpdates.enumerated() {
|
||||||
|
switch update {
|
||||||
|
case .update(id: let id, keyword: let keyword, wholeWord: let wholeWord):
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][id]" => id)
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword)
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord)
|
||||||
|
case .add(keyword: let keyword, wholeWord: let wholeWord):
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword)
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord)
|
||||||
|
case .destroy(id: let id):
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][id]" => id)
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][_destroy]" => true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Request(method: .put, path: "/api/v2/filters/\(filterID)", body: ParametersBody([
|
||||||
|
"title" => title,
|
||||||
|
"expires_in" => expiresIn,
|
||||||
|
"filter_action" => action.rawValue,
|
||||||
|
] + "context" => context.contextStrings + keywordsParams))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func create(
|
||||||
|
title: String,
|
||||||
|
context: [FilterV1.Context],
|
||||||
|
expiresIn: TimeInterval?,
|
||||||
|
action: Action,
|
||||||
|
keywords keywordUpdates: [KeywordUpdate]
|
||||||
|
) -> Request<FilterV2> {
|
||||||
|
var keywordsParams = [Parameter]()
|
||||||
|
for (index, update) in keywordUpdates.enumerated() {
|
||||||
|
switch update {
|
||||||
|
case .add(keyword: let keyword, wholeWord: let wholeWord):
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword)
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord)
|
||||||
|
default:
|
||||||
|
fatalError("can only add keywords when creating filter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Request(method: .post, path: "/api/v2/filters", body: ParametersBody([
|
||||||
|
"title" => title,
|
||||||
|
"expires_in" => expiresIn,
|
||||||
|
"filter_action" => action.rawValue,
|
||||||
|
] + "context" => context.contextStrings + keywordsParams))
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case title
|
||||||
|
case context
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
case action = "filter_action"
|
||||||
|
case keywords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterV2 {
|
||||||
|
public enum Action: String, Decodable, Hashable, CaseIterable {
|
||||||
|
case warn
|
||||||
|
case hide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterV2 {
|
||||||
|
public struct Keyword: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let keyword: String
|
||||||
|
public let wholeWord: Bool
|
||||||
|
|
||||||
|
public init(id: String, keyword: String, wholeWord: Bool) {
|
||||||
|
self.id = id
|
||||||
|
self.keyword = keyword
|
||||||
|
self.wholeWord = wholeWord
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case keyword
|
||||||
|
case wholeWord = "whole_word"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterV2 {
|
||||||
|
public enum KeywordUpdate {
|
||||||
|
case update(id: String, keyword: String, wholeWord: Bool)
|
||||||
|
case add(keyword: String, wholeWord: Bool)
|
||||||
|
case destroy(id: String)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
//
|
||||||
|
// Hashtag.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
import WebURLFoundationExtras
|
||||||
|
|
||||||
|
public class Hashtag: Codable {
|
||||||
|
public let name: String
|
||||||
|
public let url: WebURL
|
||||||
|
/// Only present when returned from the trending hashtags endpoint
|
||||||
|
public let history: [History]?
|
||||||
|
/// Only present on Mastodon >= 4 and when logged in
|
||||||
|
public let following: Bool?
|
||||||
|
|
||||||
|
public init(name: String, url: URL) {
|
||||||
|
self.name = name
|
||||||
|
self.url = WebURL(url)!
|
||||||
|
self.history = nil
|
||||||
|
self.following = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.name = try container.decode(String.self, forKey: .name)
|
||||||
|
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
|
||||||
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
|
self.history = try container.decodeIfPresent([History].self, forKey: .history)
|
||||||
|
self.following = try container.decodeIfPresent(Bool.self, forKey: .following)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(url, forKey: .url)
|
||||||
|
try container.encodeIfPresent(history, forKey: .history)
|
||||||
|
try container.encodeIfPresent(following, forKey: .following)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func follow(name: String) -> Request<Hashtag> {
|
||||||
|
return Request(method: .post, path: "/api/v1/tags/\(name)/follow")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func unfollow(name: String) -> Request<Hashtag> {
|
||||||
|
return Request(method: .post, path: "/api/v1/tags/\(name)/unfollow")
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case url
|
||||||
|
case history
|
||||||
|
case following
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Hashtag: Equatable, Hashable {
|
||||||
|
public static func ==(lhs: Hashtag, rhs: Hashtag) -> Bool {
|
||||||
|
return lhs.name == rhs.name
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(url)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
//
|
||||||
|
// History.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/2/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class History: Codable {
|
||||||
|
public let day: Date
|
||||||
|
public let uses: Int
|
||||||
|
public let accounts: Int
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
if let day = try? container.decode(Date.self, forKey: .day) {
|
||||||
|
self.day = day
|
||||||
|
} else if let unixTimestamp = try? container.decode(Double.self, forKey: .day) {
|
||||||
|
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
||||||
|
} else if let str = try? container.decode(String.self, forKey: .day),
|
||||||
|
let unixTimestamp = Double(str) {
|
||||||
|
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
||||||
|
} else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .day, in: container, debugDescription: "day must be either date or UNIX timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let uses = try? container.decode(Int.self, forKey: .uses) {
|
||||||
|
self.uses = uses
|
||||||
|
} else if let str = try? container.decode(String.self, forKey: .uses),
|
||||||
|
let uses = Int(str) {
|
||||||
|
self.uses = uses
|
||||||
|
} else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .uses, in: container, debugDescription: "uses must either be int or string containing int")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let accounts = try? container.decode(Int.self, forKey: .accounts) {
|
||||||
|
self.accounts = accounts
|
||||||
|
} else if let str = try? container.decode(String.self, forKey: .accounts),
|
||||||
|
let accounts = Int(str) {
|
||||||
|
self.accounts = accounts
|
||||||
|
} else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .accounts, in: container, debugDescription: "accounts must either be int or string containing int")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case day
|
||||||
|
case uses
|
||||||
|
case accounts
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,179 @@
|
||||||
|
//
|
||||||
|
// Instance.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class Instance: Decodable {
|
||||||
|
public let uri: String
|
||||||
|
public let title: String
|
||||||
|
public let description: String
|
||||||
|
public let shortDescription: String?
|
||||||
|
public let email: String?
|
||||||
|
public let version: String
|
||||||
|
public let urls: [String: URL]
|
||||||
|
public let thumbnail: URL?
|
||||||
|
public let languages: [String]?
|
||||||
|
public let stats: Stats?
|
||||||
|
public let configuration: Configuration?
|
||||||
|
public let rules: [Rule]?
|
||||||
|
|
||||||
|
// pleroma doesn't currently implement these
|
||||||
|
public let contactAccount: Account?
|
||||||
|
|
||||||
|
// superseded by mastodon's configuration.statuses.max_characters, still used by older instances & pleroma
|
||||||
|
let maxTootCharacters: Int?
|
||||||
|
let pollLimits: PollsConfiguration?
|
||||||
|
|
||||||
|
public var maxStatusCharacters: Int? {
|
||||||
|
configuration?.statuses.maxCharacters ?? maxTootCharacters
|
||||||
|
}
|
||||||
|
public var pollsConfiguration: PollsConfiguration? {
|
||||||
|
configuration?.polls ?? pollLimits
|
||||||
|
}
|
||||||
|
|
||||||
|
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.uri = try container.decode(String.self, forKey: .uri)
|
||||||
|
self.title = try container.decode(String.self, forKey: .title)
|
||||||
|
self.description = try container.decode(String.self, forKey: .description)
|
||||||
|
self.shortDescription = try container.decodeIfPresent(String.self, forKey: .shortDescription)
|
||||||
|
self.email = try container.decodeIfPresent(String.self, forKey: .email)
|
||||||
|
self.version = try container.decode(String.self, forKey: .version)
|
||||||
|
if let urls = try? container.decodeIfPresent([String: URL].self, forKey: .urls) {
|
||||||
|
self.urls = urls
|
||||||
|
} else {
|
||||||
|
self.urls = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.languages = try? container.decodeIfPresent([String].self, forKey: .languages)
|
||||||
|
self.contactAccount = try? container.decodeIfPresent(Account.self, forKey: .contactAccount)
|
||||||
|
|
||||||
|
self.stats = try? container.decodeIfPresent(Stats.self, forKey: .stats)
|
||||||
|
self.thumbnail = try? container.decodeIfPresent(URL.self, forKey: .thumbnail)
|
||||||
|
|
||||||
|
self.configuration = try? container.decodeIfPresent(Configuration.self, forKey: .configuration)
|
||||||
|
self.rules = try? container.decodeIfPresent([Rule].self, forKey: .rules)
|
||||||
|
|
||||||
|
if let maxTootCharacters = try? container.decodeIfPresent(Int.self, forKey: .maxTootCharacters) {
|
||||||
|
self.maxTootCharacters = maxTootCharacters
|
||||||
|
} else if let str = try? container.decodeIfPresent(String.self, forKey: .maxTootCharacters),
|
||||||
|
let maxTootCharacters = Int(str, radix: 10) {
|
||||||
|
self.maxTootCharacters = maxTootCharacters
|
||||||
|
} else {
|
||||||
|
self.maxTootCharacters = nil
|
||||||
|
}
|
||||||
|
self.pollLimits = try? container.decodeIfPresent(PollsConfiguration.self, forKey: .pollLimits)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case uri
|
||||||
|
case title
|
||||||
|
case description
|
||||||
|
case shortDescription = "short_description"
|
||||||
|
case email
|
||||||
|
case version
|
||||||
|
case urls
|
||||||
|
case thumbnail
|
||||||
|
case languages
|
||||||
|
case stats
|
||||||
|
case configuration
|
||||||
|
case contactAccount = "contact_account"
|
||||||
|
case rules
|
||||||
|
|
||||||
|
case maxTootCharacters = "max_toot_chars"
|
||||||
|
case pollLimits = "poll_limits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Instance {
|
||||||
|
public struct Stats: Decodable {
|
||||||
|
public let domainCount: Int?
|
||||||
|
public let statusCount: Int?
|
||||||
|
public let userCount: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case domainCount = "domain_count"
|
||||||
|
case statusCount = "status_count"
|
||||||
|
case userCount = "user_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Instance {
|
||||||
|
public struct Configuration: Decodable {
|
||||||
|
public let statuses: StatusesConfiguration
|
||||||
|
public let mediaAttachments: MediaAttachmentsConfiguration
|
||||||
|
/// Use Instance.pollsConfiguration to support older instance that don't have this nested
|
||||||
|
let polls: PollsConfiguration
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case statuses
|
||||||
|
case mediaAttachments = "media_attachments"
|
||||||
|
case polls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Instance {
|
||||||
|
public struct StatusesConfiguration: Decodable {
|
||||||
|
public let maxCharacters: Int
|
||||||
|
public let maxMediaAttachments: Int
|
||||||
|
public let charactersReservedPerURL: Int
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case maxCharacters = "max_characters"
|
||||||
|
case maxMediaAttachments = "max_media_attachments"
|
||||||
|
case charactersReservedPerURL = "characters_reserved_per_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Instance {
|
||||||
|
public struct MediaAttachmentsConfiguration: Decodable {
|
||||||
|
public let supportedMIMETypes: [String]
|
||||||
|
public let imageSizeLimit: Int
|
||||||
|
public let imageMatrixLimit: Int
|
||||||
|
public let videoSizeLimit: Int
|
||||||
|
public let videoFrameRateLimit: Int
|
||||||
|
public let videoMatrixLimit: Int
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case supportedMIMETypes = "supported_mime_types"
|
||||||
|
case imageSizeLimit = "image_size_limit"
|
||||||
|
case imageMatrixLimit = "image_matrix_limit"
|
||||||
|
case videoSizeLimit = "video_size_limit"
|
||||||
|
case videoFrameRateLimit = "video_frame_rate_limit"
|
||||||
|
case videoMatrixLimit = "video_matrix_limit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Instance {
|
||||||
|
public struct PollsConfiguration: Decodable {
|
||||||
|
public let maxOptions: Int
|
||||||
|
public let maxCharactersPerOption: Int
|
||||||
|
public let minExpiration: TimeInterval
|
||||||
|
public let maxExpiration: TimeInterval
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case maxOptions = "max_options"
|
||||||
|
case maxCharactersPerOption = "max_characters_per_option"
|
||||||
|
case minExpiration = "min_expiration"
|
||||||
|
case maxExpiration = "max_expiration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Instance {
|
||||||
|
public struct Rule: Decodable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let text: String
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class List: Decodable {
|
public class List: Decodable, Equatable, Hashable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let title: String
|
public let title: String
|
||||||
|
|
||||||
|
@ -16,6 +16,15 @@ public class List: Decodable {
|
||||||
return .list(id: id)
|
return .list(id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||||
|
return lhs.id == rhs.id && lhs.title == rhs.title
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
hasher.combine(title)
|
||||||
|
}
|
||||||
|
|
||||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
||||||
request.range = range
|
request.range = range
|
||||||
|
@ -23,7 +32,7 @@ public class List: Decodable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ list: List, title: String) -> Request<List> {
|
public static func update(_ list: List, title: String) -> Request<List> {
|
||||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: .parameters(["title" => title]))
|
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ list: List) -> Request<Empty> {
|
public static func delete(_ list: List) -> Request<Empty> {
|
||||||
|
@ -31,13 +40,13 @@ public class List: Decodable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||||
"account_ids" => accountIDs
|
"account_ids" => accountIDs
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||||
"account_ids" => accountIDs
|
"account_ids" => accountIDs
|
||||||
))
|
))
|
||||||
}
|
}
|
|
@ -10,9 +10,10 @@ import Foundation
|
||||||
|
|
||||||
public class LoginSettings: Decodable {
|
public class LoginSettings: Decodable {
|
||||||
public let accessToken: String
|
public let accessToken: String
|
||||||
private let scope: String
|
private let scope: String?
|
||||||
|
|
||||||
public var scopes: [Scope] {
|
public var scopes: [Scope] {
|
||||||
|
guard let scope = scope else { return [] }
|
||||||
return scope.components(separatedBy: .whitespaces).compactMap(Scope.init)
|
return scope.components(separatedBy: .whitespaces).compactMap(Scope.init)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// Mention.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
|
public struct Mention: Codable {
|
||||||
|
public let url: WebURL
|
||||||
|
public let username: String
|
||||||
|
public let acct: String
|
||||||
|
/// The instance-local ID of the user being mentioned.
|
||||||
|
public let id: String
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.username = try container.decode(String.self, forKey: .username)
|
||||||
|
self.acct = try container.decode(String.self, forKey: .acct)
|
||||||
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case url
|
||||||
|
case username
|
||||||
|
case acct
|
||||||
|
case id
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
//
|
||||||
|
// NodeInfo.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/22/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct NodeInfo: Decodable {
|
||||||
|
public let version: String
|
||||||
|
public let software: Software
|
||||||
|
|
||||||
|
public struct Software: Decodable {
|
||||||
|
public let name: String
|
||||||
|
public let version: String
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,8 +15,26 @@ public class Notification: Decodable {
|
||||||
public let account: Account
|
public let account: Account
|
||||||
public let status: Status?
|
public let status: Status?
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
|
if let kind = try? container.decode(Kind.self, forKey: .kind) {
|
||||||
|
self.kind = kind
|
||||||
|
} else if let s = try? container.decode(String.self, forKey: .kind),
|
||||||
|
s == "status" {
|
||||||
|
// represent notifications of other people posting as regular mentions for now
|
||||||
|
self.kind = .mention
|
||||||
|
} else {
|
||||||
|
self.kind = .unknown
|
||||||
|
}
|
||||||
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
|
self.account = try container.decode(Account.self, forKey: .account)
|
||||||
|
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||||
|
}
|
||||||
|
|
||||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([
|
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
|
||||||
"id" => notificationID
|
"id" => notificationID
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
@ -37,6 +55,9 @@ extension Notification {
|
||||||
case favourite
|
case favourite
|
||||||
case follow
|
case follow
|
||||||
case followRequest = "follow_request"
|
case followRequest = "follow_request"
|
||||||
|
case poll
|
||||||
|
case update
|
||||||
|
case unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
//
|
||||||
|
// Poll.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/25/21.
|
||||||
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public final class Poll: Codable {
|
||||||
|
public let id: String
|
||||||
|
public let expiresAt: Date?
|
||||||
|
public let expired: Bool
|
||||||
|
public let multiple: Bool
|
||||||
|
public let votesCount: Int
|
||||||
|
public let votersCount: Int?
|
||||||
|
public let voted: Bool?
|
||||||
|
public let ownVotes: [Int]?
|
||||||
|
public let options: [Option]
|
||||||
|
public let emojis: [Emoji]
|
||||||
|
|
||||||
|
public var effectiveExpired: Bool {
|
||||||
|
expired || (expiresAt != nil && expiresAt! < Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> {
|
||||||
|
return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
case expired
|
||||||
|
case multiple
|
||||||
|
case votesCount = "votes_count"
|
||||||
|
case votersCount = "voters_count"
|
||||||
|
case voted
|
||||||
|
case ownVotes = "own_votes"
|
||||||
|
case options
|
||||||
|
case emojis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Poll {
|
||||||
|
public final class Option: Codable {
|
||||||
|
public let title: String
|
||||||
|
public let votesCount: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case title
|
||||||
|
case votesCount = "votes_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// AccountProtocol.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/11/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol AccountProtocol {
|
||||||
|
associatedtype Account: AccountProtocol
|
||||||
|
|
||||||
|
var id: String { get }
|
||||||
|
var username: String { get }
|
||||||
|
var acct: String { get }
|
||||||
|
var displayName: String { get }
|
||||||
|
var locked: Bool { get }
|
||||||
|
var createdAt: Date { get }
|
||||||
|
var followersCount: Int { get }
|
||||||
|
var followingCount: Int { get }
|
||||||
|
var statusesCount: Int { get }
|
||||||
|
var note: String { get }
|
||||||
|
var url: URL { get }
|
||||||
|
var avatar: URL? { get }
|
||||||
|
var header: URL? { get }
|
||||||
|
var moved: Bool? { get }
|
||||||
|
var bot: Bool? { get }
|
||||||
|
|
||||||
|
var movedTo: Account? { get }
|
||||||
|
var emojis: [Emoji] { get }
|
||||||
|
var fields: [Pachyderm.Account.Field] { get }
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// StatusProtocol.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/11/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol StatusProtocol {
|
||||||
|
associatedtype Status: StatusProtocol
|
||||||
|
associatedtype Account: AccountProtocol
|
||||||
|
|
||||||
|
var id: String { get }
|
||||||
|
var uri: String { get }
|
||||||
|
var inReplyToID: String? { get }
|
||||||
|
var inReplyToAccountID: String? { get }
|
||||||
|
var content: String { get }
|
||||||
|
var createdAt: Date { get }
|
||||||
|
var reblogsCount: Int { get }
|
||||||
|
var favouritesCount: Int { get }
|
||||||
|
// pachyderm impl wants Bool, StatusMO wants optional. not sure how to resolve it, but we don't need this currently
|
||||||
|
// var reblogged: Bool { get }
|
||||||
|
// var favourited: Bool { get }
|
||||||
|
var sensitive: Bool { get }
|
||||||
|
var spoilerText: String { get }
|
||||||
|
var visibility: Pachyderm.Status.Visibility { get }
|
||||||
|
var applicationName: String? { get }
|
||||||
|
var pinned: Bool? { get }
|
||||||
|
var bookmarked: Bool? { get }
|
||||||
|
|
||||||
|
var account: Account { get }
|
||||||
|
var reblog: Status? { get }
|
||||||
|
var attachments: [Attachment] { get }
|
||||||
|
var emojis: [Emoji] { get }
|
||||||
|
var hashtags: [Hashtag] { get }
|
||||||
|
var mentions: [Mention] { get }
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
//
|
||||||
|
// RegisteredApplication.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class RegisteredApplication: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let clientID: String
|
||||||
|
public let clientSecret: String
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
// Pixelfed API returns id/client_id as numbers instead of strings
|
||||||
|
func decodeStringOrInt(key: CodingKeys) throws -> String {
|
||||||
|
if let str = try? container.decode(String.self, forKey: key) {
|
||||||
|
return str
|
||||||
|
} else if let int = try? container.decode(Int.self, forKey: key) {
|
||||||
|
return int.description
|
||||||
|
} else {
|
||||||
|
throw DecodingError.typeMismatch(String.self, DecodingError.Context(codingPath: container.codingPath + [CodingKeys.id], debugDescription: ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.id = try decodeStringOrInt(key: .id)
|
||||||
|
self.clientID = try decodeStringOrInt(key: .clientID)
|
||||||
|
self.clientSecret = try container.decode(String.self, forKey: .clientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case clientID = "client_id"
|
||||||
|
case clientSecret = "client_secret"
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ public class Relationship: Decodable {
|
||||||
public let followRequested: Bool
|
public let followRequested: Bool
|
||||||
public let domainBlocking: Bool
|
public let domainBlocking: Bool
|
||||||
public let showingReblogs: Bool
|
public let showingReblogs: Bool
|
||||||
|
public let endorsed: Bool?
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
|
@ -29,5 +30,6 @@ public class Relationship: Decodable {
|
||||||
case followRequested = "requested"
|
case followRequested = "requested"
|
||||||
case domainBlocking = "domain_blocking"
|
case domainBlocking = "domain_blocking"
|
||||||
case showingReblogs = "showing_reblogs"
|
case showingReblogs = "showing_reblogs"
|
||||||
|
case endorsed
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
//
|
||||||
|
// SearchResultType.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 10/11/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SearchResultType: String {
|
||||||
|
case accounts
|
||||||
|
case hashtags
|
||||||
|
case statuses
|
||||||
|
}
|
|
@ -7,11 +7,12 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
public class Status: Decodable {
|
public final class Status: StatusProtocol, Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let uri: String
|
public let uri: String
|
||||||
public let url: URL?
|
public let url: WebURL?
|
||||||
public let account: Account
|
public let account: Account
|
||||||
public let inReplyToID: String?
|
public let inReplyToID: String?
|
||||||
public let inReplyToAccountID: String?
|
public let inReplyToAccountID: String?
|
||||||
|
@ -36,69 +37,80 @@ public class Status: Decodable {
|
||||||
public let language: String?
|
public let language: String?
|
||||||
public let pinned: Bool?
|
public let pinned: Bool?
|
||||||
public let bookmarked: Bool?
|
public let bookmarked: Bool?
|
||||||
|
public let card: Card?
|
||||||
|
public let poll: Poll?
|
||||||
|
// Hometown, Glitch only
|
||||||
|
public let localOnly: Bool?
|
||||||
|
|
||||||
public static func getContext(_ status: Status) -> Request<ConversationContext> {
|
public var applicationName: String? { application?.name }
|
||||||
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
|
|
||||||
|
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||||
|
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getCard(_ status: Status) -> Request<Card> {
|
public static func getCard(_ status: Status) -> Request<Card> {
|
||||||
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
|
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ status: Status) -> Request<Empty> {
|
public static func delete(_ statusID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func reblog(_ status: Status) -> Request<Status> {
|
public static func reblog(_ statusID: String, visibility: Visibility? = nil) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog")
|
var params: [Parameter] = []
|
||||||
|
if let visibility {
|
||||||
|
assert([.public, .unlisted, .private].contains(visibility))
|
||||||
|
params.append("visibility" => visibility.rawValue)
|
||||||
|
}
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog", queryParameters: params)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unreblog(_ status: Status) -> Request<Status> {
|
public static func unreblog(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func favourite(_ status: Status) -> Request<Status> {
|
public static func favourite(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unfavourite(_ status: Status) -> Request<Status> {
|
public static func unfavourite(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func pin(_ status: Status) -> Request<Status> {
|
public static func pin(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/pin")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/pin")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unpin(_ status: Status) -> Request<Status> {
|
public static func unpin(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unpin")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unpin")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func bookmark(_ status: Status) -> Request<Status> {
|
public static func bookmark(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/bookmark")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/bookmark")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unbookmark(_ status: Status) -> Request<Status> {
|
public static func unbookmark(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unbookmark")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func muteConversation(_ status: Status) -> Request<Status> {
|
public static func muteConversation(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/mute")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/mute")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unmuteConversation(_ status: Status) -> Request<Status> {
|
public static func unmuteConversation(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unmute")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -128,6 +140,9 @@ public class Status: Decodable {
|
||||||
case language
|
case language
|
||||||
case pinned
|
case pinned
|
||||||
case bookmarked
|
case bookmarked
|
||||||
|
case card
|
||||||
|
case poll
|
||||||
|
case localOnly = "local_only"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
//
|
||||||
|
// Suggestion.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/22/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Suggestion: Decodable {
|
||||||
|
public let source: Source
|
||||||
|
public let account: Account
|
||||||
|
|
||||||
|
public static func remove(accountID: String) -> Request<Empty> {
|
||||||
|
return Request(method: .delete, path: "/api/v1/suggestions/\(accountID)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Suggestion {
|
||||||
|
public enum Source: String, Decodable {
|
||||||
|
case staff
|
||||||
|
case pastInteractions = "past_interactions"
|
||||||
|
case global
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Timeline {
|
public enum Timeline: Equatable, Hashable {
|
||||||
case home
|
case home
|
||||||
case `public`(local: Bool)
|
case `public`(local: Bool)
|
||||||
case tag(hashtag: String)
|
case tag(hashtag: String)
|
||||||
|
@ -17,7 +17,7 @@ public enum Timeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Timeline {
|
extension Timeline {
|
||||||
var endpoint: String {
|
var endpoint: Endpoint {
|
||||||
switch self {
|
switch self {
|
||||||
case .home:
|
case .home:
|
||||||
return "/api/v1/timelines/home"
|
return "/api/v1/timelines/home"
|
|
@ -0,0 +1,18 @@
|
||||||
|
//
|
||||||
|
// WellKnown.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/22/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WellKnown: Decodable {
|
||||||
|
let links: [Link]
|
||||||
|
|
||||||
|
struct Link: Decodable {
|
||||||
|
let href: String
|
||||||
|
let rel: String
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,89 @@
|
||||||
|
//
|
||||||
|
// Body.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/8/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol Body {
|
||||||
|
var mimeType: String? { get }
|
||||||
|
var data: Data? { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyBody: Body {
|
||||||
|
var mimeType: String? { nil }
|
||||||
|
var data: Data? { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParametersBody: Body {
|
||||||
|
let parameters: [Parameter]?
|
||||||
|
|
||||||
|
init(_ parmaeters: [Parameter]?) {
|
||||||
|
self.parameters = parmaeters
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimeType: String? {
|
||||||
|
if parameters == nil || parameters!.isEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "application/x-www-form-urlencoded; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
var data: Data? {
|
||||||
|
return parameters?.urlEncoded.data(using: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FormDataBody: Body {
|
||||||
|
private static let boundary = "PachydermBoundary"
|
||||||
|
|
||||||
|
let parameters: [Parameter]?
|
||||||
|
let attachment: FormAttachment?
|
||||||
|
|
||||||
|
init(_ parameters: [Parameter]?, _ attachment: FormAttachment?) {
|
||||||
|
self.parameters = parameters
|
||||||
|
self.attachment = attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimeType: String? {
|
||||||
|
if parameters == nil && attachment == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "multipart/form-data; boundary=\(FormDataBody.boundary)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var data: Data? {
|
||||||
|
var data = Data()
|
||||||
|
parameters?.forEach { param in
|
||||||
|
guard let value = param.value else { return }
|
||||||
|
data.append("--\(FormDataBody.boundary)\r\n")
|
||||||
|
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
|
||||||
|
data.append("\(value)\r\n")
|
||||||
|
}
|
||||||
|
if let attachment = attachment {
|
||||||
|
data.append("--\(FormDataBody.boundary)\r\n")
|
||||||
|
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
|
||||||
|
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
|
||||||
|
data.append(attachment.data)
|
||||||
|
data.append("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
data.append("--\(FormDataBody.boundary)--\r\n")
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JsonBody<T: Encodable>: Body {
|
||||||
|
let value: T
|
||||||
|
|
||||||
|
init(_ value: T) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimeType: String? { "application/json" }
|
||||||
|
|
||||||
|
var data: Data? { try? Client.encoder.encode(value) }
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
//
|
||||||
|
// Endpoint.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/29/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertible {
|
||||||
|
let components: [Component]
|
||||||
|
|
||||||
|
public init(stringLiteral value: StringLiteralType) {
|
||||||
|
self.components = [.literal(value)]
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(stringInterpolation: StringInterpolation) {
|
||||||
|
self.components = stringInterpolation.components
|
||||||
|
}
|
||||||
|
|
||||||
|
var path: String {
|
||||||
|
components.map {
|
||||||
|
switch $0 {
|
||||||
|
case .literal(let s), .interpolated(let s):
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}.joined(separator: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
components.map {
|
||||||
|
switch $0 {
|
||||||
|
case .literal(let s):
|
||||||
|
return s
|
||||||
|
case .interpolated(_):
|
||||||
|
return "<redacted>"
|
||||||
|
}
|
||||||
|
}.joined(separator: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct StringInterpolation: StringInterpolationProtocol {
|
||||||
|
var components = [Component]()
|
||||||
|
|
||||||
|
public init(literalCapacity: Int, interpolationCount: Int) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func appendLiteral(_ literal: StringLiteralType) {
|
||||||
|
components.append(.literal(literal))
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func appendInterpolation(_ value: String) {
|
||||||
|
components.append(.interpolated(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Component {
|
||||||
|
case literal(String)
|
||||||
|
case interpolated(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -8,12 +8,12 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Method {
|
public enum Method {
|
||||||
case get, post, put, patch, delete
|
case get, post, put, patch, delete
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Method {
|
extension Method {
|
||||||
var name: String {
|
public var name: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .get:
|
case .get:
|
||||||
return "GET"
|
return "GET"
|
|
@ -42,6 +42,10 @@ extension String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func =>(name: String, value: TimeInterval?) -> Parameter {
|
||||||
|
return name => (value == nil ? nil : Int(value!))
|
||||||
|
}
|
||||||
|
|
||||||
static func =>(name: String, focus: (Float, Float)?) -> Parameter {
|
static func =>(name: String, focus: (Float, Float)?) -> Parameter {
|
||||||
guard let focus = focus else { return Parameter(name: name, value: nil) }
|
guard let focus = focus else { return Parameter(name: name, value: nil) }
|
||||||
return Parameter(name: name, value: "\(focus.0),\(focus.1)")
|
return Parameter(name: name, value: "\(focus.0),\(focus.1)")
|
||||||
|
@ -52,6 +56,10 @@ extension String {
|
||||||
let name = "\(name)[]"
|
let name = "\(name)[]"
|
||||||
return values.map { Parameter(name: name, value: $0) }
|
return values.map { Parameter(name: name, value: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func =>(name: String, values: [Int]) -> [Parameter] {
|
||||||
|
return name => values.map { $0.description }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Parameter: CustomStringConvertible {
|
extension Parameter: CustomStringConvertible {
|
|
@ -10,13 +10,13 @@ import Foundation
|
||||||
|
|
||||||
public struct Request<ResultType: Decodable> {
|
public struct Request<ResultType: Decodable> {
|
||||||
let method: Method
|
let method: Method
|
||||||
let path: String
|
let endpoint: Endpoint
|
||||||
let body: Body
|
let body: Body
|
||||||
var queryParameters: [Parameter]
|
var queryParameters: [Parameter]
|
||||||
|
|
||||||
init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
|
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
||||||
self.method = method
|
self.method = method
|
||||||
self.path = path
|
self.endpoint = path
|
||||||
self.body = body
|
self.body = body
|
||||||
self.queryParameters = queryParameters
|
self.queryParameters = queryParameters
|
||||||
}
|
}
|
|
@ -11,7 +11,9 @@ import Foundation
|
||||||
public enum RequestRange {
|
public enum RequestRange {
|
||||||
case `default`
|
case `default`
|
||||||
case count(Int)
|
case count(Int)
|
||||||
|
/// Chronologically immediately before the given ID
|
||||||
case before(id: String, count: Int?)
|
case before(id: String, count: Int?)
|
||||||
|
/// Chronologically immediately after the given ID
|
||||||
case after(id: String, count: Int?)
|
case after(id: String, count: Int?)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,5 +10,5 @@ import Foundation
|
||||||
|
|
||||||
public enum Response<Result: Decodable> {
|
public enum Response<Result: Decodable> {
|
||||||
case success(Result, Pagination?)
|
case success(Result, Pagination?)
|
||||||
case failure(Error)
|
case failure(Client.Error)
|
||||||
}
|
}
|
|
@ -13,12 +13,12 @@ public struct CharacterCounter {
|
||||||
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
||||||
|
|
||||||
public static func count(text: String) -> Int {
|
public static func count(text: String, for instance: Instance? = nil) -> Int {
|
||||||
let mentionsRemoved = removeMentions(in: text)
|
let mentionsRemoved = removeMentions(in: text)
|
||||||
var count = mentionsRemoved.count
|
var count = mentionsRemoved.count
|
||||||
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
||||||
count -= match.range.length
|
count -= match.range.length
|
||||||
count += 23 // Mastodon link length
|
count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// StatusState.swift
|
// CollapseState.swift
|
||||||
// Pachyderm
|
// Pachyderm
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 11/24/19.
|
// Created by Shadowfacts on 11/24/19.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class StatusState: Equatable, Hashable {
|
public class CollapseState: Equatable {
|
||||||
public var collapsible: Bool?
|
public var collapsible: Bool?
|
||||||
public var collapsed: Bool?
|
public var collapsed: Bool?
|
||||||
|
|
||||||
|
@ -21,8 +21,8 @@ public class StatusState: Equatable, Hashable {
|
||||||
self.collapsed = collapsed
|
self.collapsed = collapsed
|
||||||
}
|
}
|
||||||
|
|
||||||
public func copy() -> StatusState {
|
public func copy() -> CollapseState {
|
||||||
return StatusState(collapsible: self.collapsible, collapsed: self.collapsed)
|
return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
@ -30,11 +30,11 @@ public class StatusState: Equatable, Hashable {
|
||||||
hasher.combine(collapsed)
|
hasher.combine(collapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var unknown: StatusState {
|
public static var unknown: CollapseState {
|
||||||
StatusState(collapsible: nil, collapsed: nil)
|
CollapseState(collapsible: nil, collapsed: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func == (lhs: StatusState, rhs: StatusState) -> Bool {
|
public static func == (lhs: CollapseState, rhs: CollapseState) -> Bool {
|
||||||
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
|
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -12,7 +12,7 @@ public class InstanceSelector {
|
||||||
|
|
||||||
private static let decoder = JSONDecoder()
|
private static let decoder = JSONDecoder()
|
||||||
|
|
||||||
public static func getInstances(category: String?, completion: @escaping Client.Callback<[Instance]>) {
|
public static func getInstances(category: String?, completion: @escaping (Result<[Instance], Client.ErrorType>) -> Void) {
|
||||||
let url: URL
|
let url: URL
|
||||||
if let category = category {
|
if let category = category {
|
||||||
url = URL(string: "https://api.joinmastodon.org/servers?category=\(category)")!
|
url = URL(string: "https://api.joinmastodon.org/servers?category=\(category)")!
|
||||||
|
@ -22,23 +22,26 @@ public class InstanceSelector {
|
||||||
let request = URLRequest(url: url)
|
let request = URLRequest(url: url)
|
||||||
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(.networkError(error)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data,
|
guard let data = data,
|
||||||
let response = response as? HTTPURLResponse else {
|
let response = response as? HTTPURLResponse else {
|
||||||
completion(.failure(Client.Error.invalidResponse))
|
completion(.failure(.invalidResponse))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard response.statusCode == 200 else {
|
guard response.statusCode == 200 else {
|
||||||
completion(.failure(Client.Error.unknownError))
|
completion(.failure(.unexpectedStatus(response.statusCode)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let result = try? decoder.decode([Instance].self, from: data) else {
|
let result: [Instance]
|
||||||
completion(.failure(Client.Error.invalidModel))
|
do {
|
||||||
|
result = try decoder.decode([Instance].self, from: data)
|
||||||
|
} catch {
|
||||||
|
completion(.failure(.invalidModel(error)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
completion(.success(result, nil))
|
completion(.success(result))
|
||||||
}
|
}
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
//
|
||||||
|
// NotificationGroup.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/5/19.
|
||||||
|
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct NotificationGroup: Identifiable, Hashable {
|
||||||
|
public private(set) var notifications: [Notification]
|
||||||
|
public let id: String
|
||||||
|
public let kind: Notification.Kind
|
||||||
|
public let statusState: CollapseState?
|
||||||
|
|
||||||
|
init?(notifications: [Notification]) {
|
||||||
|
guard !notifications.isEmpty else { return nil }
|
||||||
|
self.notifications = notifications
|
||||||
|
self.id = notifications.first!.id
|
||||||
|
self.kind = notifications.first!.kind
|
||||||
|
if kind == .mention {
|
||||||
|
self.statusState = .unknown
|
||||||
|
} else {
|
||||||
|
self.statusState = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
||||||
|
guard lhs.notifications.count == rhs.notifications.count else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (a, b) in zip(lhs.notifications, rhs.notifications) where a.id != b.id {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
for notification in notifications {
|
||||||
|
hasher.combine(notification.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func append(_ notification: Notification) {
|
||||||
|
notifications.append(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func append(group: NotificationGroup) {
|
||||||
|
notifications.append(contentsOf: group.notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||||
|
var groups = [NotificationGroup]()
|
||||||
|
for notification in notifications {
|
||||||
|
if allowedTypes.contains(notification.kind) {
|
||||||
|
if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) {
|
||||||
|
groups[groups.count - 1].append(notification)
|
||||||
|
continue
|
||||||
|
} else if groups.count >= 2 {
|
||||||
|
let secondToLastGroup = groups[groups.count - 2]
|
||||||
|
if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) {
|
||||||
|
groups[groups.count - 2].append(notification)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.append(NotificationGroup(notifications: [notification])!)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
|
||||||
|
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||||
|
guard !first.isEmpty else {
|
||||||
|
return second
|
||||||
|
}
|
||||||
|
guard !second.isEmpty else {
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged = first
|
||||||
|
var second = second
|
||||||
|
merged.reserveCapacity(second.count)
|
||||||
|
while let firstGroupFromSecond = second.first,
|
||||||
|
allowedTypes.contains(firstGroupFromSecond.kind) {
|
||||||
|
|
||||||
|
second.removeFirst()
|
||||||
|
|
||||||
|
guard let lastGroup = merged.last,
|
||||||
|
allowedTypes.contains(lastGroup.kind) else {
|
||||||
|
merged.append(firstGroupFromSecond)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) {
|
||||||
|
merged[merged.count - 1].append(group: firstGroupFromSecond)
|
||||||
|
} else if merged.count >= 2 {
|
||||||
|
let secondToLastGroup = merged[merged.count - 2]
|
||||||
|
if allowedTypes.contains(secondToLastGroup.kind), canMerge(notification: firstGroupFromSecond.notifications.first!, into: secondToLastGroup) {
|
||||||
|
merged[merged.count - 2].append(group: firstGroupFromSecond)
|
||||||
|
} else {
|
||||||
|
merged.append(firstGroupFromSecond)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
merged.append(firstGroupFromSecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.append(contentsOf: second)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,268 @@
|
||||||
|
//
|
||||||
|
// NotificationGroupTests.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/26/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import Pachyderm
|
||||||
|
|
||||||
|
class NotificationGroupTests: XCTestCase {
|
||||||
|
|
||||||
|
let decoder: JSONDecoder = {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .iso8601
|
||||||
|
return d
|
||||||
|
}()
|
||||||
|
|
||||||
|
let statusA = """
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"created_at": "2019-11-23T07:28:34Z",
|
||||||
|
"account": {
|
||||||
|
"id": "2",
|
||||||
|
"username": "bar",
|
||||||
|
"acct": "bar",
|
||||||
|
"display_name": "bar",
|
||||||
|
"locked": false,
|
||||||
|
"created_at": "2019-11-01T01:01:01Z",
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"statuses_count": 0,
|
||||||
|
"note": "",
|
||||||
|
"url": "https://example.com/@bar",
|
||||||
|
"uri": "https://example.com/@bar",
|
||||||
|
},
|
||||||
|
"url": "https://example.com/@bar/1",
|
||||||
|
"uri": "https://example.com/@bar/1",
|
||||||
|
"content": "",
|
||||||
|
"emojis": [],
|
||||||
|
"reblogs_count": 0,
|
||||||
|
"favourites_count": 0,
|
||||||
|
"sensitive": false,
|
||||||
|
"spoiler_text": "",
|
||||||
|
"visibility": "public",
|
||||||
|
"media_attachments": [],
|
||||||
|
"mentions": [],
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
lazy var likeA1Data = """
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"type": "favourite",
|
||||||
|
"created_at": "2019-11-23T07:29:18Z",
|
||||||
|
"account": {
|
||||||
|
"id": "1",
|
||||||
|
"username": "foo",
|
||||||
|
"acct": "foo",
|
||||||
|
"display_name": "foo",
|
||||||
|
"locked": false,
|
||||||
|
"created_at": "2019-11-01T01:01:01Z",
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"statuses_count": 0,
|
||||||
|
"note": "",
|
||||||
|
"url": "https://example.com/@foo",
|
||||||
|
"uri": "https://example.com/@foo",
|
||||||
|
},
|
||||||
|
"status": \(statusA)
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
lazy var likeA1 = try! decoder.decode(Notification.self, from: likeA1Data)
|
||||||
|
lazy var likeA2Data = """
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"type": "favourite",
|
||||||
|
"created_at": "2019-11-23T07:30:00Z",
|
||||||
|
"account": {
|
||||||
|
"id": "2",
|
||||||
|
"username": "baz",
|
||||||
|
"acct": "baz",
|
||||||
|
"display_name": "baz",
|
||||||
|
"locked": false,
|
||||||
|
"created_at": "2019-11-01T01:01:01Z",
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"statuses_count": 0,
|
||||||
|
"note": "",
|
||||||
|
"url": "https://example.com/@baz",
|
||||||
|
"uri": "https://example.com/@baz",
|
||||||
|
},
|
||||||
|
"status": \(statusA)
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
lazy var likeA2 = try! decoder.decode(Notification.self, from: likeA2Data)
|
||||||
|
let statusB = """
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"created_at": "2019-11-23T07:28:34Z",
|
||||||
|
"account": {
|
||||||
|
"id": "2",
|
||||||
|
"username": "bar",
|
||||||
|
"acct": "bar",
|
||||||
|
"display_name": "bar",
|
||||||
|
"locked": false,
|
||||||
|
"created_at": "2019-11-01T01:01:01Z",
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"statuses_count": 0,
|
||||||
|
"note": "",
|
||||||
|
"url": "https://example.com/@bar",
|
||||||
|
"uri": "https://example.com/@bar",
|
||||||
|
},
|
||||||
|
"url": "https://example.com/@bar/1",
|
||||||
|
"uri": "https://example.com/@bar/1",
|
||||||
|
"content": "",
|
||||||
|
"emojis": [],
|
||||||
|
"reblogs_count": 0,
|
||||||
|
"favourites_count": 0,
|
||||||
|
"sensitive": false,
|
||||||
|
"spoiler_text": "",
|
||||||
|
"visibility": "public",
|
||||||
|
"media_attachments": [],
|
||||||
|
"mentions": [],
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
lazy var likeBData = """
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"type": "favourite",
|
||||||
|
"created_at": "2019-11-23T07:29:18Z",
|
||||||
|
"account": {
|
||||||
|
"id": "1",
|
||||||
|
"username": "foo",
|
||||||
|
"acct": "foo",
|
||||||
|
"display_name": "foo",
|
||||||
|
"locked": false,
|
||||||
|
"created_at": "2019-11-01T01:01:01Z",
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"statuses_count": 0,
|
||||||
|
"note": "",
|
||||||
|
"url": "https://example.com/@foo",
|
||||||
|
"uri": "https://example.com/@foo",
|
||||||
|
},
|
||||||
|
"status": \(statusB)
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
lazy var likeB = try! decoder.decode(Notification.self, from: likeBData)
|
||||||
|
lazy var likeB2Data = """
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"type": "favourite",
|
||||||
|
"created_at": "2019-11-23T07:29:18Z",
|
||||||
|
"account": {
|
||||||
|
"id": "2",
|
||||||
|
"username": "bar",
|
||||||
|
"acct": "bar",
|
||||||
|
"display_name": "bar",
|
||||||
|
"locked": false,
|
||||||
|
"created_at": "2019-11-02T01:01:01Z",
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"statuses_count": 0,
|
||||||
|
"note": "",
|
||||||
|
"url": "https://example.com/@bar",
|
||||||
|
"uri": "https://example.com/@bar",
|
||||||
|
},
|
||||||
|
"status": \(statusB)
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
lazy var likeB2 = try! decoder.decode(Notification.self, from: likeB2Data)
|
||||||
|
lazy var mentionBData = """
|
||||||
|
{
|
||||||
|
"id": "5",
|
||||||
|
"type": "mention",
|
||||||
|
"created_at": "2019-11-23T07:29:18Z",
|
||||||
|
"account": {
|
||||||
|
"id": "1",
|
||||||
|
"username": "foo",
|
||||||
|
"acct": "foo",
|
||||||
|
"display_name": "foo",
|
||||||
|
"locked": false,
|
||||||
|
"created_at": "2019-11-01T01:01:01Z",
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"statuses_count": 0,
|
||||||
|
"note": "",
|
||||||
|
"url": "https://example.com/@foo",
|
||||||
|
"uri": "https://example.com/@foo",
|
||||||
|
},
|
||||||
|
"status": \(statusB)
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
lazy var mentionB = try! decoder.decode(Notification.self, from: mentionBData)
|
||||||
|
|
||||||
|
|
||||||
|
func testGroupSimple() {
|
||||||
|
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
|
||||||
|
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGroupWithOtherGroupableInBetween() {
|
||||||
|
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
|
||||||
|
XCTAssertEqual(groups, [
|
||||||
|
NotificationGroup(notifications: [likeA1, likeA2])!,
|
||||||
|
NotificationGroup(notifications: [likeB])!,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDontGroupWithUngroupableInBetween() {
|
||||||
|
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
|
||||||
|
XCTAssertEqual(groups, [
|
||||||
|
NotificationGroup(notifications: [likeA1])!,
|
||||||
|
NotificationGroup(notifications: [mentionB])!,
|
||||||
|
NotificationGroup(notifications: [likeA2])!,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMergeSimpleGroups() {
|
||||||
|
let group1 = NotificationGroup(notifications: [likeA1])!
|
||||||
|
let group2 = NotificationGroup(notifications: [likeA2])!
|
||||||
|
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
|
||||||
|
XCTAssertEqual(merged, [
|
||||||
|
NotificationGroup(notifications: [likeA1, likeA2])!
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMergeGroupsWithOtherGroupableInBetween() {
|
||||||
|
let group1 = NotificationGroup(notifications: [likeA1])!
|
||||||
|
let group2 = NotificationGroup(notifications: [likeB])!
|
||||||
|
let group3 = NotificationGroup(notifications: [likeA2])!
|
||||||
|
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
||||||
|
XCTAssertEqual(merged, [
|
||||||
|
NotificationGroup(notifications: [likeA1, likeA2])!,
|
||||||
|
NotificationGroup(notifications: [likeB])!,
|
||||||
|
])
|
||||||
|
|
||||||
|
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
|
||||||
|
XCTAssertEqual(merged2, [
|
||||||
|
NotificationGroup(notifications: [likeA1, likeA2])!,
|
||||||
|
NotificationGroup(notifications: [likeB])!,
|
||||||
|
])
|
||||||
|
|
||||||
|
let group4 = NotificationGroup(notifications: [likeB2])!
|
||||||
|
let group5 = NotificationGroup(notifications: [mentionB])!
|
||||||
|
let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite])
|
||||||
|
print(merged3.count)
|
||||||
|
XCTAssertEqual(merged3, [
|
||||||
|
group1,
|
||||||
|
group5,
|
||||||
|
NotificationGroup(notifications: [likeB, likeB2]),
|
||||||
|
group3
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDontMergeWithUngroupableInBetween() {
|
||||||
|
let group1 = NotificationGroup(notifications: [likeA1])!
|
||||||
|
let group2 = NotificationGroup(notifications: [mentionB])!
|
||||||
|
let group3 = NotificationGroup(notifications: [likeA2])!
|
||||||
|
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
||||||
|
XCTAssertEqual(merged, [
|
||||||
|
NotificationGroup(notifications: [likeA1])!,
|
||||||
|
NotificationGroup(notifications: [mentionB])!,
|
||||||
|
NotificationGroup(notifications: [likeA2])!,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
//
|
||||||
|
// URLTests.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/17/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
import WebURL
|
||||||
|
import WebURLFoundationExtras
|
||||||
|
|
||||||
|
class URLTests: XCTestCase {
|
||||||
|
|
||||||
|
func testDecodeURL() {
|
||||||
|
XCTAssertNotNil(WebURL(URL(string: "https://xn--baw-joa.social/@unituebingen")!))
|
||||||
|
XCTAssertNotNil(WebURL("https://xn--baw-joa.social/@unituebingen"))
|
||||||
|
XCTAssertNotNil(URLComponents(string: "https://xn--baw-joa.social/test/é"))
|
||||||
|
XCTAssertNotNil(WebURL("https://xn--baw-joa.social/test/é"))
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é"))
|
||||||
|
XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue