forked from shadowfacts/Tusker
Compare commits
862 Commits
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 5ce9892a9b | |
Shadowfacts | 54376ac585 | |
Shadowfacts | 26c483fc9a | |
Shadowfacts | a68d2ce952 | |
Shadowfacts | adaf8dc217 | |
Shadowfacts | 572c5a0824 | |
Shadowfacts | e469d207b4 | |
Shadowfacts | 82ec120871 | |
Shadowfacts | 242c60d74d | |
Shadowfacts | 20692b0630 | |
Shadowfacts | 9990d50e3e | |
Shadowfacts | 670047af6f | |
Shadowfacts | e8a492ef7d | |
Shadowfacts | 583d9b97dd | |
Shadowfacts | 88176fe599 | |
Shadowfacts | 19c3008c8f | |
Shadowfacts | 51f9f421b8 | |
Shadowfacts | b700e17d7e | |
Shadowfacts | dc01804359 | |
Shadowfacts | a5066140fd | |
Shadowfacts | 351efe4b58 | |
Shadowfacts | c716f03784 | |
Shadowfacts | fa828a5eae | |
Shadowfacts | 56d12295ba | |
Shadowfacts | a442197adf | |
Shadowfacts | a99fb7f0b0 | |
Shadowfacts | f44dae632c | |
Shadowfacts | 0dcb67c44e | |
Shadowfacts | e869fdc38f | |
Shadowfacts | 5c86feccb9 | |
Shadowfacts | 01cf597b5d | |
Shadowfacts | 12bab71b17 | |
Shadowfacts | f4b51c06c1 | |
Shadowfacts | c99c397cf6 | |
Shadowfacts | 814f64b3e2 | |
Shadowfacts | 3a3af77907 | |
Shadowfacts | 93e72e1cb6 | |
Shadowfacts | 522e7830e5 | |
Shadowfacts | 263210ac3c | |
Shadowfacts | 506d2ad8a9 | |
Shadowfacts | f9c0506590 | |
Shadowfacts | 3f4917931b | |
Shadowfacts | b7166771cf | |
Shadowfacts | 40230c5478 | |
Shadowfacts | 68bd9e0bed | |
Shadowfacts | 3e28c012d7 | |
Shadowfacts | 57c023c973 | |
Shadowfacts | cc696e58fc | |
Shadowfacts | 59af29ff64 | |
Shadowfacts | 59fb69525b | |
Shadowfacts | 1bd4d144a3 | |
Shadowfacts | b54d34ebfc | |
Shadowfacts | d1ffab3e42 | |
Shadowfacts | d873b157ee | |
Shadowfacts | d7be2048af | |
Shadowfacts | 3d1f506684 | |
Shadowfacts | cd8f0e7926 | |
Shadowfacts | 960ba84683 | |
Shadowfacts | 2eead1f9de | |
Shadowfacts | b663335c6d | |
Shadowfacts | 9ce6bd566f | |
Shadowfacts | 9547bd2913 | |
Shadowfacts | 9b2e6140a3 | |
Shadowfacts | 6de255681c | |
Shadowfacts | 805e5eddd0 | |
Shadowfacts | 4945a234e7 | |
Shadowfacts | 230696f456 | |
Shadowfacts | c113903980 | |
Shadowfacts | 0e95cd0adf | |
Shadowfacts | 494708a362 | |
Shadowfacts | 3a21983b98 | |
Shadowfacts | 1817247077 | |
Shadowfacts | 0d9eed73dd | |
Shadowfacts | 59d43fd3f6 | |
Shadowfacts | d321c31776 | |
Shadowfacts | ce10c7d6e2 | |
Shadowfacts | 37b9673b12 | |
Shadowfacts | 7c7af945e4 | |
Shadowfacts | cb32c66a59 | |
Shadowfacts | 4249ab30ca | |
Shadowfacts | 67e9c1245e | |
Shadowfacts | 3d9a1086b6 | |
Shadowfacts | fda0c18794 | |
Shadowfacts | dffa5d8f75 | |
Shadowfacts | 9891b601a8 | |
Shadowfacts | a8f6aa6ed7 | |
Shadowfacts | 348dcc558c | |
Shadowfacts | 703f6f695b | |
Shadowfacts | fdbfe49a7c | |
Shadowfacts | 3f0dd599b3 | |
Shadowfacts | 07b6bf33cb | |
Shadowfacts | d0758dc73c | |
Shadowfacts | b85c0eb95d | |
Shadowfacts | eea0ef258c | |
Shadowfacts | 18f6445a7c | |
Shadowfacts | c5f42719a0 | |
Shadowfacts | eb89aec00f | |
Shadowfacts | 61576bce58 | |
Shadowfacts | f7d4737782 | |
Shadowfacts | 3dd0f3a154 | |
Shadowfacts | 145ffbfcf0 | |
Shadowfacts | bcf2a2f026 | |
Shadowfacts | 1358152dec | |
Shadowfacts | 2e2279ba8c | |
Shadowfacts | 60dadf599c | |
Shadowfacts | 90537f9d12 | |
Shadowfacts | 8b0c2f80b6 | |
Shadowfacts | 42423f36db | |
Shadowfacts | 176eb7c011 | |
Shadowfacts | da9ca78a8b | |
Shadowfacts | b470ee6401 | |
Shadowfacts | fccd4e427c | |
Shadowfacts | f25031afd4 | |
Shadowfacts | ca65f84137 | |
Shadowfacts | d4057adf4d | |
Shadowfacts | 007937d2d7 | |
Shadowfacts | 5f040ed390 | |
Shadowfacts | 870d0c8404 | |
Shadowfacts | 47b9ac890a | |
Shadowfacts | 50b84350d9 | |
Shadowfacts | cdc64f1b2c | |
Shadowfacts | 2913098e74 | |
Shadowfacts | ce99352e90 | |
Shadowfacts | 8322d3a36c | |
Shadowfacts | a818457f8c | |
Shadowfacts | 1f6644b703 | |
Shadowfacts | 412c5ee91d | |
Shadowfacts | dcc5f7f716 | |
Shadowfacts | 9fefc9e8f8 | |
Shadowfacts | d1af911241 | |
Shadowfacts | 5abd265195 | |
Shadowfacts | 3cb0f46533 | |
Shadowfacts | c367a2e9f1 | |
Shadowfacts | 3eceffbb6b | |
Shadowfacts | 7c3a00a40d | |
Shadowfacts | 45a90fb4a2 | |
Shadowfacts | 8557e110a8 | |
Shadowfacts | c2232a5e14 | |
Shadowfacts | e6d9a33dbf | |
Shadowfacts | d8fccc8f1b | |
Shadowfacts | 6528070f1c | |
Shadowfacts | 09c6a87e19 | |
Shadowfacts | cd0d8fffcb | |
Shadowfacts | 1b6f0c07fd | |
Shadowfacts | 2f31b50a5b | |
Shadowfacts | cee4e15b06 | |
Shadowfacts | 888f44366c | |
Shadowfacts | c88076eec0 | |
Shadowfacts | afe47437e4 | |
Shadowfacts | 4dc484c3c2 | |
Shadowfacts | 0f2a85b108 | |
Shadowfacts | 5e55ce75c2 | |
Shadowfacts | eec2adbfd9 | |
Shadowfacts | a848f6e425 | |
Shadowfacts | 44896d305e | |
Shadowfacts | 6c70ed4b4e | |
Shadowfacts | e3c480131a | |
Shadowfacts | 575166f5b4 | |
Shadowfacts | c60aa3e3f3 | |
Shadowfacts | 75f0d12c82 | |
Shadowfacts | 5cf2bc4fbf | |
Shadowfacts | 908b499f8f | |
Shadowfacts | 67c7905acf | |
Shadowfacts | eacafe87b3 | |
Shadowfacts | 2a53b24487 | |
Shadowfacts | 9df3c33c6c | |
Shadowfacts | d4e82d6e7a | |
Shadowfacts | 06ba758309 | |
Shadowfacts | 2c56902389 | |
Shadowfacts | cb3fd43dbd | |
Shadowfacts | 3d15759fb9 | |
Shadowfacts | 5620b6ab78 | |
Shadowfacts | 09999175f7 | |
Shadowfacts | f2a9f890ff | |
Shadowfacts | 093994b474 | |
Shadowfacts | 3d0de5af04 | |
Shadowfacts | 966a906436 | |
Shadowfacts | 844d4056e3 | |
Shadowfacts | 00ef131bb6 | |
Shadowfacts | d6be6f14dc | |
Shadowfacts | 2ccf028bc2 | |
Shadowfacts | 3eeffada1f | |
Shadowfacts | 0499255be7 | |
Shadowfacts | f909c1da10 | |
Shadowfacts | 81543965ae | |
Shadowfacts | 96d42756d5 | |
Shadowfacts | f6e57d664f | |
Shadowfacts | c33be1cbf3 | |
Shadowfacts | 6d99156bd9 | |
Shadowfacts | ca764811ed | |
Shadowfacts | a589bb2863 | |
Shadowfacts | 6f35fd2676 | |
Shadowfacts | e83cef1c8c | |
Shadowfacts | b89df3f27b | |
Shadowfacts | 4ecc16a93b | |
Shadowfacts | 8960873ff3 | |
Shadowfacts | 043a708515 | |
Shadowfacts | c6b230414e | |
Shadowfacts | f5e9f66f76 | |
Shadowfacts | ee5f9a62ff | |
Shadowfacts | a92cf8c812 | |
Shadowfacts | 756874949a | |
Shadowfacts | 798e0c0cf1 | |
Shadowfacts | 3f370945e6 | |
Shadowfacts | a759731eba | |
Shadowfacts | 405d5def7c | |
Shadowfacts | 1f9806d02f | |
Shadowfacts | c43c951b92 | |
Shadowfacts | 00c44c612f | |
Shadowfacts | e5c4fceacd | |
Shadowfacts | 70227a7fa1 | |
Shadowfacts | cb5488dcaa | |
Shadowfacts | 910e18fb5e | |
Shadowfacts | 66af946766 | |
Shadowfacts | 6784ed7fdf | |
Shadowfacts | 66f0ba6891 | |
Shadowfacts | ee7bf5138c | |
Shadowfacts | c32181818a | |
Shadowfacts | 4665df228d | |
Shadowfacts | c7a56a9f61 | |
Shadowfacts | 39251b9aa2 | |
Shadowfacts | db534e5993 | |
Shadowfacts | e94bee4fc8 | |
Shadowfacts | 216e58e5ec | |
Shadowfacts | a4d13ad03b | |
Shadowfacts | 05cfecb797 | |
Shadowfacts | 132fcfa099 | |
Shadowfacts | 475b9911b1 | |
Shadowfacts | 7825ccbb3d | |
Shadowfacts | f87da10a29 | |
Shadowfacts | 1eec70449d | |
Shadowfacts | 19ca930ee8 | |
Shadowfacts | 2e31d34e9d | |
Shadowfacts | 8a339ec171 | |
Shadowfacts | c7d79422bd | |
Shadowfacts | baf96a8b06 | |
Shadowfacts | bc516a6326 | |
Shadowfacts | 1cd6af1236 | |
Shadowfacts | 9f6910ba73 | |
Shadowfacts | 9cf4975bfd | |
Shadowfacts | ee992bc0bf | |
Shadowfacts | ff8a83ca2d | |
Shadowfacts | 4c957b86ae | |
Shadowfacts | ff11835333 | |
Shadowfacts | 9353bbb56c | |
Shadowfacts | edc887dd4c | |
Shadowfacts | 68dad77f81 | |
Shadowfacts | 840b83012a | |
Shadowfacts | e150856e91 | |
Shadowfacts | 42a3f6c880 | |
Shadowfacts | 7a47b09b39 | |
Shadowfacts | 241e6f7e3a | |
Shadowfacts | f02afaac26 | |
Shadowfacts | bdd4a4d755 | |
Shadowfacts | 94c1eb2c81 | |
Shadowfacts | b03991ae1d | |
Shadowfacts | f98589b419 | |
Shadowfacts | 9fad2a882a | |
Shadowfacts | ec76754270 | |
Shadowfacts | d0bb197e8c | |
Shadowfacts | efd90bca3e | |
Shadowfacts | 3efa017942 | |
Shadowfacts | c5226f6374 | |
Shadowfacts | 281585cdf0 | |
Shadowfacts | 6d4ab4d54b | |
Shadowfacts | 9e429463b2 | |
Shadowfacts | 51db0066ac | |
Shadowfacts | 9763edef47 | |
Shadowfacts | 442f57bfc4 | |
Shadowfacts | ae7101bb30 | |
Shadowfacts | 490d48c635 | |
Shadowfacts | 69ee3bb4f0 | |
Shadowfacts | 46b455c3d1 | |
Shadowfacts | e522e30ce5 | |
Shadowfacts | c73784aa81 | |
Shadowfacts | 7affa09e5e | |
Shadowfacts | 7435d02f6e | |
Shadowfacts | 2467297f04 | |
Shadowfacts | cf317e15e9 | |
Shadowfacts | bcae60316b | |
Shadowfacts | 1a2fa10708 | |
Shadowfacts | f79c2feea6 | |
Shadowfacts | 7ec87d7853 | |
Shadowfacts | f5704e561b | |
Shadowfacts | d6faf3a37b | |
Shadowfacts | b0a6952643 | |
Shadowfacts | 06b58cfb9c | |
Shadowfacts | afcec24f86 | |
Shadowfacts | 3f90a0df04 | |
Shadowfacts | 395ce6523d | |
Shadowfacts | cced930549 | |
Shadowfacts | 7b2bd1a7af | |
Shadowfacts | f447150bbc | |
Shadowfacts | 08bd78d51b | |
Shadowfacts | f0ec372f50 | |
Shadowfacts | d2c28ada7f | |
Shadowfacts | 375ad25919 | |
Shadowfacts | abf0568398 | |
Shadowfacts | 2386f545e2 | |
Shadowfacts | 908c4ee085 | |
Shadowfacts | 23e5e87915 | |
Shadowfacts | b4693252be | |
Shadowfacts | f3cf2dd8ec | |
Shadowfacts | d96ec2a732 | |
Shadowfacts | b8fe0454b5 | |
Shadowfacts | 1166c6e639 | |
Shadowfacts | eda552c7c9 | |
Shadowfacts | 841c08be2c | |
Shadowfacts | eafb506d64 | |
Shadowfacts | fe00015248 | |
Shadowfacts | 509ed305cd | |
Shadowfacts | c05107bccd | |
Shadowfacts | 4fcc32ca4b | |
Shadowfacts | 6857529d06 | |
Shadowfacts | 42e29862ac | |
Shadowfacts | 3ecee61013 | |
Shadowfacts | f9aee46bbe | |
Shadowfacts | 1cf3ce48ce | |
Shadowfacts | 072bb0daf0 | |
Shadowfacts | d36e0ad27d | |
Shadowfacts | a80cbe79c2 | |
Shadowfacts | cf71fc3f98 | |
Shadowfacts | be977dbea9 | |
Shadowfacts | f327cfd197 | |
Shadowfacts | 4bb01becd2 | |
Shadowfacts | 64fcc87516 | |
Shadowfacts | 62e528fc22 | |
Shadowfacts | 030fd4467d | |
Shadowfacts | 489840019e | |
Shadowfacts | 9af8c06b1c | |
Shadowfacts | 55e0573a5c | |
Shadowfacts | ac142ae11c | |
Shadowfacts | 99a58e2c33 | |
Shadowfacts | c740fb1c1f | |
Shadowfacts | 175001d561 | |
Shadowfacts | d481ef6c9f | |
Shadowfacts | 3caa419659 | |
Shadowfacts | 074b028015 | |
Shadowfacts | bab0dd3294 | |
Shadowfacts | 8a3acc6889 | |
Shadowfacts | d37c5dde2f | |
Shadowfacts | 53260555f6 | |
Shadowfacts | 70524dd642 | |
Shadowfacts | b6232a9f1e | |
Shadowfacts | 41481f465a | |
Shadowfacts | 527e7129af | |
Shadowfacts | 229b51686c | |
Shadowfacts | e156a97861 | |
Shadowfacts | bdec14c463 | |
Shadowfacts | ec0509c645 | |
Shadowfacts | 4500e9be27 | |
Shadowfacts | a2cc3a0436 | |
Shadowfacts | dc654812b1 | |
Shadowfacts | f122383d0b | |
Shadowfacts | 0f6492a051 | |
Shadowfacts | b235f0e826 | |
Shadowfacts | 27d44340e8 | |
Shadowfacts | fc26c9fb54 | |
Shadowfacts | ba60f92223 | |
Shadowfacts | c489d018bd | |
Shadowfacts | a9a518c6c1 | |
Shadowfacts | b4bdf8b0dc | |
Shadowfacts | 94f71541f8 | |
Shadowfacts | c2402303cc | |
Shadowfacts | 5cef76e494 | |
Shadowfacts | bf27b8fd47 | |
Shadowfacts | 32b8d27949 | |
Shadowfacts | fb5581ae67 | |
Shadowfacts | cd01d2f8c3 | |
Shadowfacts | 65c3c8026d | |
Shadowfacts | 534f83e716 | |
Shadowfacts | 93c859a3c4 | |
Shadowfacts | 4d183fe0b2 | |
Shadowfacts | fd72390a22 | |
Shadowfacts | 5a4323067a | |
Shadowfacts | 43d8434e17 | |
Shadowfacts | e8576277e0 | |
Shadowfacts | 7f0a9d8d5a | |
Shadowfacts | 51f4a780e2 | |
Shadowfacts | 180a8eb18d | |
Shadowfacts | eb61043867 | |
Shadowfacts | e09935125f | |
Shadowfacts | e8ef9345e9 | |
Shadowfacts | 28c1a9092b | |
Shadowfacts | 5e609aa40d | |
Shadowfacts | 158940f8e6 | |
Shadowfacts | 141e8b96a5 | |
Shadowfacts | 108a02826f | |
Shadowfacts | be1ca70ebf | |
Shadowfacts | 34edd8a13f | |
Shadowfacts | 23f383a7f9 | |
Shadowfacts | 99caaa0f28 | |
Shadowfacts | 0f70c9059e | |
Shadowfacts | 6d7074e71d | |
Shadowfacts | 13809b91d1 | |
Shadowfacts | 16f6dc84c9 | |
Shadowfacts | cdfb06f4a7 | |
Shadowfacts | 4e98e569eb | |
Shadowfacts | 6d3ffd7dd3 | |
Shadowfacts | ca7fe74a90 | |
Shadowfacts | 380f878d81 | |
Shadowfacts | 1c36312850 | |
Shadowfacts | de946be008 | |
Shadowfacts | b40d815274 | |
Shadowfacts | bc7500bde9 | |
Shadowfacts | 676e603ffc | |
Shadowfacts | 01bbfc31f2 | |
Shadowfacts | a846954dcd | |
Shadowfacts | 53302e3b26 | |
Shadowfacts | c0301ce7e7 | |
Shadowfacts | 14f32f24fa | |
Shadowfacts | 19db78e352 | |
Shadowfacts | 9d01bbabd7 | |
Shadowfacts | a93a4fccc1 | |
Shadowfacts | 1da25300ca | |
Shadowfacts | cb47443649 | |
Shadowfacts | 86862825f6 | |
Shadowfacts | e6f1968609 | |
Shadowfacts | 4c5da1b5a9 | |
Shadowfacts | e57ef210fd | |
Shadowfacts | dcdfe853e1 | |
Shadowfacts | 34e57c297b | |
Shadowfacts | 6c2c2e6ae7 | |
Shadowfacts | aae3bd0bba | |
Shadowfacts | 2b5d4681e3 | |
Shadowfacts | e4eff2d362 | |
Shadowfacts | 37311e5f17 | |
Shadowfacts | af5a0b7bbd | |
Shadowfacts | 3aa45cb365 | |
Shadowfacts | a07b398cbe | |
Shadowfacts | 2ccec2f4df | |
Shadowfacts | 0de9a9fd37 | |
Shadowfacts | bd21e88e8b | |
Shadowfacts | 2464e2530f | |
Shadowfacts | 44021d3ad2 | |
Shadowfacts | a46eaafbcf | |
Shadowfacts | eb496243c7 | |
Shadowfacts | 6e5e0c3bb5 | |
Shadowfacts | dfc8234908 | |
Shadowfacts | 157c8629a9 | |
Shadowfacts | bde21fbc6c | |
Shadowfacts | 74820e8922 | |
Shadowfacts | f7a9075b77 | |
Shadowfacts | 4af56e48bf | |
Shadowfacts | 978486bc15 | |
Shadowfacts | 27dd8a1927 | |
Shadowfacts | 78196e14c3 | |
Shadowfacts | a0eb5dc596 | |
Shadowfacts | e4c22a0205 | |
Shadowfacts | c4bf5d406d | |
Shadowfacts | 53d43b5707 | |
Shadowfacts | b1564d822e | |
Shadowfacts | a8a2f0a26c | |
Shadowfacts | 46e1205327 | |
Shadowfacts | 6a2de2be55 | |
Shadowfacts | db6ba0c62c | |
Shadowfacts | 16029dc161 | |
Shadowfacts | 31a0db014a | |
Shadowfacts | 5be8005e24 | |
Shadowfacts | ad4e112e96 | |
Shadowfacts | 7a2dc7d3c4 | |
Shadowfacts | 0948371f83 | |
Shadowfacts | 3ba1a00257 | |
Shadowfacts | 1b42cd7816 | |
Shadowfacts | a2fe0dfb78 | |
Shadowfacts | bf1ed57180 | |
Shadowfacts | 6821f1b9a0 | |
Shadowfacts | 7ae741cd83 | |
Shadowfacts | fe9ad83ddc | |
Shadowfacts | 6b7c828cc9 | |
Shadowfacts | 2be1ee19de | |
Shadowfacts | 3f15a453bd | |
Shadowfacts | 53611d80d6 | |
Shadowfacts | 4614b25f33 | |
Shadowfacts | 519446c5a8 | |
Shadowfacts | 4b52cafb9a | |
Shadowfacts | 1ca84a3b95 | |
Shadowfacts | b28792eb29 | |
Shadowfacts | 9c3be68e1c | |
Shadowfacts | df9ce81060 | |
Shadowfacts | 173eda1757 | |
Shadowfacts | b2b15b8b6e | |
Shadowfacts | f448090c2a | |
Shadowfacts | 232e3285ae | |
Shadowfacts | ebc127c921 | |
Shadowfacts | 41665b1060 | |
Shadowfacts | 3a3b7aaee4 | |
Shadowfacts | f2485f0ba1 | |
Shadowfacts | 75caf2c1eb | |
Shadowfacts | f1a6a405c2 | |
Shadowfacts | 88105f22a0 | |
Shadowfacts | 9c368f295e | |
Shadowfacts | 04deb08bcf | |
Shadowfacts | f704d15dd7 | |
Shadowfacts | 297af7b905 | |
Shadowfacts | 6c0564e0ee | |
Shadowfacts | 3d232d81ba | |
Shadowfacts | 3109aafd20 | |
Shadowfacts | 105a01811a | |
Shadowfacts | 33999fe895 | |
Shadowfacts | 7f12479514 | |
Shadowfacts | 0eb000224e | |
Shadowfacts | 3c9692d5b2 | |
Shadowfacts | 50bfaf7236 | |
Shadowfacts | 385f31728d | |
Shadowfacts | bcd487d311 | |
Shadowfacts | 8f8e2a2aea | |
Shadowfacts | 54034ff727 | |
Shadowfacts | ee5db96c9e | |
Shadowfacts | f825760fe9 | |
Shadowfacts | a339884d1f | |
Shadowfacts | 1de586f907 | |
Shadowfacts | bd162afdcc | |
Shadowfacts | 956b817045 | |
Shadowfacts | 28ee0908d7 | |
Shadowfacts | c3cf38b0c9 | |
Shadowfacts | 7929e7530f | |
Shadowfacts | a11e453112 | |
Shadowfacts | 2e7ad1626e | |
Shadowfacts | 4182c15500 | |
Shadowfacts | 4b43726e1d | |
Shadowfacts | a4e7082ab8 | |
Shadowfacts | f0b8f92791 | |
Shadowfacts | da88303a22 | |
Shadowfacts | cb5b70a23a | |
Shadowfacts | 2b5b749dc8 | |
Shadowfacts | ef00c0e2df | |
Shadowfacts | 06f7e306e0 | |
Shadowfacts | 878744b636 | |
Shadowfacts | f84694b809 | |
Shadowfacts | 473ef018c9 | |
Shadowfacts | 9a734565b0 | |
Shadowfacts | 2eda9657ac | |
Shadowfacts | 203c1852d4 | |
Shadowfacts | 708112c486 | |
Shadowfacts | 5b321fcc78 | |
Shadowfacts | 59231e513f | |
Shadowfacts | bf6dfab121 | |
Shadowfacts | f5f1be9f7d | |
Shadowfacts | c0148bb770 | |
Shadowfacts | d938c555b7 | |
Shadowfacts | 52efc8b752 | |
Shadowfacts | 822e3f91c4 | |
Shadowfacts | d0a1aec1c0 | |
Shadowfacts | e8305184af | |
Shadowfacts | e9727ac2c5 | |
Shadowfacts | d9a6bb0fd2 | |
Shadowfacts | 13a807ba4f | |
Shadowfacts | 32c5eee0b5 | |
Shadowfacts | 06f761bf56 | |
Shadowfacts | 4b16a69275 | |
Shadowfacts | a309b041bf | |
Shadowfacts | 8c40a5a9e8 | |
Shadowfacts | 3b11dd216f | |
Shadowfacts | 8db5649cd5 | |
Shadowfacts | f2f6eb81f7 | |
Shadowfacts | f6831ec02b | |
Shadowfacts | 7f64654800 | |
Shadowfacts | 8e570027a1 | |
Shadowfacts | df9fb3c527 | |
Shadowfacts | 2080fdc955 | |
Shadowfacts | 70f8748364 | |
Shadowfacts | 0343e2e310 | |
Shadowfacts | 80645a089c | |
Shadowfacts | 37442bcb48 | |
Shadowfacts | a99072dd7c | |
Shadowfacts | 6b57ec8b97 | |
Shadowfacts | d84d402271 | |
Shadowfacts | f004c82302 | |
Shadowfacts | 126e8c8858 | |
Shadowfacts | dbc89509d7 | |
Shadowfacts | 0ba38e4a3a | |
Shadowfacts | 361ce456cf | |
Shadowfacts | c1cfde9d49 | |
Shadowfacts | daa38772b4 | |
Shadowfacts | dc83172aea | |
Shadowfacts | b909a633a6 | |
Shadowfacts | 1f95a6cb8e | |
Shadowfacts | 468af3f9a6 | |
Shadowfacts | 038e4b2e4e | |
Shadowfacts | de53e0dcd6 | |
Shadowfacts | 1cf7434918 | |
Shadowfacts | fc7e7f502b | |
Shadowfacts | 38a2ebd32b | |
Shadowfacts | 3b965b92f2 | |
Shadowfacts | 421cb7ba03 | |
Shadowfacts | 8319935a3d | |
Shadowfacts | 91ef386a41 | |
Shadowfacts | c8eec17180 | |
Shadowfacts | c94e60d49b | |
Shadowfacts | b00170c3f9 | |
Shadowfacts | b37e5fffbf | |
Shadowfacts | 8c27a9368f | |
Shadowfacts | 735659dee6 | |
Shadowfacts | bf02b185ed | |
Shadowfacts | 4ccf5d21a4 | |
Shadowfacts | 9ac1c43511 | |
Shadowfacts | 76b9496fe6 | |
Shadowfacts | ae8191ca0e | |
Shadowfacts | a9a9bfebeb | |
Shadowfacts | 2d8e2f0824 | |
Shadowfacts | 6f18d46037 | |
Shadowfacts | 6261318df1 | |
Shadowfacts | bff7585fa9 | |
Shadowfacts | 4dbc4ebeb2 | |
Shadowfacts | fc391cc18c | |
Shadowfacts | 35b390d3c1 | |
Shadowfacts | b21703f6d9 | |
Shadowfacts | d003098146 | |
Shadowfacts | db7c183d06 | |
Shadowfacts | 7d3c82f4b7 | |
Shadowfacts | 13ec3366d3 | |
Shadowfacts | f9a41fd4f3 | |
Shadowfacts | 2157126332 | |
Shadowfacts | e87dcfe48e | |
Shadowfacts | 566c3d474d | |
Shadowfacts | ca03cf3b08 | |
Shadowfacts | f0e530722f | |
Shadowfacts | dcd1b4ad94 | |
Shadowfacts | 3394c2126c | |
Shadowfacts | 85765928b4 | |
Shadowfacts | f13874ee01 | |
Shadowfacts | bac272a2db | |
Shadowfacts | 48bd957276 | |
Shadowfacts | d4d42e7856 | |
Shadowfacts | 671a8e0cb3 | |
Shadowfacts | 822c2e0fa2 | |
Shadowfacts | ee651ae96a | |
Shadowfacts | 9fc4aa8a40 | |
Shadowfacts | 8f6a012538 | |
Shadowfacts | 91d6430815 | |
Shadowfacts | eac5a4c9a6 | |
Shadowfacts | 7449688bfe | |
Shadowfacts | 63612b2fb0 | |
Shadowfacts | 8e010c7fa5 | |
Shadowfacts | 3181c47fde | |
Shadowfacts | a133955489 | |
Shadowfacts | 7551c79715 | |
Shadowfacts | 5a4e387026 | |
Shadowfacts | 00945a0028 | |
Shadowfacts | 2b9d384f8f | |
Shadowfacts | 90efee3f20 | |
Shadowfacts | 574d1f9134 | |
Shadowfacts | 25e82d828f | |
Shadowfacts | 2eb9e63724 | |
Shadowfacts | d85f74f365 | |
Shadowfacts | f775527d63 | |
Shadowfacts | a6d64282c0 | |
Shadowfacts | 24fb0e0e7b | |
Shadowfacts | b6a5a60066 | |
Shadowfacts | f68d1009e5 | |
Shadowfacts | 99b74559da | |
Shadowfacts | 346888db41 | |
Shadowfacts | 7b218bfd75 | |
Shadowfacts | 098c4254d4 | |
Shadowfacts | bbdb7fe41f | |
Shadowfacts | 3c13d2083b | |
Shadowfacts | ad55851090 | |
Shadowfacts | a37423a119 | |
Shadowfacts | 02daf88db3 | |
Shadowfacts | ce3b8ba4b3 | |
Shadowfacts | 891fd3826b | |
Shadowfacts | e0eba95b48 | |
Shadowfacts | 2febb37a8e | |
Shadowfacts | a20e8b2f48 | |
Shadowfacts | b3d5ed8505 | |
Shadowfacts | 4401503b85 | |
Shadowfacts | 6c5909c800 | |
Shadowfacts | af5109f86c | |
Shadowfacts | b782e66a45 | |
Shadowfacts | a1ffb23f0d | |
Shadowfacts | ea5afeeb88 | |
Shadowfacts | 49334766ef | |
Shadowfacts | 3bba4edb45 | |
Shadowfacts | bda8fdb1b9 | |
Shadowfacts | f361517a92 | |
Shadowfacts | a12afb8dc2 | |
Shadowfacts | de1a97d357 | |
Shadowfacts | c17cf460d7 | |
Shadowfacts | 8ff20bf7aa | |
Shadowfacts | 205056f636 | |
Shadowfacts | 40197e04cf | |
Shadowfacts | 2249e5a315 | |
Shadowfacts | bff1ea8b9d | |
Shadowfacts | b614226871 | |
Shadowfacts | f51f3c8a94 | |
Shadowfacts | 074a296a68 | |
Shadowfacts | 2874e4bfd3 | |
Shadowfacts | 74a157d26c | |
Shadowfacts | 3d3fc3f515 | |
Shadowfacts | 6c371f868f | |
Shadowfacts | 06855420da | |
Shadowfacts | 0d7cc69947 | |
Shadowfacts | cfc69627e5 | |
Shadowfacts | 160f48679b | |
Shadowfacts | 4931665b45 | |
Shadowfacts | 849882287f | |
Shadowfacts | 436159bd46 | |
Shadowfacts | 2224dbebb8 | |
Shadowfacts | 9882250a9b | |
Shadowfacts | bb22a6bf9e | |
Shadowfacts | 15c83f8332 | |
Shadowfacts | 5ec35b6009 | |
Shadowfacts | 22fe1e8ab1 | |
Shadowfacts | 813d0433d6 | |
Shadowfacts | cd9d64410f | |
Shadowfacts | 2b66f98832 | |
Shadowfacts | 6ebcc162e6 | |
Shadowfacts | 8b7c78e3b1 | |
Shadowfacts | ab8ccbb408 | |
Shadowfacts | f89d2c1cca | |
Shadowfacts | 30449a2875 | |
Shadowfacts | afed157f29 | |
Shadowfacts | 6b4223a9d6 | |
Shadowfacts | 0746e12737 | |
Shadowfacts | 350e331eb2 | |
Shadowfacts | bb3f353dbc | |
Shadowfacts | 6bd2eacb88 | |
Shadowfacts | 29b594207c | |
Shadowfacts | e5363b2e21 | |
Shadowfacts | d04259b253 | |
Shadowfacts | f50c219f95 | |
Shadowfacts | b2fe2fdf9a | |
Shadowfacts | 850a0e90ce | |
Shadowfacts | 391ea1b46a | |
Shadowfacts | 247bb31c56 | |
Shadowfacts | 5471d810c8 | |
Shadowfacts | ad0a9ecafe | |
Shadowfacts | ee630cf9df | |
Shadowfacts | c786c022b8 | |
Shadowfacts | 33649cc5c0 | |
Shadowfacts | 71a10f8514 | |
Shadowfacts | a864f4e344 | |
Shadowfacts | 007d5d6791 | |
Shadowfacts | f176a6c8eb | |
Shadowfacts | 104981f3d3 | |
Shadowfacts | 2ba6b64485 | |
Shadowfacts | 81ac3708a3 | |
Shadowfacts | 8e9e0fa346 | |
Shadowfacts | b6f32ca6be | |
Shadowfacts | e042754be1 | |
Shadowfacts | 38ac5858a9 | |
Shadowfacts | 0c0180264e | |
Shadowfacts | 3d9477f0c9 | |
Shadowfacts | 6f51f321f6 | |
Shadowfacts | ab17a688cf | |
Shadowfacts | 18bc6ce61e | |
Shadowfacts | 765b5e1a7c | |
Shadowfacts | a3e64703ab | |
Shadowfacts | d74be9d81d | |
Shadowfacts | 6ca5bb0c74 | |
Shadowfacts | 76550d8fb8 | |
Shadowfacts | daf3741c9a | |
Shadowfacts | b2977540e0 | |
Shadowfacts | bcc70e9f8c | |
Shadowfacts | 2252b6d09e | |
Shadowfacts | 8deb502140 | |
Shadowfacts | 2582907919 | |
Shadowfacts | 266868376d | |
Shadowfacts | 71fa3910a1 | |
Shadowfacts | 75f290ae8f | |
Shadowfacts | 073a1afbde | |
Shadowfacts | aaa031f212 | |
Shadowfacts | 762d298c06 | |
Shadowfacts | 2a892fa6ec | |
Shadowfacts | cb82826fcf | |
Shadowfacts | 6e5498430f | |
Shadowfacts | 57fb921573 | |
Shadowfacts | d1b5126288 | |
Shadowfacts | 9d2324b587 | |
Shadowfacts | 60921cb95f | |
Shadowfacts | 9e76879ce6 | |
Shadowfacts | 1992a4c60b | |
Shadowfacts | f833bc3a6f | |
Shadowfacts | 4731801893 | |
Shadowfacts | 4293b51c31 | |
Shadowfacts | ecadb83c6d | |
Shadowfacts | 205bdffebd | |
Shadowfacts | ae7ca9c91c | |
Shadowfacts | 841119949b | |
Shadowfacts | b63f663947 | |
Shadowfacts | 00a23b525f | |
Shadowfacts | ea85b11945 | |
Shadowfacts | d8c7eb5cf5 | |
Shadowfacts | 8bc185ecf9 | |
Shadowfacts | 1832e64ad7 | |
Shadowfacts | 87bc1f5f75 | |
Shadowfacts | 6e2f6bb8e9 | |
Shadowfacts | 74d8adfffe | |
Shadowfacts | 99127b617b | |
Shadowfacts | 65ea72c07f | |
Shadowfacts | 04ca932a01 | |
Shadowfacts | 4ea2dff8f1 | |
Shadowfacts | 9f0176350c | |
Shadowfacts | dac1e1fe3f | |
Shadowfacts | afed69e43e | |
Shadowfacts | b2096f22c3 | |
Shadowfacts | 14c456df22 | |
Shadowfacts | 3f34357692 | |
Shadowfacts | 429dcefa88 | |
Shadowfacts | d1a35620c9 | |
Shadowfacts | ce741d6e1f | |
Shadowfacts | 5a82851fe9 | |
Shadowfacts | 92ff900bc0 | |
Shadowfacts | 2a1deb8d7d | |
Shadowfacts | 38eea44a8b | |
Shadowfacts | 2d45fbbd91 | |
Shadowfacts | 32382c4783 | |
Shadowfacts | 521c46c0be | |
Shadowfacts | c114749519 | |
Shadowfacts | 825424cfba | |
Shadowfacts | 985eb24e88 | |
Shadowfacts | 7cadcf1e86 | |
Shadowfacts | a314521b96 | |
Shadowfacts | ab3bad0e16 | |
Shadowfacts | ec75906bc1 | |
Shadowfacts | 137a537f68 | |
Shadowfacts | 91123fd24a | |
Shadowfacts | 597dd56032 | |
Shadowfacts | 37847a2f9f | |
Shadowfacts | 471d3459a6 | |
Shadowfacts | 512eec09a8 | |
Shadowfacts | af8a9faaeb | |
Shadowfacts | 20c4c4bb2f | |
Shadowfacts | 76268e7a14 | |
Shadowfacts | 29596180a1 | |
Shadowfacts | ebfd8b3efd | |
Shadowfacts | 509acbde19 | |
Shadowfacts | 474064669d | |
Shadowfacts | 1940368c43 | |
Shadowfacts | 49c9c69b5a | |
Shadowfacts | ff29f2768b | |
Shadowfacts | 942df433b3 | |
Shadowfacts | 5e2b551045 | |
Shadowfacts | 2e64500c35 | |
Shadowfacts | 7b7c05ff68 | |
Shadowfacts | aec5c0b787 | |
Shadowfacts | d8901b38f5 | |
Shadowfacts | 9d7c876e3c | |
Shadowfacts | 455273f322 | |
Shadowfacts | 16347b2ad0 | |
Shadowfacts | 0e1cbce10d | |
Shadowfacts | 8bd6f53f01 | |
Shadowfacts | fe32356bce | |
Shadowfacts | 1f337613be | |
Shadowfacts | 3f4a62f5f9 | |
Shadowfacts | b506704716 | |
Shadowfacts | 6a3dcca9ee | |
Shadowfacts | edd1e55cbb | |
Shadowfacts | f1facea929 | |
Shadowfacts | d638ea054b | |
Shadowfacts | e11784904b | |
Shadowfacts | 9f1d3804d9 | |
Shadowfacts | 333295367a | |
Shadowfacts | e9d14c6cbf | |
Shadowfacts | 8fc915d6a0 | |
Fahim Farook | 2b4898329f | |
Shadowfacts | 5a9513bb30 | |
Shadowfacts | e45459e556 | |
Shadowfacts | 8b546daeaa | |
Shadowfacts | 125f91257a | |
Fahim Farook | 507d9c23e7 |
|
@ -0,0 +1,157 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
sodipodi:docname="Tusker.svg"
|
||||
inkscape:version="1.0beta2 (2b71d25, 2019-12-03)"
|
||||
inkscape:export-ydpi="11.52"
|
||||
inkscape:export-xdpi="11.52"
|
||||
inkscape:export-filename="/Users/shadowfacts/Desktop/60x60@2x.png"
|
||||
id="svg8"
|
||||
version="1.1"
|
||||
viewBox="0 0 264.58333 264.58333"
|
||||
height="1000"
|
||||
width="1000">
|
||||
<defs
|
||||
id="defs2">
|
||||
<inkscape:path-effect
|
||||
bendpath1-nodetypes="cc"
|
||||
bendpath4="M 27.271345,85.808468 V 178.94843"
|
||||
bendpath3="M 27.271345,178.94843 H 242.39013"
|
||||
bendpath2="M 242.39013,85.808468 V 178.94843"
|
||||
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
|
||||
xx="true"
|
||||
yy="true"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect1345"
|
||||
effect="envelope" />
|
||||
<inkscape:path-effect
|
||||
allow_transforms="true"
|
||||
css_properties=""
|
||||
attributes=""
|
||||
method="d"
|
||||
linkeditem=""
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect38"
|
||||
effect="clone_original" />
|
||||
<inkscape:path-effect
|
||||
scale_y_rel="false"
|
||||
prop_scale="1"
|
||||
strokepath="M0,0 L1,0"
|
||||
endpoint_spacing_variation="0;1"
|
||||
endpoint_edge_variation="0;1"
|
||||
startpoint_spacing_variation="0;1"
|
||||
startpoint_edge_variation="0;1"
|
||||
count="5"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect32"
|
||||
effect="curvestitching" />
|
||||
<filter
|
||||
height="1.3500000000000001"
|
||||
width="1.2"
|
||||
id="filter1277"
|
||||
inkscape:label="Drop Shadow"
|
||||
style="color-interpolation-filters:sRGB;">
|
||||
<feFlood
|
||||
id="feFlood1267"
|
||||
result="flood"
|
||||
flood-color="rgb(0,0,0)"
|
||||
flood-opacity="0.321569" />
|
||||
<feComposite
|
||||
id="feComposite1269"
|
||||
result="composite1"
|
||||
operator="in"
|
||||
in2="SourceGraphic"
|
||||
in="flood" />
|
||||
<feGaussianBlur
|
||||
id="feGaussianBlur1271"
|
||||
result="blur"
|
||||
stdDeviation="5"
|
||||
in="composite1" />
|
||||
<feOffset
|
||||
id="feOffset1273"
|
||||
result="offset"
|
||||
dy="5"
|
||||
dx="-2.5" />
|
||||
<feComposite
|
||||
id="feComposite1275"
|
||||
result="composite2"
|
||||
operator="over"
|
||||
in2="offset"
|
||||
in="SourceGraphic" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="23"
|
||||
inkscape:window-x="1920"
|
||||
inkscape:window-height="1395"
|
||||
inkscape:window-width="1902"
|
||||
units="px"
|
||||
showgrid="false"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:current-layer="layer2"
|
||||
inkscape:document-units="px"
|
||||
inkscape:cy="496.39379"
|
||||
inkscape:cx="442.66632"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
borderopacity="1.0"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff"
|
||||
id="base" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 2"
|
||||
id="layer2"
|
||||
inkscape:groupmode="layer">
|
||||
<rect
|
||||
y="-0.14500916"
|
||||
x="-0.14500916"
|
||||
height="264.87335"
|
||||
width="264.87335"
|
||||
id="rect865"
|
||||
style="fill:#75e04e;fill-opacity:1;stroke:#75e04e;stroke-width:0.239149;stroke-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
style="display:none"
|
||||
id="layer1"
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Layer 1">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||
id="path28" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Layer 1 copy"
|
||||
inkscape:groupmode="layer"
|
||||
id="g1343">
|
||||
<path
|
||||
id="path1341"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.56578600000000001px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 8.1 KiB |
|
@ -0,0 +1,153 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
sodipodi:docname="Tusker transparent.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
inkscape:export-ydpi="98.304001"
|
||||
inkscape:export-xdpi="98.304001"
|
||||
inkscape:export-filename="../Desktop/1024x1024-dark@1x.png"
|
||||
id="svg8"
|
||||
version="1.1"
|
||||
viewBox="0 0 264.58333 264.58333"
|
||||
height="1000"
|
||||
width="1000"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2">
|
||||
<inkscape:path-effect
|
||||
bendpath1-nodetypes="cc"
|
||||
bendpath4="M 27.271345,85.808468 V 178.94843"
|
||||
bendpath3="M 27.271345,178.94843 H 242.39013"
|
||||
bendpath2="M 242.39013,85.808468 V 178.94843"
|
||||
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
|
||||
xx="true"
|
||||
yy="true"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect1345"
|
||||
effect="envelope" />
|
||||
<inkscape:path-effect
|
||||
allow_transforms="true"
|
||||
css_properties=""
|
||||
attributes=""
|
||||
method="d"
|
||||
linkeditem=""
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect38"
|
||||
effect="clone_original" />
|
||||
<inkscape:path-effect
|
||||
scale_y_rel="false"
|
||||
prop_scale="1"
|
||||
strokepath="M0,0 L1,0"
|
||||
endpoint_spacing_variation="0;1"
|
||||
endpoint_edge_variation="0;1"
|
||||
startpoint_spacing_variation="0;1"
|
||||
startpoint_edge_variation="0;1"
|
||||
count="5"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect32"
|
||||
effect="curvestitching" />
|
||||
<filter
|
||||
height="1.317445"
|
||||
width="1.1258237"
|
||||
id="filter1277"
|
||||
inkscape:label="Drop Shadow"
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
x="-0.068723437"
|
||||
y="-0.1318855">
|
||||
<feFlood
|
||||
id="feFlood1267"
|
||||
result="flood"
|
||||
flood-color="rgb(0,0,0)"
|
||||
flood-opacity="0.321569" />
|
||||
<feComposite
|
||||
id="feComposite1269"
|
||||
result="composite1"
|
||||
operator="in"
|
||||
in2="SourceGraphic"
|
||||
in="flood" />
|
||||
<feGaussianBlur
|
||||
id="feGaussianBlur1271"
|
||||
result="blur"
|
||||
stdDeviation="5"
|
||||
in="composite1" />
|
||||
<feOffset
|
||||
id="feOffset1273"
|
||||
result="offset"
|
||||
dy="5"
|
||||
dx="-2.5" />
|
||||
<feComposite
|
||||
id="feComposite1275"
|
||||
result="composite2"
|
||||
operator="over"
|
||||
in2="offset"
|
||||
in="SourceGraphic" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-x="1280"
|
||||
inkscape:window-height="1387"
|
||||
inkscape:window-width="1280"
|
||||
units="px"
|
||||
showgrid="false"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:current-layer="layer2"
|
||||
inkscape:document-units="px"
|
||||
inkscape:cy="404.46507"
|
||||
inkscape:cx="442.29528"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
borderopacity="1.0"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff"
|
||||
id="base"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 2"
|
||||
id="layer2"
|
||||
inkscape:groupmode="layer" />
|
||||
<g
|
||||
style="display:none"
|
||||
id="layer1"
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Layer 1">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||
id="path28" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Layer 1 copy"
|
||||
inkscape:groupmode="layer"
|
||||
id="g1343">
|
||||
<path
|
||||
id="path1341"
|
||||
style="fill:#75e04e;fill-opacity:1;stroke:#74e04d;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 7.9 KiB |
|
@ -0,0 +1,162 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
sodipodi:docname="Tusker.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
inkscape:export-ydpi="98.304001"
|
||||
inkscape:export-xdpi="98.304001"
|
||||
inkscape:export-filename="../Desktop/1024x1024@1x.png"
|
||||
id="svg8"
|
||||
version="1.1"
|
||||
viewBox="0 0 264.58333 264.58333"
|
||||
height="1000"
|
||||
width="1000"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2">
|
||||
<inkscape:path-effect
|
||||
bendpath1-nodetypes="cc"
|
||||
bendpath4="M 27.271345,85.808468 V 178.94843"
|
||||
bendpath3="M 27.271345,178.94843 H 242.39013"
|
||||
bendpath2="M 242.39013,85.808468 V 178.94843"
|
||||
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
|
||||
xx="true"
|
||||
yy="true"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect1345"
|
||||
effect="envelope" />
|
||||
<inkscape:path-effect
|
||||
allow_transforms="true"
|
||||
css_properties=""
|
||||
attributes=""
|
||||
method="d"
|
||||
linkeditem=""
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect38"
|
||||
effect="clone_original" />
|
||||
<inkscape:path-effect
|
||||
scale_y_rel="false"
|
||||
prop_scale="1"
|
||||
strokepath="M0,0 L1,0"
|
||||
endpoint_spacing_variation="0;1"
|
||||
endpoint_edge_variation="0;1"
|
||||
startpoint_spacing_variation="0;1"
|
||||
startpoint_edge_variation="0;1"
|
||||
count="5"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect32"
|
||||
effect="curvestitching" />
|
||||
<filter
|
||||
height="1.317445"
|
||||
width="1.1258237"
|
||||
id="filter1277"
|
||||
inkscape:label="Drop Shadow"
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
x="-0.068723437"
|
||||
y="-0.1318855">
|
||||
<feFlood
|
||||
id="feFlood1267"
|
||||
result="flood"
|
||||
flood-color="rgb(0,0,0)"
|
||||
flood-opacity="0.321569" />
|
||||
<feComposite
|
||||
id="feComposite1269"
|
||||
result="composite1"
|
||||
operator="in"
|
||||
in2="SourceGraphic"
|
||||
in="flood" />
|
||||
<feGaussianBlur
|
||||
id="feGaussianBlur1271"
|
||||
result="blur"
|
||||
stdDeviation="5"
|
||||
in="composite1" />
|
||||
<feOffset
|
||||
id="feOffset1273"
|
||||
result="offset"
|
||||
dy="5"
|
||||
dx="-2.5" />
|
||||
<feComposite
|
||||
id="feComposite1275"
|
||||
result="composite2"
|
||||
operator="over"
|
||||
in2="offset"
|
||||
in="SourceGraphic" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-x="1280"
|
||||
inkscape:window-height="1387"
|
||||
inkscape:window-width="1280"
|
||||
units="px"
|
||||
showgrid="false"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:current-layer="layer2"
|
||||
inkscape:document-units="px"
|
||||
inkscape:cy="496.38895"
|
||||
inkscape:cx="442.29528"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
borderopacity="1.0"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff"
|
||||
id="base"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 2"
|
||||
id="layer2"
|
||||
inkscape:groupmode="layer">
|
||||
<rect
|
||||
y="-0.14500916"
|
||||
x="-0.14500916"
|
||||
height="264.87335"
|
||||
width="264.87335"
|
||||
id="rect865"
|
||||
style="fill:#75e04e;fill-opacity:1;stroke:#75e04e;stroke-width:0.239149;stroke-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
style="display:none"
|
||||
id="layer1"
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Layer 1">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||
id="path28" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Layer 1 copy"
|
||||
inkscape:groupmode="layer"
|
||||
id="g1343">
|
||||
<path
|
||||
id="path1341"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.56578600000000001px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 8.2 KiB |
|
@ -0,0 +1,268 @@
|
|||
## 2024.4
|
||||
This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.
|
||||
|
||||
Features/Improvements:
|
||||
- Import image description when adding attachments from Photos if possible
|
||||
- iPadOS 18: New floating sidebar/tab bar
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when viewing profiles in certain circumstances
|
||||
- Fix video controls in attachment gallery not auto-hiding
|
||||
- Fix crash if hashtag search results includes duplicates
|
||||
- Fix "no content" text not being removed from list timeline after refreshing
|
||||
- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
|
||||
- macOS: Fix reselecting current item not navigating back
|
||||
|
||||
## 2024.3
|
||||
This update includes a number of bugfixes and performance improvements. See below for a list of fixes.
|
||||
|
||||
Bugfixes:
|
||||
- Fix an issue displaying rich text in certain cases
|
||||
- Fix crash when video attachment finishes playing
|
||||
- Fix video attachment thumbnails being flipped on Compose screen
|
||||
- Fix profile header images being blurry
|
||||
- Fix crash when opening push notifications in certain circumstances
|
||||
- Fix certain links in profile fields not being tappable
|
||||
- Fix gifv playback pausing audio from other apps
|
||||
- Fix gifv playback being paused when returning from background
|
||||
- Fix badges on gifv attachments not appearing
|
||||
- Fix excessive network traffic when opening profile pages
|
||||
- Fix controls visibility not matching across attachment gallery pages
|
||||
- Fix add hashtag/instance pinned timeline sheet in Customize Timelines dismissing instantly
|
||||
- Fix Dynamic Type not applying to status content
|
||||
- Fix mention/status push notifications not showing CW
|
||||
- Fix sensitive attachment thumbnails being shown in push notifications
|
||||
- Fix profile moved overlay visual and VoiceOver issues
|
||||
- Fix opening Mastodon remote status links
|
||||
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
|
||||
- Pleroma/Akkoma: Fix editing attachment descriptions not working
|
||||
- Pixelfed/Firefish: Fix error loading certain accounts
|
||||
- Pixelfed: Fix error loading relationships and follow/block/etc. actions
|
||||
- iPadOS: Fix pointer interactions throughout the app
|
||||
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
||||
- iPadOS: Fix Cmd+1/etc. removing columns when returning to previous tab
|
||||
- iPadOS: Fix multi-column interface not animating for some actions
|
||||
- iPadOS: Fix selecting search results always adding new column
|
||||
|
||||
## 2024.2
|
||||
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
|
||||
|
||||
Features/Improvements:
|
||||
- Push notifications
|
||||
- Add post preview to Appearance preferences
|
||||
- Show instance announcements in Notifications tab
|
||||
- Add subscription option to Tip Jar
|
||||
- iPadOS: Multi-column navigation
|
||||
- Pleroma/Akkoma: Emoji reaction notifications
|
||||
|
||||
Bugfixes:
|
||||
- Fix fetching server info on some instances
|
||||
- Fix attachment captions not displaying while loading in gallery
|
||||
- macOS: Remove in-app Safari preferences
|
||||
- Pleroma: Handle posts with missing creation date
|
||||
|
||||
## 2024.1
|
||||
This update includes a significant improvements for the attachment gallery and displaying rich text posts. See below for a full list of improvements and fixes.
|
||||
|
||||
Features/Improvements:
|
||||
- Improve attachment gallery
|
||||
- Improve animations
|
||||
- Display video captions
|
||||
- Support sharing/saving videos
|
||||
- Resume music playback after playing videos
|
||||
- Improve rich text display in posts
|
||||
- Add See Results button to polls
|
||||
- Add Share and Save to Photos menu items to post attachments
|
||||
- Show verified links in account lists
|
||||
- Display message on empty list timelines
|
||||
- Add preference to indicate attachments lacking alt text
|
||||
- Mark notifications as read on Mastodon web frontend once displayed
|
||||
- iPadOS: Support tapping the selected sidebar item to scroll to top
|
||||
|
||||
Bugfixes:
|
||||
- Fix issue changing scope after searching
|
||||
- Fix crash when searching "from:me"
|
||||
- Fix tapping Followers button on profile opening Following screen
|
||||
- Fix crash when removing poll option on Compose screen
|
||||
- Fix hang when sharing video/GIFV attachments
|
||||
- Fix stretched Save to Photos icon when sharing attachments
|
||||
- Fix GIFV playback preventing device sleep
|
||||
- Fix Notifications tab not scrolling to top when tab bar item tapped
|
||||
- Fix selection not clearing on Trending Hashtags
|
||||
- Fix fast account switcher overlapping iPhone sensor housing in landscape
|
||||
- Fix Edit List screen not updating when adding/removing accounts
|
||||
- Fix changing list reply policy not refreshing timeline
|
||||
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
|
||||
- macOS: Fix attachment gallery displaying improperly when Reduce Motion is on
|
||||
|
||||
## 2023.8
|
||||
This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes.
|
||||
|
||||
Features/Improvements:
|
||||
- Show search operators on Mastodon 4.2
|
||||
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
|
||||
- Allow changing list reply policy and exclusivity options on Edit List screen
|
||||
- Add Translate action to conversations (on supported Mastodon instances)
|
||||
- Style block quotes correclty in rich-text posts
|
||||
- Improve the appearance of lists in rich-text posts
|
||||
- Add preference to underline links
|
||||
- Compress uploaded video attachments to fit within instance limits
|
||||
- Add preference to hide attachments in timelines
|
||||
- Update visible timestamps after refresh notifications/timelines
|
||||
- iPadOS: Allow switching between split screen and fullscreen navigation modes
|
||||
- Pixelfed: Improve error message when uploading attachment fails
|
||||
- Akkoma: Enable composing local-only posts
|
||||
|
||||
Bugfixes:
|
||||
- Fix older notifications not loading if all initiially-loaded ones are grouped together
|
||||
- Fix List timelines failing to refresh if they were initially empty
|
||||
- Fix replies to posts with CWs always showing confirmation dialog when cancelling
|
||||
- Fix Compose screen permitting setting the language to multiple/undefined
|
||||
- Fix crash when uploading attachments without file extensions
|
||||
- Fix Live Text button reappearing with swiping between attachment gallery pages
|
||||
- Fix avatars on certain notifications flickering when refreshing
|
||||
- Fix avatars on follow request notifications not being rounded
|
||||
- Fix timeline jump button appearing incorrectly when Button Shapes acccessibility setting is on
|
||||
- Fix public instance timeline screen not handling post deletion correctly
|
||||
- Fix post that's reblogged and contains a followed hashtag not showing the reblogger
|
||||
- Fix crash on launch when reblogged posts are visible
|
||||
- Fix crash when showing display names with custom emoji in certain places
|
||||
- Fix crash when showing trending hashtags without history data
|
||||
- Fix potential crash on instance selector screen
|
||||
- Fix potential crash if the app is dismissed while fast account switcher is animating
|
||||
- Fix potential crash after deleting List on the Eplore screen
|
||||
- Pixelfed: Fix error decoding certain posts
|
||||
- VoiceOver: Fix history entries on Edit History screen not having descriptions
|
||||
- iPadOS: Fix delay on app launch before "My Profile" sidebar item appears
|
||||
- iPadOS: Fix language picker button not highlighting when hovered with the cursor
|
||||
- macOS: Fix "New Post" window title appearing twice
|
||||
- macOS: Fix Cmd+W sometimes closing non-foreground windows
|
||||
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
|
||||
- macOS: Fix images copied from Safari not pasting on Compose screen
|
||||
|
||||
## 2023.7
|
||||
This update adds support for iOS 17 and includes some minor changes.
|
||||
|
||||
Changes:
|
||||
- Support iOS 17
|
||||
- Indicate that edit history may be incomplete for remote posts
|
||||
- Fix crash when collapsing to tab-bar mode in certain circumstances
|
||||
- Fix potential crashes when using autocomplete on the Compose screen
|
||||
- Fix Iceshrimp instances not being detected
|
||||
|
||||
## 2023.6
|
||||
This update fixes a number of bugs and improves stability throughout the app. See below for a list of fixes.
|
||||
|
||||
Bugfixes:
|
||||
- Fix issues displaying main post in the Conversation screen
|
||||
- Fix crash when opening the Compose screen in certain locales
|
||||
- Fix issues when collapsing from sidebar to tab bar mode
|
||||
- Fix incorrect UI being displayed when accessing certain parts of the app immediately after launch
|
||||
- Fix link card images not being blurred on posts marked sensitive
|
||||
- Fix links appearing with incorrect accent color intermittently
|
||||
- Fix being unable to remove followed hashtags from the Explore screen
|
||||
- Akkoma: Fix not being able to follow hashtags
|
||||
- Pleroma: Fix refreshing Mentions failing
|
||||
- iPhone: Fix ducked Compose screen disappearing when rotating on large phones
|
||||
|
||||
## 2023.5
|
||||
This update adds new several Compose-related features, including the ability to edit posts, a share sheet extension, and a post language picker. See below for the full list of improvements and bugfixes.
|
||||
|
||||
Features/Improvements:
|
||||
- Edit posts
|
||||
- Indicate edited posts in timestamp
|
||||
- Show post edit history from Conversation screen
|
||||
- Add Share Sheet extension
|
||||
- Add expanded attachment view on Compose screen
|
||||
- Add an attachment, select the description text field, then tap the expand button
|
||||
- Expanded view allows you to see the attachment while writing the description
|
||||
- Allows playing back videos while writing description
|
||||
- iOS 16: Allows zooming in to the attachment
|
||||
- Add language picker to the Compose screen
|
||||
- Improve Compose screen ducking behavior
|
||||
- Show reblogger's avatar on reblogged posts
|
||||
- Use system photo picker instead of custom interface
|
||||
- Improve hashtag search UI in Customize Timelines
|
||||
- Improve status collapse/expand animation on Notifications screen
|
||||
- Apply filters to Notifications screen
|
||||
- Improve performance when scrolling through timeline
|
||||
- Improve error messages when editing filters
|
||||
- Change favorite/reblog button order to match Mastodon UI
|
||||
- Gracefully handle unknown attachment types
|
||||
- iPadOS: Persist sidebar visibility across
|
||||
|
||||
Bugfixes:
|
||||
- Fix scroll-to-top not working in in-app Safari
|
||||
- Fix inaccruate titles in certain error popups
|
||||
- Fix error decoding post HTML
|
||||
- Fix replied-to account not being the first @-mention
|
||||
- Fix "No Content" message on profiles using wrong background color
|
||||
- Fix reblogged posts appearing in Bookmarks
|
||||
- Fix spurious errors when loading timeline
|
||||
- Fix crash when displaying certain profiles
|
||||
- Fix crash when the server returns invalid notifications
|
||||
- Fix link previews not appearing in Notifications
|
||||
- Fix Notifications screen taking a long time to load
|
||||
- Fix deleted posts not being removed from Notifications screen
|
||||
- Fix crashes when switching between sidebar/tab-bar modes
|
||||
- Fix instance features not being detected on IDNA domains
|
||||
- Fix list/hashtag timelines missing controls when opened in new window
|
||||
- Fix reblog button being enabled on the user's own direct posts
|
||||
- Fix main post in Conversation flickering
|
||||
- Fix link card images not loading on Mastodon
|
||||
- Fix crash when editing filter with the Hide action
|
||||
- Fix certain remote status links not being resolved
|
||||
- Fix Handoff to iPad/Mac presenting new screen modally
|
||||
- GoToSocial: Fix decoding certain posts
|
||||
- Calckey: Fix decoding certain posts
|
||||
- iPadOS: Fix Compose window lacking a title
|
||||
- iPadOS: Fix keyboard focus highlight not showing
|
||||
- macOS: Fix sidebar keyboard shortcuts not working
|
||||
|
||||
## 2023.4
|
||||
Features/Improvements:
|
||||
- Add preference for non-pure-black dark mode
|
||||
- Add Jump to Present button to timelines on the home tab
|
||||
- Consolidate Trends into a single screen
|
||||
- Allow pinning instance public timelines to the Home tab
|
||||
- Add GIF/ALT badges to attachments (and preference to hide them)
|
||||
- Add action to show hide/show reblogs from specific accounts
|
||||
- Add preference to hide link preview cards
|
||||
- Hide placeholder image in link preview card for previews without images
|
||||
- Truncate links in posts
|
||||
- Move Drafts button in Compose screen to nav bar to reduce accidental presses
|
||||
- Load more posts/notifications on each page
|
||||
- Update Bookmarks screen when posts are bookmarked/unbookmarked
|
||||
- Add infinite scrolling to Bookmarks screen
|
||||
- Add Favorites screen to the Explore tab
|
||||
- Make attachment description text selectable in gallery
|
||||
- Add long press to copy username on profile screens
|
||||
- Optimize conversation loading
|
||||
- Apply server-configured poll limits in Compose screen
|
||||
- Add infinite scrolling to trending links/hashtags/posts
|
||||
- Add state restoration for more screens
|
||||
- Persist state when switching between accounts
|
||||
- Add Handoff support for various screens
|
||||
- Add preference to sync timeline position using Mastodon API, rather than iCloud
|
||||
- Show percentage of voters for multi-choice polls, rather than percentage of votes
|
||||
- Display message on remote profiles with no posts
|
||||
- Indicate moved profiles
|
||||
- Make Load More button on timelines more prominent
|
||||
- VoiceOver: Make fast account switcher accessible
|
||||
- VoiceOver: Improve labels for notifications
|
||||
- VoiceOver: Fix custom emoji picker not having labels
|
||||
|
||||
Bugfixes:
|
||||
- Workaround for not being able to sign in to certain instances
|
||||
- Fix timeline position sync not working in certain circumstances
|
||||
- Fix local-only posts not being decodable when logged in to Akkoma instances
|
||||
- Fix Trends sometimes appearing in Explore/sidebar on non-Mastodon instances
|
||||
- Fix favoriters/rebloggers list not resizing on screen rotation
|
||||
- Fix crash when tapping My Profile tab immediately after app launch
|
||||
- Handle authentication required errors on instance public timelines
|
||||
- Fix follow request accept/reject buttons not matching accent color preference
|
||||
- Fix tapping reblog count in conversation main status showing favorites list
|
||||
- Fix crash when certain tags are present in post HTML
|
||||
- Fix crash when opening Report screen in certain circumstances
|
||||
- iPadOS: Fix crash when resizing window while on the Explore screen
|
||||
- iOS 15: Fix accent colors not being displayed in Preferences
|
613
CHANGELOG.md
613
CHANGELOG.md
|
@ -1,5 +1,618 @@
|
|||
# Changelog
|
||||
|
||||
## 2024.5 (139)
|
||||
Bugfixes:
|
||||
- Fix error decoding certain posts
|
||||
|
||||
## 2024.5 (138)
|
||||
Bugfixes:
|
||||
- Fix potential crash when displaying certain attachments
|
||||
- Fix potential crash due to race condition when opening push notification in app
|
||||
- Fix misaligned text between profile field values/labels
|
||||
- Fix rate limited error message not including reset timestamp
|
||||
- iPadOS/macOS: Fix Cmd+R shortcut not working
|
||||
|
||||
## 2024.5 (137)
|
||||
Features/Improvements:
|
||||
- Improve gallery presentation/dismissal transitions
|
||||
|
||||
Bugfixes:
|
||||
- Account for bidirectional text in display names
|
||||
- Fix crash when playing back gifv
|
||||
- Fix gallery controls not hiding if video loading fails
|
||||
- iPadOS: Fix incorrect gallery dismiss animation on non-fullscreen windows
|
||||
- iPadOS: Fix hang when switching accounts
|
||||
|
||||
## 2024.4 (136)
|
||||
Features/Improvements:
|
||||
- Import image description when adding attachments from Photos if possible
|
||||
- Reorganize toolbar buttons when adding saved hashtag
|
||||
- Show errors when loading video in attachment gallery fails
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when viewing profiles in certain circumstances
|
||||
- Fix profile tab switching animation getting stuck
|
||||
- Fix video controls in attachment gallery not auto-hiding
|
||||
- Pleroma: Fix error when loading polls in some circumstances
|
||||
- iPadOS 18: Fix incorrect two-column layout when closing sidebar
|
||||
- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
|
||||
- macOS: Fix reselecting current item not navigating back
|
||||
|
||||
## 2024.4 (135)
|
||||
Features/Improvements:
|
||||
- iOS 18: New floating sidebar/tab bar
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when hashtag search results include duplicates
|
||||
- Fix "no content" text not being removed from list timeline after refreshing
|
||||
|
||||
## 2024.3 (133)
|
||||
- Add additional info to Tip Jar
|
||||
|
||||
## 2024.3 (132)
|
||||
- Add ToS nag before signing in
|
||||
|
||||
## 2024.3 (131)
|
||||
Bugfixes:
|
||||
- Fix Cmd+3 not correctly switching to Explore tab
|
||||
|
||||
## 2024.3 (130)
|
||||
Bugfixes:
|
||||
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
|
||||
- Fix crash when dragging between buttons in reblog confirmation alert
|
||||
- Fix potential crash when displaying search results
|
||||
- Mac: Fix Post button not displaying on Compose screen
|
||||
|
||||
## 2024.3 (129)
|
||||
Bugfixes:
|
||||
- Fix excessive network traffic on profile pages
|
||||
- Fix attachment gallery controls visibility not being synced between pages
|
||||
- Fix video attachments not restarting when play pressed while at ends
|
||||
- Fix profile field text being misaligned
|
||||
- Fix at sign in timeline statuses usernames sometimes clipping
|
||||
- Fix add hashtag/instance to Pinned Timelines sheets dismissing immediately when opened
|
||||
- Fix for display name being replaced with incorrect user in certain circumstances
|
||||
- Fix profile moved overlay view appearing behind avatar/header
|
||||
- Fix profile moved view accessibility with VoiceOver
|
||||
- Fix mention/status push notifications not showing content warning
|
||||
- Fix sensitive attachment thumbnails being shown in push notifications
|
||||
- Fix Dynamic Type not applying to status content
|
||||
- Fix expand all option in Conversation not transferring when opening ancestors
|
||||
- Fix not being able to resolve remote Mastodon status links in Conversation screen
|
||||
- Fix status indicator icons overlapping thread links when Dynamic Type is enabled
|
||||
|
||||
## 2024.3 (128)
|
||||
Bugfixes:
|
||||
- Fix selecting poll option playing too much haptic feedback
|
||||
- Fix crash when displaying HTML in certain posts
|
||||
- Fix gifv playback pausing audio from other apps
|
||||
- Fix gifv playback not resuming after returning from background
|
||||
- Fix attachment badges not appearing on gifvs
|
||||
- iPadOS: Fix poll options not having pointer hover effects
|
||||
- iPadOS: Fix haptic feedback not working on new Magic Keyboard
|
||||
- iPadOS: Fix scrubbing video with pointer not letting you click to select position
|
||||
- iPadOS: Fix multi-column navigation not animating when replacing multiple columns
|
||||
|
||||
## 2024.3 (127)
|
||||
Bugfixes:
|
||||
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen
|
||||
- Fix profile header images being blurry
|
||||
- Fix dismissing gallery when presented from sheet
|
||||
- Fix potential crash in multi-column interface
|
||||
- Fix crash when opening push notification while sheet presented
|
||||
- Fix being able to block your own domain
|
||||
- Fix links in profile fields with other text not being interactable
|
||||
- Fix excessive CPU use immediately after app launch
|
||||
- Fix timeline failing to load when one status is malformed
|
||||
- iPadOS: Fix pointer interactions on conversation main status action buttons
|
||||
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
||||
- iPadOS: Fix Cmd+1/etc. resetting navigation state when returning to previous column
|
||||
- iPadOS: Fix previous sidebar selection losing navigation state in some circumstances
|
||||
- iPadOS: Fix profile followers/following buttons not having pointer effect
|
||||
- iPadOS: Fix search token suggestions not having pointer effect
|
||||
- iPadOS: Fix conversation thread links appearing above avatar during pointer effect
|
||||
- iPadOS: Fix multi-column interface not animating scroll when replacing subsequent columns
|
||||
- iPadOS: Fix not being able to select text on conversation main status by double-clicking with cursor
|
||||
- iPadOS: Fix selecting search result always pushing new column rather than replacing
|
||||
- Pixelfed/Firefish: Fix error loading accounts in some circumstances
|
||||
- Pixelfed: Fix loading relationships and follow/block/etc. actions not working
|
||||
|
||||
## 2024.3 (126)
|
||||
Bugfixes:
|
||||
- Fix an issue displaying post HTML in certain edge cases
|
||||
- Fix crash when video attachment playback ends
|
||||
- Fix excessive CPU usage when scrubbing video attachment
|
||||
- Fix video attachment thubmnails being flipped on Compose screen
|
||||
- Pleroma: Fix editing attachment descriptions not working
|
||||
|
||||
## 2024.2 (124)
|
||||
Features/Improvements:
|
||||
- Add subscription option to Tip Jar
|
||||
|
||||
Bugfixes:
|
||||
- Fix attachment captions not displaying while loading in gallery
|
||||
- Fix tapping follow request push notification not working
|
||||
- Pleroma: Handle posts with missing creation dates
|
||||
|
||||
## 2024.2 (122)
|
||||
Features/Improvements:
|
||||
- Show instance announcements in Notifications
|
||||
- Pleroma/Akkoma: Display emoji reactions in Notifications
|
||||
- Pleroma/Akkoma: Add push notifications for emoji reactions
|
||||
|
||||
Bugfixes:
|
||||
- Fix issue fetching server info on some instances
|
||||
- Fix Preferences background color not updating after changing Pure Black Dark Mode
|
||||
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
|
||||
|
||||
## 2024.2 (121)
|
||||
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.
|
||||
|
||||
Features/Improvements:
|
||||
- iPadOS: Enable multi-column navigation
|
||||
- Add post preview to Appearance preferences
|
||||
- Consolidate Media preferences section with Appearance
|
||||
- Add icons to Preferences sections
|
||||
|
||||
Bugfixes:
|
||||
- Fix push notifications not working on Pleroma/Akkoma and older Mastodon versions
|
||||
- Fix push notifications not working with certain accounts
|
||||
- Fix links on About screen not being aligned
|
||||
- macOS: Remove non-functional in-app Safari preferences
|
||||
|
||||
## 2024.2 (120)
|
||||
This build adds push notifications, which can be enabled in Preferences -> Notifications.
|
||||
|
||||
## 2024.1 (119)
|
||||
Features/Improvements:
|
||||
- Add Account Settings button to Preferences
|
||||
|
||||
## 2024.1 (118)
|
||||
Bugfixes:
|
||||
- Fix music not pausing/resuming when video playback starts
|
||||
|
||||
## 2024.1 (117)
|
||||
Features/Improvements:
|
||||
- Add See Results button to polls
|
||||
|
||||
Bugfixes:
|
||||
- Fix race condition when presenting gallery for 4th of more than 4 attachments
|
||||
- Fix gallery interactive dismissal not working for 4th or later attachments on posts with more than 4 attachments
|
||||
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
|
||||
- macOS: Fix gallery being positioned incorrectly when Reduce Motion is on
|
||||
|
||||
## 2024.1 (116)
|
||||
Features/Improvements:
|
||||
- Display message on empty list timelines
|
||||
- Add preference to display badge for attachments that lack alt text
|
||||
- Mark notifications as read on the Mastodon web frontend once displayed
|
||||
- iPadOS: Support tapping the selected sidebar item to scroll to top
|
||||
|
||||
Bugfixes:
|
||||
- Fix playing back GIFVs preventing the device sleeping
|
||||
- Fix incorrect cell separator insets followers/following lists
|
||||
- Fix memory leak in attachments gallery
|
||||
- Fix notifications tab not scrolling to top when tab bar item tapped
|
||||
- Fix Trending Hashtags screen not clearing selection
|
||||
- Fix fast account switcher overlapping sensor housing on landscape iPhones
|
||||
- Fix Edit List screen not updating when accounts are added/removed
|
||||
- Fix changing List reply policy not refreshing list timeline
|
||||
- macOS: Fix certain gallery attachments being incorrectly sized/positioned
|
||||
|
||||
## 2024.1 (115)
|
||||
Features/Improvements:
|
||||
- Rewrite attachment gallery
|
||||
- Fixes a number of long-standing issues
|
||||
- Adds a custom video player that shows controls and caption
|
||||
- Supports sharing/saving videos
|
||||
|
||||
Bugfixes:
|
||||
- Fix hang when sharing video/gifv attachments
|
||||
- Fix stretched icon for Save to Photos action when sharing attachment
|
||||
- Fix crash when Compose screen is dismissed while adding attachments
|
||||
- Fix crash when sharing attachment from context menu on iPad
|
||||
|
||||
## 2024.1 (113)
|
||||
Features/Improvements:
|
||||
- Add Share and Save to Photos context menu actions to attachments
|
||||
- Show verified link in account lists
|
||||
- Change cell separator appearance on posts
|
||||
|
||||
Bugfixes:
|
||||
- Fix tapping Followers button on profiles opening Following screen
|
||||
- Fix crash when removing poll option on Compose screen
|
||||
- Fix leading indentation in post text being ignored
|
||||
- Fix crash when viewing posts containing HTML numeric character references
|
||||
- Fix paragraphs starting with links being combined with previous paragraph
|
||||
|
||||
## 2024.1 (112)
|
||||
Bugfixes:
|
||||
- Fix profile field links not displaying
|
||||
- Fix various issues displaying rich text in posts
|
||||
- Fix issue changing scope after searching
|
||||
- Fix crash when searching for "from:me"
|
||||
|
||||
## 2024.1 (111)
|
||||
This build contains a complete rewrite of the HTML parsing pipeline for displaying posts. If you notice any issues with how post text appears—especially when it differs from on the web—please report it!
|
||||
|
||||
## 2023.8 (110)
|
||||
Bugfixes:
|
||||
- Fix potential crash after deleting List on Explore screen
|
||||
|
||||
## 2023.8 (109)
|
||||
Features/Improvements:
|
||||
- Add Translate action to conversations (on supported Mastodon instances)
|
||||
- Improve share extension launch speed
|
||||
- Add preference for hiding attachments in timelines
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash during state restoration when reblogged statuses are present
|
||||
- Fix timeline state restoration using incorrect scroll position in certain circumstances
|
||||
- Fix status that is reblogged and contains a followed hashtag not showing reblogger label
|
||||
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
|
||||
- macOS: Fix images copied from Safari not pasting on Compose screen
|
||||
|
||||
## 2023.8 (107)
|
||||
Features/Improvements:
|
||||
- Style blockquotes in statuses
|
||||
- Use server language preference for search operator suggestions
|
||||
- Render IDN domains in the account switcher
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when showing trending hashtags with improper history data
|
||||
- Fix crash when uploading attachment w/o file extension
|
||||
- Fix status deletions not being handled properly in logged out views
|
||||
- Fix status history entries not having VoiceOver descriptions
|
||||
- Fix avatars in follow request notifications not being rounded
|
||||
- Fix potential crash if the app is dismissed while fast account switcher is animating
|
||||
- Fix error decoding certain statuses on Pixelfed
|
||||
|
||||
## 2023.8 (106)
|
||||
Bugfixes:
|
||||
- Fix being able to set post language to multiple/undefined
|
||||
- iPadOS: Fix language picker button not having a pointer effect
|
||||
- macOS: Fix Cmd+W sometimes closing the non-foreground window
|
||||
|
||||
## 2023.8 (105)
|
||||
Features/Improvements:
|
||||
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
|
||||
- Add preference to underline links
|
||||
- Allow changing list reply policy and exclusivity from menu on Edit List screen
|
||||
- Attribute network requests to user, rather than developer, when appropriate
|
||||
|
||||
Bugfixes:
|
||||
- Fix older notifications not loading if all initially-loaded are grouped together
|
||||
- Fix list timelines failing to refresh if there were no statuses initially
|
||||
- Fix timeline jump button having a background when Button Shapes accessibility setting is on
|
||||
- Fix crash when relaunching app after not being launched in more than a week
|
||||
- Fix potential crash on instance selector screen
|
||||
- Fix crash when showing display names with custom emojis in certain places
|
||||
|
||||
## 2023.8 (104)
|
||||
Features/Improvements:
|
||||
- Show search operators on Mastodon 4.2
|
||||
- Enable composing local-only posts on Akkoma
|
||||
- Update timestamps after refreshing notifications/timelines
|
||||
- Improve list appearance in rich text posts
|
||||
- Improve error message when uploading attachment to Pixelfed fails
|
||||
- Compress uploaded videos to fit within instance limits
|
||||
- iPad: Allow switching between split screen and full screen navigation
|
||||
|
||||
Bugfixes:
|
||||
- Fix replies to posts with content warnings always showing confirmation dialog before closing
|
||||
- Fix Live Text control reappearing when swiping between attachment gallery pages
|
||||
- Fix avatars on certain notifications flickering when refreshing
|
||||
- iPad: Fix delay on app launch before "My Profile" sidebar item appears
|
||||
- macOS: Fix "New Post" window title appearing twice
|
||||
|
||||
## 2023.7 (103)
|
||||
Features/Improvements:
|
||||
- Add support for iOS 17
|
||||
- Indicate that edit history may be incomplete for remote posts
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when collapsing to tab-bar mode in certain circumstances
|
||||
- Fix potential crashes when using autocomplete on the Compose screen
|
||||
- Fix Iceshrimp instances not being detected
|
||||
|
||||
## 2023.6 (100)
|
||||
Bugfixes:
|
||||
- Fix Conversation main post flashing incorrect background color when touched
|
||||
- Fix reblogs count button in Conversation main post not being left-aligned
|
||||
- Fix Conversation main post flickering when context loaded
|
||||
- Fix context menu not appearing when long pressing finished/voted poll
|
||||
- Fix Tip Jar button width changing while purchasing
|
||||
- Fix crash when opening Compose screen in certain locales
|
||||
- Fix potential issue with Recognize Text context menu action on attachments
|
||||
- Fix attachment deletion context menu action not working
|
||||
- Fix crash when collapsing from sidebar to tab bar mode
|
||||
- Fix crash when post deleted before Notifications screen is loaded
|
||||
- Fix race conditions when accessing certain parts of the app immediately upon launch
|
||||
- Fix crash when viewing invalid user post notifications
|
||||
- Fix non-square avatars not displaying correctly in various places
|
||||
- Fix incorrect context menu preview being shown on filtered posts
|
||||
- Fix link card images not being blurred on sensitive posts
|
||||
- Fix reblog confirmation alert showing incorrect visibilities for non-public posts
|
||||
- Fix Home/Notifications tab switchers being cut off with smaller than default Dynamic Type sizes
|
||||
- Fix posts using incorrect accent color for links in certain circumstances
|
||||
- Fix not being able to remove followed hashtags from Explore screen
|
||||
- Fix not being able to attach images from Markup share sheet or Shortcuts share action
|
||||
- Fix very wide attachments being untappably short
|
||||
- Fix double posting in poor network conditions
|
||||
- Fix crash when autocompleting emoji on instances with a large number of custom emoji
|
||||
- Akkoma: Fix not being able to follow hashtags
|
||||
- Pleroma: Fix refreshing Mentions failing
|
||||
- iPhone: Fix ducked Compose screen breaking when rotating on Plus/Max iPhone models
|
||||
- iPhone: Fix Compose toolbar not extending to the full width of the screen in landscape on iPhone
|
||||
- iPadOS: Fix closing app dismissing in-app Safari
|
||||
- iPadOS: Fix reblog confirmation alert not being centered in split view
|
||||
|
||||
## 2023.5 (98)
|
||||
Bugfixes:
|
||||
- Fix broken animation when opening/closing expanded attachment view on Compose screen
|
||||
|
||||
## 2023.5 (97)
|
||||
Features/Improvements:
|
||||
- Change favorite/reblog button order to match Mastodon
|
||||
- Use QuickLook as a fallback for uknown attachment types
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when adding drawing attachment
|
||||
|
||||
## 2023.5 (96)
|
||||
Features/Improvements:
|
||||
- Resolve Mastodon's remote status links
|
||||
|
||||
Bugfixes:
|
||||
- Fix handoff to iPad/Mac presenting new screen modally rather than navigating
|
||||
- Fix crash if timeline gap cell is accessibility-activated after leaking
|
||||
- Fix various crashes when multiple Compose/Drafts screens are opened
|
||||
- Delete orphaned draft attachments
|
||||
- Fix deleted posts not getting removed from Notifications screen
|
||||
- Fix replied-to status not changing when selecting draft
|
||||
|
||||
## 2023.5 (94)
|
||||
Features/Improvements:
|
||||
- Apply filters to Notifications screen
|
||||
|
||||
Bugfixes:
|
||||
- Fix editing posts not working on Akkoma
|
||||
- Fix editing Markdown/HTML posts
|
||||
- Fix crash when editing filter with Hide action
|
||||
|
||||
## 2023.5 (91)
|
||||
Features/Improvements:
|
||||
- Improve performance when scrolling through timeline
|
||||
- Improve error messages when editing filters
|
||||
- Enable editing posts on Pleroma 2.5+
|
||||
|
||||
Bugfixes:
|
||||
- Fix share sheet extension not working with Apple News
|
||||
- Fix crash when sharing certain photos with share extension
|
||||
- Fix reblog button being enabled on Direct posts
|
||||
- Fix expanded statuses collapsing when opening Conversation
|
||||
- Fix main post in Conversation flickering when context loaded
|
||||
- Fix link card images not loading on Mastodon
|
||||
|
||||
## 2023.5 (89)
|
||||
This build is a hotfix for an issue loading notifications in certain circumstances. The changelong for the previous build (adding post editing) is included below.
|
||||
|
||||
## 2023.5 (85)
|
||||
This build adds support for editing posts and showing edit timestamps and history.
|
||||
|
||||
Features/Improvements:
|
||||
- Post editing
|
||||
- Show post edit history
|
||||
- Improve rate limit exceeded error message
|
||||
- Shorten hashtag save/follow action subtitles so they fit in the context menu
|
||||
- Remove Hide/Show Reblogs action for accounts the user isn't following
|
||||
|
||||
Bugfixes:
|
||||
- Fix nodeinfo not being fetched on instances with punycode domains
|
||||
- Fix potential crash with interactive push gesture
|
||||
- Fix list timelines opened in new window lacking Edit button
|
||||
- Fix hashtag timelines opened in new window lacking save/follow actions
|
||||
- Fix being able to scroll to top while fast account switcher is active
|
||||
- Fix decoding statuses lacking emojis on Calckey
|
||||
- Fix decoding polls on Calckey
|
||||
|
||||
## 2023.5 (84)
|
||||
Bugfixes:
|
||||
- Fix notifications scrolling to top when refreshing
|
||||
- Fix decoding statuses failing on GoToSocial
|
||||
- Fix assorted issues when collapsing/expanding between sidebar and tab bar modes
|
||||
|
||||
## 2023.5 (83)
|
||||
This build contains significant refactors to the notifications screen, please report any issues you encounter.
|
||||
|
||||
Features/Improvements:
|
||||
- Tweak appearance of profile fields
|
||||
- Make language picker sheet half-height
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when laying out profile fields on certain accounts
|
||||
- Fix other presented screens getting dismissed when opened after closing expanded attachment view
|
||||
- Fix janky status collapse/expand animation on notifications screen
|
||||
- Fix link previews not appearing in notifications
|
||||
|
||||
## 2023.5 (81)
|
||||
Further improvements and fixes to the Compose screen, see below. Features are frozen for the upcoming release, please report any bugs you encounter!
|
||||
|
||||
Features/Improvements:
|
||||
- Add expanded attachment view on Compose screen
|
||||
- Add an attachment, select the description text field, and tap on the expand button on the attachment thumbnail
|
||||
- Expanded attachment view allows you to view the attachment larger while writing the description
|
||||
- Plays back videos while writing the description
|
||||
- iOS 16: Allow zooming in to expanded attachment view
|
||||
- Add language picker to Compose screen
|
||||
- Persist sidebar visibility across app launches
|
||||
- Align link verification checkmarks to link rather than creen edge
|
||||
- Fully dismiss, rather than ducking, the Compose screen when swiped down with no content
|
||||
- Remove Automatically Save Drafts preference
|
||||
- Drafts are always saved automatically, and the save/delete sheet is now always shown on dismiss
|
||||
|
||||
Bugfixes:
|
||||
- Fix share sheet extension being unavailable on iOS 15
|
||||
- Fix crash when loading draft with poll from share sheet extension
|
||||
- Fix active draft being deleted when Compose screen ducked
|
||||
- Fix restored, ducked Compose screen lacking title
|
||||
- Fix error when reloading empty profile
|
||||
- Fix local attachments not being deleted upon draft deletion
|
||||
- Fix GIFs being converted to still images on upload
|
||||
- Fix crash on deleting draft with attachments in share extension
|
||||
- Fix deleted attachments in Compose screen reappearing
|
||||
- Fix spinner on Send Report button being misplaced
|
||||
- Fix crash on launch loop when migrating from previous version in certain circumstances
|
||||
|
||||
## 2023.5 (80)
|
||||
This build adds a Share Sheet extension and introduces further Compose screen refactors.
|
||||
|
||||
Features/Improvements:
|
||||
- Add Share Sheet extension
|
||||
- Show reblogger's avatar on reblogged posts
|
||||
|
||||
Bugfixes:
|
||||
- Fix not being able to close Compose screen when Automatically Save Drafts preference is off
|
||||
- Fix Post button always being disabled when Require Attachment Descriptions preference is on
|
||||
- Fix crash when pasting screenshots
|
||||
- Fix not being able to paste gifs
|
||||
- Don't consider HTTP 206 responses to timeline requests to be errors
|
||||
- Fix crash when displaying menu for statuses missing URLs
|
||||
- Fix errors while posting not displaying useful error messages
|
||||
|
||||
## 2023.5 (77)
|
||||
The Compose screen has been substantially refactored in this build, in preparation for upcoming features, so please report any issues you encounter!
|
||||
|
||||
Features/Improvements:
|
||||
- Use system photo picker instead of custom interface
|
||||
- Improve Customize Timelines hashtag search UI
|
||||
|
||||
Bugfixes:
|
||||
- Fix scroll-to-top not working in in-app Safari
|
||||
- Fix crash when decoding pinned timelines fails
|
||||
- Fix inaccurate titles in certain error popups
|
||||
- Fix crash when comments present in status HTML
|
||||
- Fix replied-to account not being the first mention
|
||||
- Fix Compose window not having title set initially
|
||||
- Fix crash when the API returns notifications that are missing statuses
|
||||
- Fix "No Content" cell on profiles not using non-pure-black background
|
||||
- Fix reblogged statuses appearing in the Bookmarks list
|
||||
- Fix keyboard focus highlight not showing
|
||||
- macOS: Fix sidebar item keyboard shortcuts not working
|
||||
|
||||
## 2023.4 (76)
|
||||
App Store release
|
||||
|
||||
## 2023.4 (75)
|
||||
This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below.
|
||||
|
||||
## 2023.4 (74)
|
||||
Features/Improvements:
|
||||
- Add state restoration for more screens
|
||||
- Persist state when switching between accounts
|
||||
- Add handoff for various screens
|
||||
- Add preference to hide GIF/ALT badges on attachments
|
||||
- Add preference to use Mastodon timeline marker API for syncing Home timeline position
|
||||
- Show percentage of voters for multi-choice poll results, rather than percentage of votes
|
||||
- Change search results view controller to dismiss keyboard on scroll
|
||||
- Only show inaccurate favorite/reblog count warning for posts from remote instances
|
||||
- Show message on remote profiles with no statuses
|
||||
- Add banner to profiles that have moved
|
||||
- Hide placeholder image for link cards without images
|
||||
- Don't check for present statuses when refreshing timeline
|
||||
- Make timeline Load More button more prominent
|
||||
- iOS 16.4: Use iOS-provided link previews in Share Sheet
|
||||
|
||||
Bugfixes:
|
||||
- Fix tapping reblog count in conversation main status showing favorites list
|
||||
- Fix status favorite/reblog list not adjusting to non-pure-black dark mode
|
||||
- Fix non-pure-black dark mode not applying to auxiliary windows
|
||||
- Fix poll option tracking gesture unselecting options when touch location moves between options
|
||||
- Fix crash when tapping conversation "More Replies" cell
|
||||
- Fix crash when script/style tags are present in post HTML
|
||||
- Fix crash when opening Report screen in certain circumstances
|
||||
|
||||
## 2023.4 (73)
|
||||
Features/Improvements:
|
||||
- Add preference for non-pure-black dark mode
|
||||
- Add Jump to Present button to timelines
|
||||
- Improve status collapse animation in search results screen
|
||||
- Add more trending links/hashtags/profiles buttons to Trends screen
|
||||
- Add infinite scrolling to trending links/hashtags screens
|
||||
- Add Share action to trending link context menu
|
||||
|
||||
Bugfixes:
|
||||
- Fix icon in suggested profile popover not adjusting to dark mode
|
||||
|
||||
## 2023.4 (72)
|
||||
Features/Improvements:
|
||||
- Consolidate Trends into a single screen
|
||||
- Make attachment description text selectable in gallery
|
||||
- Add long press to copy usernames on profile screen
|
||||
- Add Favorites screen to Explore tab
|
||||
- Optimize conversation loading when opening a conversation that is already fully-loaded
|
||||
- Apply Mastodon poll limits in Compose screen
|
||||
- VoiceOver: Fast account switcher improvements (make the screen modal, select the first account upon opening the switcher, make each account a single item)
|
||||
- VoiceOver: Improve labels for notifications
|
||||
- VoiceOver: Fix custom emoji picker buttons not having labels
|
||||
|
||||
Bugfixes:
|
||||
- Fix trends sometimes appearing in Explore/sidebar on non-Mastodon instances
|
||||
- Fix status favorite/reblog accounts list not resizing on device rotation
|
||||
- Fix bookmarks screen sometimes going haywire
|
||||
- Fix trending statuses not being deselected upon navigating back
|
||||
- Fix crash when tapping My Profile tab too early in app lifecycle
|
||||
- Handle 401 errors on instance timelines properly
|
||||
- Fix potential crash when showing context menu previews for status
|
||||
- Fix follow request accept/reject buttons not matching accent color preference
|
||||
- iPadOS: Fix crash when switching between sidebar and tab bar while on the Explore screen
|
||||
- iOS 15: Fix accent colors not being disaplyed in Preferences
|
||||
|
||||
## 2023.4 (71)
|
||||
Features/Improvements:
|
||||
- Allow pinning instance public timelines to the Home tab
|
||||
- Improve UI and retry mechanism when adding account
|
||||
- Increase page size to 40 on a bunch of screens
|
||||
- Update bookmarks screen when posts are bookmarked/unbookmarked
|
||||
- Allow loading older and refreshing bookmarks screen
|
||||
- Tweak follow count button color
|
||||
|
||||
Bugfixes:
|
||||
- Fix timeline position sync not working in certain circumstances
|
||||
- iPadOS: Fix flicker when opening favorite/reblog list in notificationss
|
||||
|
||||
## 2023.4 (70)
|
||||
Features/Improvements:
|
||||
- Add GIF/ALT badges to attachments
|
||||
- Add menu action to hide/show reblogs from specific accounts
|
||||
- Apply Mastodon's link truncation
|
||||
- Add preference to hide link preview cards
|
||||
- Tweak link preview card border color in dark mode
|
||||
- Unify haptic feedback across the app
|
||||
- Move Drafts button to the nav bar when the post doesn't have any content, to reduce accidental presses
|
||||
|
||||
Bugfixes:
|
||||
- Fix status URLs with fragments not being resolved
|
||||
- Workaround for local-only posts not being decodable when logged in to Akkoma instances
|
||||
|
||||
## 2023.3 (69)
|
||||
Features/Improvements:
|
||||
- Add Tip Jar under Preferences
|
||||
- Add scopes to search screen
|
||||
- Workarounds for logging in to mastodon.social being unreliable
|
||||
- Handle instance timeline authentication errors more gracefully
|
||||
- iPadOS: Add Trending Posts and Profile Suggestions to Explore screen
|
||||
|
||||
Bugfixes:
|
||||
- Fix Open in Safari action not working
|
||||
- Fix notifications for posts from subscribed accounts not being shown
|
||||
- Detect Misskey status links
|
||||
- Fix trending hashtag cells not adjusting to Dynamic Type
|
||||
- Fix raw HTML being shown in link preview cards
|
||||
- iPadOS: Fix crash when expanding window size while showing trending statuses/hashtags/links screen
|
||||
- iPadOS: Fix preview actions not working on Explore screen
|
||||
- macOS: Fix not being able to right-click to remove pinned timelines
|
||||
|
||||
## 2023.2 (68)
|
||||
Bugfixes:
|
||||
- Fix crash when inserting present items in empty timeline
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
# Haptic Feedback
|
||||
|
||||
## Selection changed
|
||||
`UISelectionFeedbackGenerator`
|
||||
|
||||
## Actions
|
||||
On success, `UIImpactFeedbackGenerator` with the `.light` style. On error, `UINotificationFeedbackGenerator` with the `.error` type.
|
|
@ -0,0 +1,22 @@
|
|||
<?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>TuskerInfo</key>
|
||||
<dict>
|
||||
<key>PushProxyHost</key>
|
||||
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
|
||||
<key>PushProxyScheme</key>
|
||||
<string>$(TUSKER_PUSH_PROXY_SCHEME)</string>
|
||||
<key>SentryDSN</key>
|
||||
<string>$(SENTRY_DSN)</string>
|
||||
</dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,14 @@
|
|||
<?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>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.$(BUNDLE_ID_PREFIX).Tusker</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,385 @@
|
|||
//
|
||||
// NotificationService.swift
|
||||
// NotificationExtension
|
||||
//
|
||||
// Created by Shadowfacts on 4/9/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UserNotifications
|
||||
import UserAccounts
|
||||
import PushNotifications
|
||||
import CryptoKit
|
||||
import OSLog
|
||||
import Pachyderm
|
||||
import Intents
|
||||
import HTMLStreamer
|
||||
import UIKit
|
||||
import TuskerPreferences
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
|
||||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self)
|
||||
|
||||
private var pendingRequest: (UNMutableNotificationContent, (UNNotificationContent) -> Void, Task<Void, Never>)?
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
logger.error("Couldn't get mutable content")
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
guard request.content.userInfo["v"] as? Int == 1,
|
||||
let accountID = (request.content.userInfo["ctx"] as? String).flatMap(\.removingPercentEncoding),
|
||||
let account = UserAccountsManager.shared.getAccount(id: accountID),
|
||||
let subscription = getSubscription(account: account),
|
||||
let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }),
|
||||
let salt = (request.content.userInfo["salt"] as? String).flatMap(decodeBase64URL(_:)),
|
||||
let serverPublicKeyData = (request.content.userInfo["pk"] as? String).flatMap(decodeBase64URL(_:)) else {
|
||||
logger.error("Missing info from push notification")
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
guard let body = decryptNotification(subscription: subscription, serverPublicKeyData: serverPublicKeyData, salt: salt, encryptedBody: encryptedBody) else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
let withoutPadding = body.dropFirst(2)
|
||||
|
||||
let notification: PushNotification
|
||||
do {
|
||||
notification = try JSONDecoder().decode(PushNotification.self, from: withoutPadding)
|
||||
} catch {
|
||||
logger.error("Unable to decode push payload: \(String(describing: error))")
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
mutableContent.title = notification.title
|
||||
mutableContent.body = notification.body
|
||||
mutableContent.userInfo["notificationID"] = notification.notificationID
|
||||
mutableContent.userInfo["accountID"] = accountID
|
||||
mutableContent.targetContentIdentifier = accountID
|
||||
|
||||
let task = Task {
|
||||
await updateNotificationContent(mutableContent, account: account, push: notification)
|
||||
if !Task.isCancelled {
|
||||
contentHandler(pendingRequest?.0 ?? mutableContent)
|
||||
pendingRequest = nil
|
||||
}
|
||||
}
|
||||
pendingRequest = (mutableContent, contentHandler, task)
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
if let pendingRequest {
|
||||
logger.debug("Expiring with pending request")
|
||||
pendingRequest.2.cancel()
|
||||
pendingRequest.1(pendingRequest.0)
|
||||
self.pendingRequest = nil
|
||||
} else {
|
||||
logger.debug("Expiring without pending request")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNotificationContent(_ content: UNMutableNotificationContent, account: UserAccountInfo, push: PushNotification) async {
|
||||
let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken)
|
||||
let notification: Pachyderm.Notification
|
||||
do {
|
||||
notification = try await client.run(Client.getNotification(id: push.notificationID)).0
|
||||
} catch {
|
||||
logger.error("Error fetching notification: \(String(describing: error))")
|
||||
return
|
||||
}
|
||||
|
||||
let kindStr: String?
|
||||
switch notification.kind {
|
||||
case .reblog:
|
||||
kindStr = "🔁 Reblogged"
|
||||
case .favourite:
|
||||
kindStr = "⭐️ Favorited"
|
||||
case .follow:
|
||||
kindStr = "👤 Followed by @\(notification.account.acct)"
|
||||
case .followRequest:
|
||||
kindStr = "👤 Asked to follow by @\(notification.account.acct)"
|
||||
case .poll:
|
||||
kindStr = "📊 Poll finished"
|
||||
case .update:
|
||||
kindStr = "✏️ Edited"
|
||||
case .emojiReaction:
|
||||
if let emoji = notification.emoji {
|
||||
kindStr = "\(emoji) Reacted"
|
||||
} else {
|
||||
kindStr = nil
|
||||
}
|
||||
default:
|
||||
kindStr = nil
|
||||
}
|
||||
|
||||
let notificationContent: String?
|
||||
if let status = notification.status {
|
||||
if notification.kind == .mention || notification.kind == .status,
|
||||
!status.spoilerText.isEmpty {
|
||||
notificationContent = "⚠️ \(status.spoilerText)"
|
||||
} else {
|
||||
notificationContent = NotificationService.textConverter.convert(html: status.content)
|
||||
}
|
||||
} else if notification.kind == .follow || notification.kind == .followRequest {
|
||||
notificationContent = nil
|
||||
} else {
|
||||
notificationContent = push.body
|
||||
}
|
||||
|
||||
content.body = [kindStr, notificationContent].compactMap { $0 }.joined(separator: "\n")
|
||||
|
||||
let attachmentDataTask: Task<URL?, Never>?
|
||||
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
|
||||
// because we risk just fetching the same thing a bunch of times for many senders.
|
||||
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
|
||||
let status = notification.status,
|
||||
!status.sensitive,
|
||||
let attachment = status.attachments.first {
|
||||
let url = attachment.previewURL ?? attachment.url
|
||||
attachmentDataTask = Task {
|
||||
do {
|
||||
let data = try await URLSession.shared.data(from: url).0
|
||||
let localAttachmentURL = FileManager.default.temporaryDirectory.appendingPathComponent("attachment_\(attachment.id)").appendingPathExtension(url.pathExtension)
|
||||
try data.write(to: localAttachmentURL)
|
||||
return localAttachmentURL
|
||||
} catch {
|
||||
logger.error("Error setting notification attachments: \(String(describing: error))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
attachmentDataTask = nil
|
||||
}
|
||||
|
||||
let conversationIdentifier: String?
|
||||
if let status = notification.status {
|
||||
if let context = status.pleromaExtras?.context {
|
||||
conversationIdentifier = "context:\(context)"
|
||||
} else if [Notification.Kind.reblog, .favourite, .poll, .update].contains(notification.kind) {
|
||||
conversationIdentifier = "status:\(status.id)"
|
||||
} else {
|
||||
conversationIdentifier = nil
|
||||
}
|
||||
} else {
|
||||
conversationIdentifier = nil
|
||||
}
|
||||
|
||||
let account: Account?
|
||||
switch notification.kind {
|
||||
case .mention, .status:
|
||||
account = notification.status?.account
|
||||
default:
|
||||
account = notification.account
|
||||
}
|
||||
let sender: INPerson?
|
||||
if let account {
|
||||
let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown)
|
||||
let image: INImage?
|
||||
if let avatar = account.avatar,
|
||||
let (data, resp) = try? await URLSession.shared.data(from: avatar),
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode,
|
||||
(200...299).contains(code) {
|
||||
image = INImage(imageData: data)
|
||||
} else {
|
||||
image = nil
|
||||
}
|
||||
sender = INPerson(
|
||||
personHandle: handle,
|
||||
nameComponents: nil,
|
||||
displayName: account.displayName,
|
||||
image: image,
|
||||
contactIdentifier: nil,
|
||||
customIdentifier: account.id
|
||||
)
|
||||
} else {
|
||||
sender = nil
|
||||
}
|
||||
|
||||
let intent = INSendMessageIntent(
|
||||
recipients: nil,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: notificationContent,
|
||||
speakableGroupName: nil,
|
||||
conversationIdentifier: conversationIdentifier,
|
||||
serviceName: nil,
|
||||
sender: sender,
|
||||
attachments: nil
|
||||
)
|
||||
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
interaction.direction = .incoming
|
||||
|
||||
do {
|
||||
try await interaction.donate()
|
||||
} catch {
|
||||
logger.error("Error donating interaction: \(String(describing: error))")
|
||||
return
|
||||
}
|
||||
|
||||
let updatedContent: UNMutableNotificationContent
|
||||
|
||||
let contentProviding: any UNNotificationContentProviding
|
||||
if #available(iOS 18.0, visionOS 2.0, *),
|
||||
await Preferences.shared.hasFeatureFlag(.pushNotifCustomEmoji) {
|
||||
let attributedString = NSMutableAttributedString(string: content.body)
|
||||
|
||||
for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() {
|
||||
let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
|
||||
guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }),
|
||||
let (data, _) = try? await URLSession.shared.data(from: emoji.url),
|
||||
let image = UIImage(data: data) else {
|
||||
continue
|
||||
}
|
||||
let attachment = NSTextAttachment(image: image)
|
||||
let attachmentStr = NSAttributedString(attachment: attachment)
|
||||
attributedString.replaceCharacters(in: match.range, with: attachmentStr)
|
||||
}
|
||||
|
||||
let attributedCtx = UNNotificationAttributedMessageContext(sendMessageIntent: intent, attributedContent: attributedString)
|
||||
contentProviding = attributedCtx
|
||||
} else {
|
||||
contentProviding = intent
|
||||
}
|
||||
|
||||
do {
|
||||
let newContent = try content.updating(from: contentProviding)
|
||||
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
|
||||
pendingRequest?.0 = newMutableContent
|
||||
updatedContent = newMutableContent
|
||||
} else {
|
||||
updatedContent = content
|
||||
}
|
||||
} catch {
|
||||
logger.error("Error updating notification from intent: \(String(describing: error))")
|
||||
updatedContent = content
|
||||
}
|
||||
|
||||
if let localAttachmentURL = await attachmentDataTask?.value,
|
||||
let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) {
|
||||
updatedContent.attachments = [
|
||||
attachment
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
|
||||
DispatchQueue.main.sync {
|
||||
// this is necessary because of a swift bug: https://github.com/apple/swift/pull/72507
|
||||
MainActor.runUnsafely {
|
||||
PushManager.shared.pushSubscription(account: account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func decryptNotification(subscription: PushNotifications.PushSubscription, serverPublicKeyData: Data, salt: Data, encryptedBody: Data) -> Data? {
|
||||
// See https://github.com/ClearlyClaire/webpush/blob/f14a4d52e201128b1b00245d11b6de80d6cfdcd9/lib/webpush/encryption.rb
|
||||
|
||||
var context = Data()
|
||||
context.append(0)
|
||||
let clientPublicKey = subscription.secretKey.publicKey.x963Representation
|
||||
let clientPublicKeyLength = UInt16(clientPublicKey.count)
|
||||
context.append(UInt8((clientPublicKeyLength >> 8) & 0xFF))
|
||||
context.append(UInt8(clientPublicKeyLength & 0xFF))
|
||||
context.append(clientPublicKey)
|
||||
let serverPublicKeyLength = UInt16(serverPublicKeyData.count)
|
||||
context.append(UInt8((serverPublicKeyLength >> 8) & 0xFF))
|
||||
context.append(UInt8(serverPublicKeyLength & 0xFF))
|
||||
context.append(serverPublicKeyData)
|
||||
|
||||
func info(encoding: String) -> Data {
|
||||
var info = Data("Content-Encoding: \(encoding)\0P-256".utf8)
|
||||
info.append(context)
|
||||
return info
|
||||
}
|
||||
|
||||
let sharedSecret: SharedSecret
|
||||
do {
|
||||
let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData)
|
||||
sharedSecret = try subscription.secretKey.sharedSecretFromKeyAgreement(with: serverPublicKey)
|
||||
} catch {
|
||||
logger.error("Error getting shared secret: \(String(describing: error))")
|
||||
return nil
|
||||
}
|
||||
|
||||
let sharedInfo = Data("Content-Encoding: auth\0".utf8)
|
||||
let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: subscription.authSecret, sharedInfo: sharedInfo, outputByteCount: 32)
|
||||
let contentEncryptionKeyInfo = info(encoding: "aesgcm")
|
||||
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: contentEncryptionKeyInfo, outputByteCount: 16)
|
||||
let nonceInfo = info(encoding: "nonce")
|
||||
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: nonceInfo, outputByteCount: 12)
|
||||
|
||||
let nonceAndEncryptedBody = nonce.withUnsafeBytes { noncePtr in
|
||||
var data = Data(buffer: noncePtr.bindMemory(to: UInt8.self))
|
||||
data.append(encryptedBody)
|
||||
return data
|
||||
}
|
||||
do {
|
||||
let sealedBox = try AES.GCM.SealedBox(combined: nonceAndEncryptedBody)
|
||||
let decrypted = try AES.GCM.open(sealedBox, using: contentEncryptionKey)
|
||||
return decrypted
|
||||
} catch {
|
||||
logger.error("Error decrypting push: \(String(describing: error))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MainActor {
|
||||
@_unavailableFromAsync
|
||||
@available(macOS, obsoleted: 14.0)
|
||||
@available(iOS, obsoleted: 17.0)
|
||||
@available(watchOS, obsoleted: 10.0)
|
||||
@available(tvOS, obsoleted: 17.0)
|
||||
@available(visionOS 1.0, *)
|
||||
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
||||
return try MainActor.assumeIsolated(body)
|
||||
}
|
||||
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
return try withoutActuallyEscaping(body) { fn in
|
||||
try unsafeBitCast(fn, to: (() throws -> T).self)()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeBase64URL(_ s: String) -> Data? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
if str.count % 4 != 0 {
|
||||
str.append(String(repeating: "=", count: 4 - str.count % 4))
|
||||
}
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
// copied from HTMLConverter.Callbacks, blergh
|
||||
private struct HTMLCallbacks: HTMLConversionCallbacks {
|
||||
static func makeURL(string: String) -> URL? {
|
||||
try? URL.ParseStrategy().parse(string)
|
||||
}
|
||||
|
||||
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||
guard name == "span" else {
|
||||
return .default
|
||||
}
|
||||
let clazz = attributes.attributeValue(for: "class")
|
||||
if clazz == "invisible" {
|
||||
return .skip
|
||||
} else if clazz == "ellipsis" {
|
||||
return .append("…")
|
||||
} else {
|
||||
return .default
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import MobileCoreServices
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ActionViewController: UIViewController {
|
||||
|
||||
|
@ -17,25 +18,29 @@ class ActionViewController: UIViewController {
|
|||
super.viewDidLoad()
|
||||
|
||||
findURLFromWebPage { (components) in
|
||||
if let components = components {
|
||||
self.searchForURLInApp(components)
|
||||
} else {
|
||||
self.findURLItem { (components) in
|
||||
if let components = components {
|
||||
self.searchForURLInApp(components)
|
||||
DispatchQueue.main.async {
|
||||
if let components {
|
||||
self.searchForURLInApp(components)
|
||||
} else {
|
||||
self.findURLItem { (components) in
|
||||
if let components {
|
||||
DispatchQueue.main.async {
|
||||
self.searchForURLInApp(components)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func findURLFromWebPage(completion: @escaping (URLComponents?) -> Void) {
|
||||
private func findURLFromWebPage(completion: @escaping @Sendable (URLComponents?) -> Void) {
|
||||
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||
for provider in item.attachments! {
|
||||
guard provider.hasItemConformingToTypeIdentifier(kUTTypePropertyList as String) else {
|
||||
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
|
||||
continue
|
||||
}
|
||||
provider.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil) { (result, error) in
|
||||
provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, 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,
|
||||
|
@ -53,13 +58,13 @@ class ActionViewController: UIViewController {
|
|||
completion(nil)
|
||||
}
|
||||
|
||||
private func findURLItem(completion: @escaping (URLComponents?) -> Void) {
|
||||
private func findURLItem(completion: @escaping @Sendable (URLComponents?) -> Void) {
|
||||
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||
for provider in item.attachments! {
|
||||
guard provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) else {
|
||||
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {
|
||||
continue
|
||||
}
|
||||
provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { (result, error) in
|
||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (result, error) in
|
||||
guard let result = result as? URL,
|
||||
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
|
||||
completion(nil)
|
||||
|
|
|
@ -24,6 +24,8 @@
|
|||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionServiceRoleType</key>
|
||||
<string>NSExtensionServiceRoleTypeViewer</string>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
||||
|
|
|
@ -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,23 @@
|
|||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-url",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/karwa/swift-url.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "6f45f3cd6606f39c3753b302fe30aea980067b30"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
// swift-tools-version: 6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ComposeUI",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "ComposeUI",
|
||||
targets: ["ComposeUI"]),
|
||||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
.package(path: "../Pachyderm"),
|
||||
.package(path: "../InstanceFeatures"),
|
||||
.package(path: "../TuskerComponents"),
|
||||
.package(path: "../MatchedGeometryPresentation"),
|
||||
],
|
||||
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: "ComposeUI",
|
||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ComposeUITests",
|
||||
dependencies: ["ComposeUI"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
# ComposeUI
|
||||
|
||||
A description of this package.
|
|
@ -0,0 +1,197 @@
|
|||
//
|
||||
// PostService.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/27/22.
|
||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
class PostService: ObservableObject {
|
||||
private let mastodonController: ComposeMastodonContext
|
||||
private let config: ComposeUIConfig
|
||||
private let draft: Draft
|
||||
|
||||
@Published var currentStep = 1
|
||||
@Published private(set) var totalSteps = 2
|
||||
|
||||
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
|
||||
self.mastodonController = mastodonController
|
||||
self.config = config
|
||||
self.draft = draft
|
||||
}
|
||||
|
||||
func post() async throws {
|
||||
guard draft.hasContent || draft.editedStatusID != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
// save before posting, so if a crash occurs during network request, the status won't be lost
|
||||
DraftsPersistentContainer.shared.save()
|
||||
|
||||
let uploadedAttachments = try await uploadAttachments()
|
||||
|
||||
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : ""
|
||||
let sensitive = !contentWarning.isEmpty
|
||||
|
||||
let request: Request<Status>
|
||||
|
||||
if let editedStatusID = draft.editedStatusID {
|
||||
if mastodonController.instanceFeatures.needsEditAttachmentsInSeparateRequest {
|
||||
await updateEditedAttachments()
|
||||
}
|
||||
|
||||
request = Client.editStatus(
|
||||
id: editedStatusID,
|
||||
text: textForPosting(),
|
||||
contentType: config.contentType,
|
||||
spoilerText: contentWarning,
|
||||
sensitive: sensitive,
|
||||
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
|
||||
mediaIDs: uploadedAttachments,
|
||||
mediaAttributes: draft.draftAttachments.compactMap {
|
||||
if let id = $0.editedAttachmentID {
|
||||
return EditStatusMediaAttributes(id: id, description: $0.attachmentDescription, focus: nil)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
},
|
||||
poll: draft.poll.map {
|
||||
EditPollParameters(options: $0.pollOptions.map(\.text), expiresIn: Int($0.duration), multiple: $0.multiple)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
request = Client.createStatus(
|
||||
text: textForPosting(),
|
||||
contentType: config.contentType,
|
||||
inReplyTo: draft.inReplyToID,
|
||||
mediaIDs: uploadedAttachments,
|
||||
sensitive: sensitive,
|
||||
spoilerText: contentWarning,
|
||||
visibility: draft.localOnly && mastodonController.instanceFeatures.localOnlyPostsVisibility ? Status.localPostVisibility : draft.visibility.rawValue,
|
||||
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
|
||||
pollOptions: draft.poll?.pollOptions.map(\.text),
|
||||
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
||||
pollMultiple: draft.poll?.multiple,
|
||||
localOnly: mastodonController.instanceFeatures.localOnlyPosts && !mastodonController.instanceFeatures.localOnlyPostsVisibility ? draft.localOnly : nil,
|
||||
idempotencyKey: draft.id.uuidString
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
let (status, _) = try await mastodonController.run(request)
|
||||
currentStep += 1
|
||||
mastodonController.storeCreatedStatus(status)
|
||||
} catch let error as Client.Error {
|
||||
throw Error.posting(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadAttachments() async throws -> [String] {
|
||||
// 2 steps (request data, then upload) for each attachment
|
||||
self.totalSteps += 2 * draft.attachments.count
|
||||
|
||||
var attachments: [String] = []
|
||||
attachments.reserveCapacity(draft.attachments.count)
|
||||
for (index, attachment) in draft.draftAttachments.enumerated() {
|
||||
// if this attachment already exists and is being edited, we don't do anything
|
||||
// edits to the description are handled as part of the edit status request
|
||||
if let editedAttachmentID = attachment.editedAttachmentID {
|
||||
attachments.append(editedAttachmentID)
|
||||
currentStep += 2
|
||||
continue
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let utType: UTType
|
||||
do {
|
||||
(data, utType) = try await getData(for: attachment)
|
||||
currentStep += 1
|
||||
} catch let error as DraftAttachment.ExportError {
|
||||
throw Error.attachmentData(index: index, cause: error)
|
||||
}
|
||||
let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription)
|
||||
attachments.append(uploaded.id)
|
||||
currentStep += 1
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
attachment.getData(features: mastodonController.instanceFeatures) { result in
|
||||
switch result {
|
||||
case let .success(res):
|
||||
continuation.resume(returning: res)
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment {
|
||||
guard let mimeType = utType.preferredMIMEType else {
|
||||
throw Error.attachmentMissingMimeType(index: index, type: utType)
|
||||
}
|
||||
var filename = "file"
|
||||
if let ext = utType.preferredFilenameExtension {
|
||||
filename.append(".\(ext)")
|
||||
}
|
||||
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: filename)
|
||||
let req = Client.upload(attachment: formAttachment, description: description)
|
||||
do {
|
||||
return try await mastodonController.run(req).0
|
||||
} catch let error as Client.Error {
|
||||
throw Error.attachmentUpload(index: index, cause: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func textForPosting() -> String {
|
||||
var text = draft.text
|
||||
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
|
||||
// which we want to strip out before actually posting the status
|
||||
text = text.replacingOccurrences(of: "\u{fffc}", with: "")
|
||||
|
||||
if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack {
|
||||
text += " 👁"
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// only needed for akkoma, not used on regular mastodon
|
||||
private func updateEditedAttachments() async {
|
||||
for attachment in draft.draftAttachments {
|
||||
guard let id = attachment.editedAttachmentID else {
|
||||
continue
|
||||
}
|
||||
let req = Client.updateAttachment(id: id, description: attachment.attachmentDescription, focus: nil)
|
||||
_ = try? await mastodonController.run(req)
|
||||
}
|
||||
}
|
||||
|
||||
enum Error: Swift.Error, LocalizedError {
|
||||
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
|
||||
case attachmentMissingMimeType(index: Int, type: UTType)
|
||||
case attachmentUpload(index: Int, cause: Client.Error)
|
||||
case posting(Client.Error)
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case let .attachmentData(index: index, cause: cause):
|
||||
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||
case let .attachmentMissingMimeType(index: index, type: type):
|
||||
return "Attachment \(index + 1): unknown MIME type for \(type.identifier)"
|
||||
case let .attachmentUpload(index: index, cause: cause):
|
||||
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||
case let .posting(error):
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,24 +1,25 @@
|
|||
//
|
||||
// CharacterCounter.swift
|
||||
// Pachyderm
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 9/29/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import InstanceFeatures
|
||||
|
||||
public struct CharacterCounter {
|
||||
|
||||
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)
|
||||
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
||||
|
||||
public static func count(text: String, for instance: Instance? = nil) -> Int {
|
||||
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
|
||||
let mentionsRemoved = removeMentions(in: text)
|
||||
var count = mentionsRemoved.count
|
||||
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
||||
count -= match.range.length
|
||||
count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
|
||||
count += instanceFeatures.charsReservedPerURL
|
||||
}
|
||||
return count
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// ComposeInput.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
protocol ComposeInput: AnyObject, ObservableObject {
|
||||
var toolbarElements: [ToolbarElement] { get }
|
||||
var textInputMode: UITextInputMode? { get }
|
||||
|
||||
var autocompleteState: AutocompleteState? { get }
|
||||
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { get }
|
||||
|
||||
func autocomplete(with string: String)
|
||||
|
||||
func applyFormat(_ format: StatusFormat)
|
||||
|
||||
func beginAutocompletingEmoji()
|
||||
}
|
||||
|
||||
enum ToolbarElement {
|
||||
case emojiPicker
|
||||
case formattingButtons
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// ComposeMastodonContext.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
import InstanceFeatures
|
||||
import UserAccounts
|
||||
|
||||
public protocol ComposeMastodonContext {
|
||||
var accountInfo: UserAccountInfo? { get }
|
||||
var instanceFeatures: InstanceFeatures { get }
|
||||
|
||||
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
|
||||
|
||||
func getCustomEmojis() async -> [Emoji]
|
||||
|
||||
@MainActor
|
||||
func searchCachedAccounts(query: String) -> [AccountProtocol]
|
||||
@MainActor
|
||||
func cachedRelationship(for accountID: String) -> RelationshipProtocol?
|
||||
@MainActor
|
||||
func searchCachedHashtags(query: String) -> [Hashtag]
|
||||
|
||||
func storeCreatedStatus(_ status: Status)
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// ComposeUIConfig.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import PhotosUI
|
||||
import PencilKit
|
||||
import TuskerComponents
|
||||
|
||||
public struct ComposeUIConfig {
|
||||
// Config
|
||||
public var allowSwitchingDrafts = true
|
||||
public var textSelectionStartsAtBeginning = false
|
||||
|
||||
// Style
|
||||
public var backgroundColor = Color(uiColor: .systemBackground)
|
||||
public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground)
|
||||
public var groupedCellBackgroundColor = Color(uiColor: .systemBackground)
|
||||
public var fillColor = Color(uiColor: .systemFill)
|
||||
public var avatarStyle = AvatarImageView.Style.roundRect
|
||||
|
||||
// Preferences
|
||||
public var useTwitterKeyboard = false
|
||||
public var contentType = StatusContentType.plain
|
||||
public var requireAttachmentDescriptions = false
|
||||
|
||||
// Host callbacks
|
||||
public var dismiss: @MainActor (DismissMode) -> Void = { _ in }
|
||||
public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
|
||||
public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)?
|
||||
public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil }
|
||||
|
||||
public init() {
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeUIConfig {
|
||||
}
|
|
@ -0,0 +1,223 @@
|
|||
//
|
||||
// AttachmentRowController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/12/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TuskerComponents
|
||||
import Vision
|
||||
import MatchedGeometryPresentation
|
||||
|
||||
class AttachmentRowController: ViewController {
|
||||
let parent: ComposeController
|
||||
let attachment: DraftAttachment
|
||||
|
||||
@Published var descriptionMode: DescriptionMode = .allowEntry
|
||||
@Published var textRecognitionError: Error?
|
||||
@Published var focusAttachmentOnTextEditorUnfocus = false
|
||||
|
||||
let thumbnailController: AttachmentThumbnailController
|
||||
|
||||
private var descriptionObservation: NSKeyValueObservation?
|
||||
|
||||
init(parent: ComposeController, attachment: DraftAttachment) {
|
||||
self.parent = parent
|
||||
self.attachment = attachment
|
||||
self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent)
|
||||
|
||||
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
||||
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
|
||||
if attachment.faultingState == 0 {
|
||||
self.updateAttachmentDescriptionState()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func updateAttachmentDescriptionState() {
|
||||
if attachment.attachmentDescription.isEmpty {
|
||||
parent.attachmentsMissingDescriptions.insert(attachment.id)
|
||||
} else {
|
||||
parent.attachmentsMissingDescriptions.remove(attachment.id)
|
||||
}
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AttachmentView(attachment: attachment)
|
||||
}
|
||||
|
||||
private func removeAttachment() {
|
||||
withAnimation {
|
||||
var newAttachments = parent.draft.draftAttachments
|
||||
newAttachments.removeAll(where: { $0.id == attachment.id })
|
||||
parent.draft.attachments = NSMutableOrderedSet(array: newAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
private func editDrawing() {
|
||||
guard case .drawing(let drawing) = attachment.data else {
|
||||
return
|
||||
}
|
||||
parent.config.presentDrawing?(drawing) { newDrawing in
|
||||
self.attachment.drawing = newDrawing
|
||||
}
|
||||
}
|
||||
|
||||
private func focusAttachment() {
|
||||
focusAttachmentOnTextEditorUnfocus = false
|
||||
parent.focusedAttachment = (attachment, thumbnailController)
|
||||
}
|
||||
|
||||
private func recognizeText() {
|
||||
descriptionMode = .recognizingText
|
||||
|
||||
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
||||
DispatchQueue.main.async {
|
||||
let data: Data
|
||||
switch result {
|
||||
case .success((let d, _)):
|
||||
data = d
|
||||
case .failure(let error):
|
||||
self.descriptionMode = .allowEntry
|
||||
self.textRecognitionError = error
|
||||
return
|
||||
}
|
||||
|
||||
let handler = VNImageRequestHandler(data: data)
|
||||
let request = VNRecognizeTextRequest { request, error in
|
||||
DispatchQueue.main.async {
|
||||
if let results = request.results as? [VNRecognizedTextObservation] {
|
||||
var text = ""
|
||||
for observation in results {
|
||||
let result = observation.topCandidates(1).first!
|
||||
text.append(result.string)
|
||||
text.append("\n")
|
||||
}
|
||||
self.attachment.attachmentDescription = text
|
||||
}
|
||||
self.descriptionMode = .allowEntry
|
||||
}
|
||||
}
|
||||
request.recognitionLevel = .accurate
|
||||
request.usesLanguageCorrection = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
try handler.perform([request])
|
||||
} catch let error as NSError where error.code == 1 {
|
||||
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
|
||||
return
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.descriptionMode = .allowEntry
|
||||
self.textRecognitionError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentView: View {
|
||||
@ObservedObject private var attachment: DraftAttachment
|
||||
@EnvironmentObject private var controller: AttachmentRowController
|
||||
@FocusState private var textEditorFocused: Bool
|
||||
|
||||
init(attachment: DraftAttachment) {
|
||||
self.attachment = attachment
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ControllerView(controller: { controller.thumbnailController })
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: false))
|
||||
.matchedGeometrySource(id: attachment.id, presentationID: attachment.id)
|
||||
.overlay {
|
||||
thumbnailFocusedOverlay
|
||||
}
|
||||
.frame(width: thumbnailSize, height: thumbnailSize)
|
||||
.onTapGesture {
|
||||
textEditorFocused = false
|
||||
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
|
||||
controller.focusAttachmentOnTextEditorUnfocus = true
|
||||
}
|
||||
.contextMenu {
|
||||
if attachment.drawingData != nil {
|
||||
Button(action: controller.editDrawing) {
|
||||
Label("Edit Drawing", systemImage: "hand.draw")
|
||||
}
|
||||
} else if attachment.type == .image {
|
||||
Button(action: controller.recognizeText) {
|
||||
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive, action: controller.removeAttachment) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} preview: {
|
||||
ControllerView(controller: { controller.thumbnailController })
|
||||
}
|
||||
|
||||
switch controller.descriptionMode {
|
||||
case .allowEntry:
|
||||
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
|
||||
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
|
||||
.focused($textEditorFocused)
|
||||
|
||||
case .recognizingText:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
.alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in
|
||||
Button("OK") {}
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
||||
#if os(visionOS)
|
||||
.onChange(of: textEditorFocused) {
|
||||
if !textEditorFocused && controller.focusAttachmentOnTextEditorUnfocus {
|
||||
controller.focusAttachment()
|
||||
}
|
||||
}
|
||||
#else
|
||||
.onChange(of: textEditorFocused) { newValue in
|
||||
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
|
||||
controller.focusAttachment()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var thumbnailSize: CGFloat {
|
||||
#if os(visionOS)
|
||||
120
|
||||
#else
|
||||
80
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnailFocusedOverlay: some View {
|
||||
Image(systemName: "arrow.up.backward.and.arrow.down.forward")
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
// use .opacity and an animation, because .transition doesn't seem to play nice with @FocusState
|
||||
.opacity(textEditorFocused ? 1 : 0)
|
||||
.animation(.linear(duration: 0.1), value: textEditorFocused)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AttachmentRowController {
|
||||
enum DescriptionMode {
|
||||
case allowEntry, recognizingText
|
||||
}
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
//
|
||||
// AttachmentThumbnailController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 11/10/21.
|
||||
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Photos
|
||||
import TuskerComponents
|
||||
|
||||
class AttachmentThumbnailController: ViewController {
|
||||
unowned let parent: ComposeController
|
||||
let attachment: DraftAttachment
|
||||
|
||||
@Published private var image: UIImage?
|
||||
@Published private var gifController: GIFController?
|
||||
@Published private var fullSize: Bool = false
|
||||
|
||||
init(attachment: DraftAttachment, parent: ComposeController) {
|
||||
self.attachment = attachment
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func loadImageIfNecessary(fullSize: Bool) {
|
||||
if (gifController != nil) || (image != nil && self.fullSize) {
|
||||
return
|
||||
}
|
||||
self.fullSize = fullSize
|
||||
|
||||
switch attachment.data {
|
||||
case .editing(_, let kind, let url):
|
||||
switch kind {
|
||||
case .image:
|
||||
Task { @MainActor in
|
||||
self.image = await parent.fetchAttachment(url)
|
||||
}
|
||||
|
||||
case .video, .gifv:
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||
self.image = UIImage(cgImage: cgImage)
|
||||
}
|
||||
#endif
|
||||
|
||||
case .audio, .unknown:
|
||||
break
|
||||
}
|
||||
|
||||
case .asset(let id):
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
||||
return
|
||||
}
|
||||
let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier })
|
||||
if isGIF {
|
||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
||||
guard let data else { return }
|
||||
if typeIdentifier == UTType.gif.identifier {
|
||||
self.gifController = GIFController(gifData: data)
|
||||
} else {
|
||||
let image = UIImage(data: data)
|
||||
DispatchQueue.main.async {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let size: CGSize
|
||||
if fullSize {
|
||||
size = PHImageManagerMaximumSize
|
||||
} else {
|
||||
// currently only used as thumbnail in ComposeAttachmentRow
|
||||
size = CGSize(width: 80, height: 80)
|
||||
}
|
||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||
DispatchQueue.main.async {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .drawing(let drawing):
|
||||
image = drawing.imageInLightMode(from: drawing.bounds)
|
||||
|
||||
case .file(let url, let type):
|
||||
if type.conforms(to: .movie) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||
self.image = UIImage(cgImage: cgImage)
|
||||
}
|
||||
#endif
|
||||
} else if let data = try? Data(contentsOf: url) {
|
||||
if type == .gif {
|
||||
self.gifController = GIFController(gifData: data)
|
||||
} else if type.conforms(to: .image),
|
||||
let image = UIImage(data: data) {
|
||||
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
|
||||
// crashing share extension. see FB12186346
|
||||
// if fullSize {
|
||||
image.prepareForDisplay { prepared in
|
||||
DispatchQueue.main.async {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
// } else {
|
||||
// image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
|
||||
// DispatchQueue.main.async {
|
||||
// self.image = prepared
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var view: some SwiftUI.View {
|
||||
View()
|
||||
}
|
||||
|
||||
struct View: SwiftUI.View {
|
||||
@EnvironmentObject private var controller: AttachmentThumbnailController
|
||||
@Environment(\.attachmentThumbnailConfiguration) private var config
|
||||
|
||||
var body: some SwiftUI.View {
|
||||
content
|
||||
.onAppear {
|
||||
controller.loadImageIfNecessary(fullSize: config.fullSize)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some SwiftUI.View {
|
||||
if let gifController = controller.gifController {
|
||||
GIFViewWrapper(controller: gifController)
|
||||
} else if let image = controller.image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(config.aspectRatio, contentMode: config.contentMode)
|
||||
} else {
|
||||
Image(systemName: "photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentThumbnailConfiguration {
|
||||
let aspectRatio: CGFloat?
|
||||
let contentMode: ContentMode
|
||||
let fullSize: Bool
|
||||
|
||||
init(aspectRatio: CGFloat? = nil, contentMode: ContentMode = .fit, fullSize: Bool = false) {
|
||||
self.aspectRatio = aspectRatio
|
||||
self.contentMode = contentMode
|
||||
self.fullSize = fullSize
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentThumbnailConfigurationEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue = AttachmentThumbnailConfiguration()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var attachmentThumbnailConfiguration: AttachmentThumbnailConfiguration {
|
||||
get { self[AttachmentThumbnailConfigurationEnvironmentKey.self] }
|
||||
set { self[AttachmentThumbnailConfigurationEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
private struct GIFViewWrapper: UIViewRepresentable {
|
||||
typealias UIViewType = GIFImageView
|
||||
|
||||
@State var controller: GIFController
|
||||
|
||||
func makeUIView(context: Context) -> GIFImageView {
|
||||
let view = GIFImageView()
|
||||
controller.attach(to: view)
|
||||
controller.startAnimating()
|
||||
view.contentMode = .scaleAspectFit
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: GIFImageView, context: Context) {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,225 @@
|
|||
//
|
||||
// AttachmentsListController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/8/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import PencilKit
|
||||
|
||||
class AttachmentsListController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
var draft: Draft { parent.draft }
|
||||
|
||||
var isValid: Bool {
|
||||
!requiresAttachmentDescriptions && validAttachmentCombination
|
||||
}
|
||||
|
||||
private var requiresAttachmentDescriptions: Bool {
|
||||
if parent.config.requireAttachmentDescriptions {
|
||||
if draft.attachments.count == 0 {
|
||||
return false
|
||||
} else {
|
||||
return !parent.attachmentsMissingDescriptions.isEmpty
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var validAttachmentCombination: Bool {
|
||||
if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
return true
|
||||
} else if draft.attachments.count > 1,
|
||||
draft.draftAttachments.contains(where: { $0.type == .video }) {
|
||||
return false
|
||||
} else if draft.attachments.count > 4 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
init(parent: ComposeController) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
var canAddAttachment: Bool {
|
||||
if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var canAddPoll: Bool {
|
||||
if parent.mastodonController.instanceFeatures.pollsAndAttachments {
|
||||
return true
|
||||
} else {
|
||||
return draft.attachments.count == 0
|
||||
}
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AttachmentsList()
|
||||
}
|
||||
|
||||
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
||||
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
|
||||
// results in the order switching back to the previous order and then to the correct one
|
||||
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
|
||||
var array = draft.draftAttachments
|
||||
array.move(fromOffsets: source, toOffset: destination)
|
||||
draft.attachments = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private func deleteAttachments(at indices: IndexSet) {
|
||||
var array = draft.draftAttachments
|
||||
array.remove(atOffsets: indices)
|
||||
draft.attachments = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
|
||||
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
||||
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
||||
guard let attachment = object as? DraftAttachment else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
self.canAddAttachment else {
|
||||
return
|
||||
}
|
||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addImage() {
|
||||
parent.deleteDraftOnDisappear = false
|
||||
parent.config.presentAssetPicker?({ results in
|
||||
self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider))
|
||||
})
|
||||
}
|
||||
|
||||
private func addDrawing() {
|
||||
parent.deleteDraftOnDisappear = false
|
||||
parent.config.presentDrawing?(PKDrawing()) { drawing in
|
||||
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
||||
attachment.id = UUID()
|
||||
attachment.drawing = drawing
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePoll() {
|
||||
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
|
||||
withAnimation {
|
||||
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentsList: View {
|
||||
private let cellHeight: CGFloat = 80
|
||||
private let cellPadding: CGFloat = 12
|
||||
|
||||
@EnvironmentObject private var controller: AttachmentsListController
|
||||
@EnvironmentObject private var draft: Draft
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
attachmentsList
|
||||
|
||||
Group {
|
||||
if controller.parent.config.presentAssetPicker != nil {
|
||||
addImageButton
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
|
||||
if controller.parent.config.presentDrawing != nil {
|
||||
addDrawingButton
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
|
||||
togglePollButton
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
#if os(visionOS)
|
||||
.buttonStyle(.bordered)
|
||||
.labelStyle(AttachmentButtonLabelStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
private var attachmentsList: some View {
|
||||
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
|
||||
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
.id(attachment.id)
|
||||
}
|
||||
.onMove(perform: controller.moveAttachments)
|
||||
.onDelete(perform: controller.deleteAttachments)
|
||||
.conditionally(controller.canAddAttachment) {
|
||||
$0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in
|
||||
controller.insertAttachments(at: offset, itemProviders: providers)
|
||||
})
|
||||
}
|
||||
// only sort of works, see #240
|
||||
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, isTargeted: nil) { providers in
|
||||
controller.insertAttachments(at: 0, itemProviders: providers)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var addImageButton: some View {
|
||||
Button(action: controller.addImage) {
|
||||
Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo")
|
||||
}
|
||||
.disabled(!controller.canAddAttachment)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(height: cellHeight / 2)
|
||||
}
|
||||
|
||||
private var addDrawingButton: some View {
|
||||
Button(action: controller.addDrawing) {
|
||||
Label("Draw something", systemImage: "hand.draw")
|
||||
}
|
||||
.disabled(!controller.canAddAttachment)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(height: cellHeight / 2)
|
||||
}
|
||||
|
||||
private var togglePollButton: some View {
|
||||
Button(action: controller.togglePoll) {
|
||||
Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
|
||||
}
|
||||
.disabled(!controller.canAddPoll)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(height: cellHeight / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension View {
|
||||
@ViewBuilder
|
||||
func conditionally(_ condition: Bool, body: (Self) -> some View) -> some View {
|
||||
if condition {
|
||||
body(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(visionOS 1.0, *)
|
||||
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
DefaultLabelStyle().makeBody(configuration: configuration)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
//
|
||||
// AutocompleteController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/25/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
class AutocompleteController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
|
||||
@Published var mode: Mode?
|
||||
|
||||
init(parent: ComposeController) {
|
||||
self.parent = parent
|
||||
|
||||
parent.$currentInput
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0.autocompleteStatePublisher }
|
||||
.map {
|
||||
switch $0 {
|
||||
case .mention(_):
|
||||
return Mode.mention
|
||||
case .emoji(_):
|
||||
return Mode.emoji
|
||||
case .hashtag(_):
|
||||
return Mode.hashtag
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.assign(to: &$mode)
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AutocompleteView()
|
||||
}
|
||||
|
||||
struct AutocompleteView: View {
|
||||
@EnvironmentObject private var parent: ComposeController
|
||||
@EnvironmentObject private var controller: AutocompleteController
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
if let mode = controller.mode {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
suggestionsView(mode: mode)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func suggestionsView(mode: Mode) -> some View {
|
||||
switch mode {
|
||||
case .mention:
|
||||
ControllerView(controller: { AutocompleteMentionsController(composeController: parent) })
|
||||
case .emoji:
|
||||
ControllerView(controller: { AutocompleteEmojisController(composeController: parent) })
|
||||
case .hashtag:
|
||||
ControllerView(controller: { AutocompleteHashtagsController(composeController: parent) })
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
Color(white: colorScheme == .light ? 0.98 : 0.15)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
Color(white: colorScheme == .light ? 0.85 : 0.25)
|
||||
}
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
case mention
|
||||
case emoji
|
||||
case hashtag
|
||||
}
|
||||
}
|
|
@ -0,0 +1,188 @@
|
|||
//
|
||||
// AutocompleteEmojisController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/26/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteEmojisController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
||||
|
||||
private var stateCancellable: AnyCancellable?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
@Published var expanded = false
|
||||
@Published var emojis: [Emoji] = []
|
||||
@Published var emojisBySection: [String: [Emoji]] = [:]
|
||||
|
||||
init(composeController: ComposeController) {
|
||||
self.composeController = composeController
|
||||
|
||||
stateCancellable = composeController.$currentInput
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0.autocompleteStatePublisher }
|
||||
.compactMap {
|
||||
if case .emoji(let s) = $0 {
|
||||
return s
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.removeDuplicates()
|
||||
.sink { [unowned self] query in
|
||||
self.searchTask?.cancel()
|
||||
self.searchTask = Task { [weak self] in
|
||||
await self?.queryChanged(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func queryChanged(_ query: String) async {
|
||||
var emojis = await composeController.mastodonController.getCustomEmojis()
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
if !query.isEmpty {
|
||||
emojis =
|
||||
emojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in
|
||||
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
||||
}
|
||||
.filter(\.1.matched)
|
||||
.sorted { $0.1.score > $1.1.score }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
var shortcodes = Set<String>()
|
||||
var newEmojis = [Emoji]()
|
||||
var newEmojisBySection = [String: [Emoji]]()
|
||||
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
||||
newEmojis.append(emoji)
|
||||
shortcodes.insert(emoji.shortcode)
|
||||
|
||||
let category = emoji.category ?? ""
|
||||
if newEmojisBySection.keys.contains(category) {
|
||||
newEmojisBySection[category]!.append(emoji)
|
||||
} else {
|
||||
newEmojisBySection[category] = [emoji]
|
||||
}
|
||||
}
|
||||
self.emojis = newEmojis
|
||||
self.emojisBySection = newEmojisBySection
|
||||
}
|
||||
|
||||
private func toggleExpanded() {
|
||||
withAnimation {
|
||||
expanded.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private func autocomplete(with emoji: Emoji) {
|
||||
guard let input = composeController.currentInput else { return }
|
||||
input.autocomplete(with: ":\(emoji.shortcode):")
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AutocompleteEmojisView()
|
||||
}
|
||||
|
||||
struct AutocompleteEmojisView: View {
|
||||
@EnvironmentObject private var composeController: ComposeController
|
||||
@EnvironmentObject private var controller: AutocompleteEmojisController
|
||||
@ScaledMetric private var emojiSize = 30
|
||||
|
||||
var body: some View {
|
||||
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
|
||||
HStack(alignment: controller.expanded ? .top : .center, spacing: 0) {
|
||||
emojiList
|
||||
.transition(.move(edge: .bottom))
|
||||
|
||||
toggleExpandedButton
|
||||
.padding(.trailing, 8)
|
||||
.padding(.top, controller.expanded ? 8 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var emojiList: some View {
|
||||
if controller.expanded {
|
||||
verticalGrid
|
||||
.frame(height: 150)
|
||||
} else {
|
||||
horizontalScrollView
|
||||
}
|
||||
}
|
||||
|
||||
private var verticalGrid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
||||
ForEach(controller.emojisBySection.keys.sorted(), id: \.self) { section in
|
||||
Section {
|
||||
ForEach(controller.emojisBySection[section]!, id: \.shortcode) { emoji in
|
||||
Button(action: { controller.autocomplete(with: emoji) }) {
|
||||
composeController.emojiImageView(emoji)
|
||||
.frame(height: emojiSize)
|
||||
}
|
||||
.accessibilityLabel(emoji.shortcode)
|
||||
}
|
||||
} header: {
|
||||
if !section.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section)
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.all, 8)
|
||||
// the spacing between the grid sections doesn't seem to be taken into account by the ScrollView?
|
||||
.padding(.bottom, CGFloat(controller.emojisBySection.keys.count) * 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var horizontalScrollView: some View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 8) {
|
||||
ForEach(controller.emojis, id: \.shortcode) { emoji in
|
||||
Button(action: { controller.autocomplete(with: emoji) }) {
|
||||
HStack(spacing: 4) {
|
||||
composeController.emojiImageView(emoji)
|
||||
.frame(height: emojiSize)
|
||||
Text(verbatim: ":\(emoji.shortcode):")
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(emoji.shortcode)
|
||||
.frame(height: emojiSize)
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: controller.emojis)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: emojiSize + 16)
|
||||
}
|
||||
}
|
||||
|
||||
private var toggleExpandedButton: some View {
|
||||
Button(action: controller.toggleExpanded) {
|
||||
Image(systemName: "chevron.down")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.rotationEffect(controller.expanded ? .zero : .degrees(180))
|
||||
}
|
||||
.accessibilityLabel(controller.expanded ? "Collapse" : "Expand")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
//
|
||||
// AutocompleteHashtagsController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/1/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteHashtagsController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
||||
|
||||
private var stateCancellable: AnyCancellable?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
@Published var hashtags: [Hashtag] = []
|
||||
|
||||
init(composeController: ComposeController) {
|
||||
self.composeController = composeController
|
||||
|
||||
stateCancellable = composeController.$currentInput
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0.autocompleteStatePublisher }
|
||||
.compactMap {
|
||||
if case .hashtag(let s) = $0 {
|
||||
return s
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
||||
.sink { [unowned self] query in
|
||||
self.searchTask?.cancel()
|
||||
self.searchTask = Task { [weak self] in
|
||||
await self?.queryChanged(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func queryChanged(_ query: String) async {
|
||||
guard !query.isEmpty else {
|
||||
hashtags = []
|
||||
return
|
||||
}
|
||||
|
||||
let localHashtags = mastodonController.searchCachedHashtags(query: query)
|
||||
|
||||
var onlyLocalTagsTask: Task<Void, any Error>?
|
||||
if !localHashtags.isEmpty {
|
||||
onlyLocalTagsTask = Task {
|
||||
// we only want to do the local-only search if the trends API call takes more than .25sec or it fails
|
||||
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
|
||||
self.updateHashtags(searchResults: [], trendingTags: [], localHashtags: localHashtags, query: query)
|
||||
}
|
||||
}
|
||||
|
||||
async let trendingTags = try? mastodonController.run(Client.getTrendingHashtags()).0
|
||||
async let searchResults = try? mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])).0.hashtags
|
||||
|
||||
let trends = await trendingTags ?? []
|
||||
let search = await searchResults ?? []
|
||||
|
||||
onlyLocalTagsTask?.cancel()
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
updateHashtags(searchResults: search, trendingTags: trends, localHashtags: localHashtags, query: query)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], localHashtags: [Hashtag], query: String) {
|
||||
var addedHashtags = Set<String>()
|
||||
var hashtags = [(Hashtag, Int)]()
|
||||
for group in [searchResults, trendingTags, localHashtags] {
|
||||
for tag in group where !addedHashtags.contains(tag.name) {
|
||||
let (matched, score) = FuzzyMatcher.match(pattern: query, str: tag.name)
|
||||
if matched {
|
||||
hashtags.append((tag, score))
|
||||
addedHashtags.insert(tag.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.hashtags = hashtags
|
||||
.sorted { $0.1 > $1.1 }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private func autocomplete(with hashtag: Hashtag) {
|
||||
guard let currentInput = composeController.currentInput else { return }
|
||||
currentInput.autocomplete(with: "#\(hashtag.name)")
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AutocompleteHashtagsView()
|
||||
}
|
||||
|
||||
struct AutocompleteHashtagsView: View {
|
||||
@EnvironmentObject private var controller: AutocompleteHashtagsController
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(controller.hashtags, id: \.name) { hashtag in
|
||||
Button(action: { controller.autocomplete(with: hashtag) }) {
|
||||
Text(verbatim: "#\(hashtag.name)")
|
||||
.foregroundColor(Color(uiColor: .label))
|
||||
}
|
||||
.frame(height: 30)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.animation(.linear(duration: 0.2), value: controller.hashtags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,179 @@
|
|||
//
|
||||
// AutocompleteMentionsController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/25/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteMentionsController: ViewController {
|
||||
|
||||
unowned let composeController: ComposeController
|
||||
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
||||
|
||||
private var stateCancellable: AnyCancellable?
|
||||
|
||||
@Published private var accounts: [AnyAccount] = []
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
init(composeController: ComposeController) {
|
||||
self.composeController = composeController
|
||||
|
||||
stateCancellable = composeController.$currentInput
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0.autocompleteStatePublisher }
|
||||
.compactMap {
|
||||
if case .mention(let s) = $0 {
|
||||
return s
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
||||
.sink { [unowned self] query in
|
||||
self.searchTask?.cancel()
|
||||
// weak in case the autocomplete controller is dealloc'd racing with the task starting
|
||||
self.searchTask = Task { [weak self] in
|
||||
await self?.queryChanged(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func queryChanged(_ query: String) async {
|
||||
guard !query.isEmpty else {
|
||||
accounts = []
|
||||
return
|
||||
}
|
||||
|
||||
let localSearchTask = Task {
|
||||
// we only want to search locally if the search API call takes more than .25sec or it fails
|
||||
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
|
||||
|
||||
let results = self.mastodonController.searchCachedAccounts(query: query)
|
||||
try Task.checkCancellation()
|
||||
|
||||
if !results.isEmpty {
|
||||
self.loadAccounts(results.map { .init(value: $0) }, query: query)
|
||||
}
|
||||
}
|
||||
|
||||
let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0
|
||||
guard let accounts,
|
||||
!Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
localSearchTask.cancel()
|
||||
|
||||
loadAccounts(accounts.map { .init(value: $0) }, query: query)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadAccounts(_ accounts: [AnyAccount], query: String) {
|
||||
guard case .mention(query) = composeController.currentInput?.autocompleteState else {
|
||||
return
|
||||
}
|
||||
|
||||
// when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
|
||||
let ignoreDomain = !query.contains("@")
|
||||
|
||||
self.accounts =
|
||||
accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
|
||||
let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
|
||||
let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
|
||||
return res
|
||||
}
|
||||
.filter(\.1.matched)
|
||||
.map { (account, res) -> (AnyAccount, Int) in
|
||||
// give higher weight to accounts that the user follows or is followed by
|
||||
var score = res.score
|
||||
if let relationship = mastodonController.cachedRelationship(for: account.value.id) {
|
||||
if relationship.following {
|
||||
score += 3
|
||||
}
|
||||
if relationship.followedBy {
|
||||
score += 2
|
||||
}
|
||||
}
|
||||
return (account, score)
|
||||
}
|
||||
.sorted { $0.1 > $1.1 }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private func autocomplete(with account: AnyAccount) {
|
||||
guard let input = composeController.currentInput else {
|
||||
return
|
||||
}
|
||||
input.autocomplete(with: "@\(account.value.acct)")
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AutocompleteMentionsView()
|
||||
}
|
||||
|
||||
struct AutocompleteMentionsView: View {
|
||||
@EnvironmentObject private var controller: AutocompleteMentionsController
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(controller.accounts) { account in
|
||||
AutocompleteMentionButton(account: account)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.animation(.linear(duration: 0.2), value: controller.accounts)
|
||||
}
|
||||
.onDisappear {
|
||||
controller.searchTask?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AutocompleteMentionButton: View {
|
||||
@EnvironmentObject private var composeController: ComposeController
|
||||
@EnvironmentObject private var controller: AutocompleteMentionsController
|
||||
let account: AnyAccount
|
||||
|
||||
var body: some View {
|
||||
Button(action: { controller.autocomplete(with: account) }) {
|
||||
HStack(spacing: 4) {
|
||||
AvatarImageView(
|
||||
url: account.value.avatar,
|
||||
size: 30,
|
||||
style: composeController.config.avatarStyle,
|
||||
fetchAvatar: composeController.fetchAvatar
|
||||
)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
controller.composeController.displayNameLabel(account.value, .subheadline, 14)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(verbatim: "@\(account.value.acct)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 30)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct AnyAccount: Equatable, Identifiable {
|
||||
let value: any AccountProtocol
|
||||
|
||||
var id: String { value.id }
|
||||
|
||||
static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
|
||||
return lhs.value.id == rhs.value.id
|
||||
}
|
||||
}
|
|
@ -0,0 +1,502 @@
|
|||
//
|
||||
// ComposeController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
import MatchedGeometryPresentation
|
||||
import CoreData
|
||||
|
||||
public final class ComposeController: ViewController {
|
||||
public typealias FetchAttachment = (URL) async -> UIImage?
|
||||
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
||||
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
|
||||
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
|
||||
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
|
||||
public typealias EmojiImageView = (Emoji) -> AnyView
|
||||
|
||||
@Published public private(set) var draft: Draft {
|
||||
didSet {
|
||||
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
|
||||
}
|
||||
}
|
||||
@Published public var config: ComposeUIConfig
|
||||
@Published public var mastodonController: ComposeMastodonContext
|
||||
let fetchAvatar: AvatarImageView.FetchAvatar
|
||||
let fetchAttachment: FetchAttachment
|
||||
let fetchStatus: FetchStatus
|
||||
let displayNameLabel: DisplayNameLabel
|
||||
let currentAccountContainerView: CurrentAccountContainerView
|
||||
let replyContentView: ReplyContentView
|
||||
let emojiImageView: EmojiImageView
|
||||
|
||||
@Published public var currentAccount: (any AccountProtocol)?
|
||||
@Published public var showToolbar = true
|
||||
@Published public var deleteDraftOnDisappear = true
|
||||
|
||||
@Published var autocompleteController: AutocompleteController!
|
||||
@Published var toolbarController: ToolbarController!
|
||||
@Published var attachmentsListController: AttachmentsListController!
|
||||
|
||||
// this property is here rather than on the AttachmentsListController so that the ComposeView
|
||||
// updates when it changes, because changes to it may alter postButtonEnabled
|
||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||
@Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)?
|
||||
let scrollToAttachment = PassthroughSubject<UUID, Never>()
|
||||
@Published var contentWarningBecomeFirstResponder = false
|
||||
@Published var mainComposeTextViewBecomeFirstResponder = false
|
||||
@Published var currentInput: (any ComposeInput)? = nil
|
||||
@Published var shouldEmojiAutocompletionBeginExpanded = false
|
||||
@Published var isShowingSaveDraftSheet = false
|
||||
@Published var isShowingDraftsList = false
|
||||
@Published var poster: PostService?
|
||||
@Published var postError: PostService.Error?
|
||||
@Published public private(set) var didPostSuccessfully = false
|
||||
@Published var hasChangedLanguageSelection = false
|
||||
|
||||
private var isDisappearing = false
|
||||
private var userConfirmedDelete = false
|
||||
|
||||
public var isPosting: Bool {
|
||||
poster != nil
|
||||
}
|
||||
|
||||
var charactersRemaining: Int {
|
||||
let instanceFeatures = mastodonController.instanceFeatures
|
||||
let limit = instanceFeatures.maxStatusChars
|
||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
||||
}
|
||||
|
||||
var postButtonEnabled: Bool {
|
||||
draft.editedStatusID != nil ||
|
||||
(draft.hasContent
|
||||
&& charactersRemaining >= 0
|
||||
&& !isPosting
|
||||
&& attachmentsListController.isValid
|
||||
&& isPollValid)
|
||||
}
|
||||
|
||||
private var isPollValid: Bool {
|
||||
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
|
||||
}
|
||||
|
||||
public var navigationTitle: String {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = fetchStatus(id) {
|
||||
return "Reply to @\(status.account.acct)"
|
||||
} else if draft.editedStatusID != nil {
|
||||
return "Edit Post"
|
||||
} else {
|
||||
return "New Post"
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
draft: Draft,
|
||||
config: ComposeUIConfig,
|
||||
mastodonController: ComposeMastodonContext,
|
||||
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
|
||||
fetchAttachment: @escaping FetchAttachment,
|
||||
fetchStatus: @escaping FetchStatus,
|
||||
displayNameLabel: @escaping DisplayNameLabel,
|
||||
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
|
||||
replyContentView: @escaping ReplyContentView,
|
||||
emojiImageView: @escaping EmojiImageView
|
||||
) {
|
||||
self.draft = draft
|
||||
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
|
||||
self.config = config
|
||||
self.mastodonController = mastodonController
|
||||
self.fetchAvatar = fetchAvatar
|
||||
self.fetchAttachment = fetchAttachment
|
||||
self.fetchStatus = fetchStatus
|
||||
self.displayNameLabel = displayNameLabel
|
||||
self.currentAccountContainerView = currentAccountContainerView
|
||||
self.replyContentView = replyContentView
|
||||
self.emojiImageView = emojiImageView
|
||||
|
||||
self.autocompleteController = AutocompleteController(parent: self)
|
||||
self.toolbarController = ToolbarController(parent: self)
|
||||
self.attachmentsListController = AttachmentsListController(parent: self)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
|
||||
}
|
||||
|
||||
public var view: some View {
|
||||
ComposeView(poster: poster)
|
||||
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||
.environmentObject(draft)
|
||||
.environmentObject(mastodonController.instanceFeatures)
|
||||
.environment(\.composeUIConfig, config)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
||||
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
|
||||
deleted.contains(where: { $0.objectID == self.draft.objectID }),
|
||||
!isDisappearing {
|
||||
self.config.dismiss(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
|
||||
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
|
||||
return false
|
||||
}
|
||||
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
if draft.draftAttachments.allSatisfy({ $0.type == .image }) {
|
||||
// if providers are videos, this technically allows invalid video/image combinations
|
||||
return itemProviders.count + draft.attachments.count <= 4
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public func paste(itemProviders: [NSItemProvider]) {
|
||||
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
||||
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
||||
guard let attachment = object as? DraftAttachment else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard self.attachmentsListController.canAddAttachment else { return }
|
||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func cancel() {
|
||||
if draft.hasContent {
|
||||
isShowingSaveDraftSheet = true
|
||||
} else {
|
||||
deleteDraftOnDisappear = true
|
||||
config.dismiss(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func cancel(deleteDraft: Bool) {
|
||||
deleteDraftOnDisappear = true
|
||||
userConfirmedDelete = deleteDraft
|
||||
config.dismiss(.cancel)
|
||||
}
|
||||
|
||||
func postStatus() {
|
||||
guard !isPosting,
|
||||
draft.editedStatusID != nil || draft.hasContent else {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
|
||||
self.poster = poster
|
||||
|
||||
// try to resign the first responder, if there is one.
|
||||
// otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide
|
||||
// and the first responder to change during a view update, which in turn triggers a bunch of state changes
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
|
||||
do {
|
||||
try await poster.post()
|
||||
|
||||
deleteDraftOnDisappear = true
|
||||
didPostSuccessfully = true
|
||||
|
||||
// wait .25 seconds so the user can see the progress bar has completed
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
|
||||
// don't unset the poster, so the ui remains disabled while dismissing
|
||||
|
||||
config.dismiss(.post)
|
||||
|
||||
} catch let error as PostService.Error {
|
||||
self.postError = error
|
||||
self.poster = nil
|
||||
} catch {
|
||||
fatalError("unreachable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showDrafts() {
|
||||
isShowingDraftsList = true
|
||||
}
|
||||
|
||||
func selectDraft(_ newDraft: Draft) {
|
||||
let oldDraft = self.draft
|
||||
self.draft = newDraft
|
||||
|
||||
if !oldDraft.hasContent {
|
||||
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
|
||||
}
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
func onDisappear() {
|
||||
isDisappearing = true
|
||||
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) {
|
||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||
}
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
func toggleContentWarning() {
|
||||
draft.contentWarningEnabled.toggle()
|
||||
if draft.contentWarningEnabled {
|
||||
contentWarningBecomeFirstResponder = true
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@objc private func currentInputModeChanged() {
|
||||
guard let mode = currentInput?.textInputMode,
|
||||
let code = LanguagePicker.codeFromInputMode(mode),
|
||||
!hasChangedLanguageSelection && !draft.hasContent else {
|
||||
return
|
||||
}
|
||||
draft.language = code.identifier
|
||||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
@OptionalObservedObject var poster: PostService?
|
||||
@EnvironmentObject var controller: ComposeController
|
||||
@EnvironmentObject var draft: Draft
|
||||
#if !os(visionOS)
|
||||
@StateObject private var keyboardReader = KeyboardReader()
|
||||
#endif
|
||||
@State private var globalFrameOutsideList = CGRect.zero
|
||||
|
||||
init(poster: PostService?) {
|
||||
self.poster = poster
|
||||
}
|
||||
|
||||
var config: ComposeUIConfig {
|
||||
controller.config
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
navRoot
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
|
||||
private var navRoot: some View {
|
||||
ZStack(alignment: .top) {
|
||||
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
|
||||
config.backgroundColor
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
mainList
|
||||
.onReceive(controller.scrollToAttachment) { id in
|
||||
proxy.scrollTo(id, anchor: .center)
|
||||
}
|
||||
}
|
||||
|
||||
if let poster = poster {
|
||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if controller.showToolbar {
|
||||
VStack(spacing: 0) {
|
||||
ControllerView(controller: { controller.autocompleteController })
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.default, value: controller.currentInput?.autocompleteState)
|
||||
|
||||
#if !os(visionOS)
|
||||
ControllerView(controller: { controller.toolbarController })
|
||||
#endif
|
||||
}
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||
#if targetEnvironment(macCatalyst)
|
||||
ToolbarItem(placement: .topBarTrailing) { draftsButton }
|
||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||
#else
|
||||
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
|
||||
#endif
|
||||
#if os(visionOS)
|
||||
ToolbarItem(placement: .bottomOrnament) {
|
||||
ControllerView(controller: { controller.toolbarController })
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
|
||||
.onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in
|
||||
globalFrameOutsideList = newValue
|
||||
}
|
||||
})
|
||||
.sheet(isPresented: $controller.isShowingDraftsList) {
|
||||
ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) })
|
||||
}
|
||||
.alertWithData("Error Posting", data: $controller.postError, actions: { _ in
|
||||
Button("OK") {}
|
||||
}, message: { error in
|
||||
Text(error.localizedDescription)
|
||||
})
|
||||
.matchedGeometryPresentation(id: Binding(get: { () -> UUID?? in
|
||||
let id = controller.focusedAttachment?.0.id
|
||||
// this needs to be a double optional, since the type used for for the presentationID in the geom source is a UUID?
|
||||
return id.map { Optional.some($0) }
|
||||
}, set: {
|
||||
if $0 == nil {
|
||||
controller.focusedAttachment = nil
|
||||
} else {
|
||||
fatalError()
|
||||
}
|
||||
}), backgroundColor: .black) {
|
||||
ControllerView(controller: {
|
||||
FocusedAttachmentController(
|
||||
parent: controller,
|
||||
attachment: controller.focusedAttachment!.0,
|
||||
thumbnailController: controller.focusedAttachment!.1
|
||||
)
|
||||
})
|
||||
}
|
||||
.onDisappear(perform: controller.onDisappear)
|
||||
.navigationTitle(controller.navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var mainList: some View {
|
||||
List {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = controller.fetchStatus(id) {
|
||||
ReplyStatusView(
|
||||
status: status,
|
||||
rowTopInset: 8,
|
||||
globalFrameOutsideList: globalFrameOutsideList
|
||||
)
|
||||
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
|
||||
.id(id)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
|
||||
HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining)
|
||||
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
|
||||
if draft.contentWarningEnabled {
|
||||
EmojiTextField(
|
||||
text: $draft.contentWarning,
|
||||
placeholder: "Write your warning here",
|
||||
maxLength: nil,
|
||||
becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
|
||||
focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
|
||||
MainTextView()
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
|
||||
if let poll = draft.poll {
|
||||
ControllerView(controller: { PollController(parent: controller, poll: poll) })
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
|
||||
ControllerView(controller: { controller.attachmentsListController })
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.disabled(controller.isPosting)
|
||||
}
|
||||
|
||||
private var cancelButton: some View {
|
||||
Button(action: controller.cancel) {
|
||||
Text("Cancel")
|
||||
// otherwise all Buttons in the nav bar are made semibold
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
}
|
||||
.disabled(controller.isPosting)
|
||||
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
||||
// edit drafts can't be saved
|
||||
if draft.editedStatusID == nil {
|
||||
Button(action: { controller.cancel(deleteDraft: false) }) {
|
||||
Text("Save Draft")
|
||||
}
|
||||
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||
Text("Delete Draft")
|
||||
}
|
||||
} else {
|
||||
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||
Text("Cancel Edit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var postOrDraftsButton: some View {
|
||||
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||
postButton
|
||||
} else {
|
||||
draftsButton
|
||||
}
|
||||
}
|
||||
|
||||
private var draftsButton: some View {
|
||||
Button(action: controller.showDrafts) {
|
||||
Text("Drafts")
|
||||
}
|
||||
}
|
||||
|
||||
private var postButton: some View {
|
||||
Button(action: controller.postStatus) {
|
||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(!controller.postButtonEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
|
||||
static var defaultValue: CGRect = .zero
|
||||
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue = ComposeUIConfig()
|
||||
}
|
||||
extension EnvironmentValues {
|
||||
var composeUIConfig: ComposeUIConfig {
|
||||
get { self[ComposeUIConfigEnvironmentKey.self] }
|
||||
set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,174 @@
|
|||
//
|
||||
// DraftsController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/7/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TuskerComponents
|
||||
import CoreData
|
||||
|
||||
class DraftsController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
@Published var draftForDifferentReply: Draft?
|
||||
|
||||
init(parent: ComposeController, isPresented: Binding<Bool>) {
|
||||
self.parent = parent
|
||||
self._isPresented = isPresented
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
DraftsRepresentable()
|
||||
}
|
||||
|
||||
func maybeSelectDraft(_ draft: Draft) {
|
||||
if draft.inReplyToID != parent.draft.inReplyToID,
|
||||
parent.draft.hasContent {
|
||||
draftForDifferentReply = draft
|
||||
} else {
|
||||
confirmSelectDraft(draft)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelSelectingDraft() {
|
||||
draftForDifferentReply = nil
|
||||
}
|
||||
|
||||
func confirmSelectDraft(_ draft: Draft) {
|
||||
parent.selectDraft(draft)
|
||||
closeDrafts()
|
||||
}
|
||||
|
||||
func deleteDraft(_ draft: Draft) {
|
||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||
}
|
||||
|
||||
func closeDrafts() {
|
||||
isPresented = false
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
struct DraftsRepresentable: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = UIHostingController<DraftsView>
|
||||
|
||||
func makeUIViewController(context: Context) -> UIHostingController<DraftsController.DraftsView> {
|
||||
return UIHostingController(rootView: DraftsView())
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIHostingController<DraftsController.DraftsView>, context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
struct DraftsView: View {
|
||||
@EnvironmentObject private var controller: DraftsController
|
||||
@EnvironmentObject private var currentDraft: Draft
|
||||
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
ForEach(drafts) { draft in
|
||||
Button(action: { controller.maybeSelectDraft(draft) }) {
|
||||
DraftRow(draft: draft)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive, action: { controller.deleteDraft(draft) }) {
|
||||
Label("Delete Draft", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.ifLet(controller.parent.config.userActivityForDraft(draft), modify: { view, activity in
|
||||
view.onDrag { activity }
|
||||
})
|
||||
}
|
||||
.onDelete { indices in
|
||||
indices.map { drafts[$0] }.forEach(controller.deleteDraft)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Drafts")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||
}
|
||||
}
|
||||
.alertWithData("Different Reply", data: $controller.draftForDifferentReply) { draft in
|
||||
Button(role: .cancel, action: controller.cancelSelectingDraft) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(action: { controller.confirmSelectDraft(draft) }) {
|
||||
Text("Restore Draft")
|
||||
}
|
||||
} message: { _ in
|
||||
Text("The selected draft is a reply to a different post, do you wish to use it?")
|
||||
}
|
||||
.onAppear {
|
||||
drafts.nsPredicate = NSPredicate(format: "accountID == %@ AND id != %@ AND lastModified != nil", controller.parent.mastodonController.accountInfo!.id, currentDraft.id as NSUUID)
|
||||
}
|
||||
}
|
||||
|
||||
private var cancelButton: some View {
|
||||
Button(action: controller.closeDrafts) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DraftRow: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@EnvironmentObject private var controller: DraftsController
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
if draft.editedStatusID != nil {
|
||||
// shouldn't happen unless the app crashed/was killed during an edit
|
||||
Text("Edit")
|
||||
.font(.body.bold())
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
|
||||
if draft.contentWarningEnabled {
|
||||
Text(draft.contentWarning)
|
||||
.font(.body.bold())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text(draft.text)
|
||||
.font(.body)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(draft.draftAttachments) { attachment in
|
||||
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment, parent: controller.parent) })
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
.frame(height: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let lastModified = draft.lastModified {
|
||||
Text(lastModified.formatted(.abbreviatedTimeAgo))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func ifLet<T, V: View>(_ value: T?, modify: (Self, T) -> V) -> some View {
|
||||
if let value {
|
||||
modify(self, value)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
//
|
||||
// FocusedAttachmentController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/29/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MatchedGeometryPresentation
|
||||
import AVKit
|
||||
|
||||
class FocusedAttachmentController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
let attachment: DraftAttachment
|
||||
let thumbnailController: AttachmentThumbnailController
|
||||
private let player: AVPlayer?
|
||||
|
||||
init(parent: ComposeController, attachment: DraftAttachment, thumbnailController: AttachmentThumbnailController) {
|
||||
self.parent = parent
|
||||
self.attachment = attachment
|
||||
self.thumbnailController = thumbnailController
|
||||
|
||||
if case let .file(url, type) = attachment.data,
|
||||
type.conforms(to: .movie) {
|
||||
self.player = AVPlayer(url: url)
|
||||
self.player!.isMuted = true
|
||||
} else {
|
||||
self.player = nil
|
||||
}
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
FocusedAttachmentView(attachment: attachment)
|
||||
}
|
||||
|
||||
struct FocusedAttachmentView: View {
|
||||
@ObservedObject var attachment: DraftAttachment
|
||||
@EnvironmentObject private var controller: FocusedAttachmentController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var textEditorFocused: Bool
|
||||
@EnvironmentObject private var matchedGeomState: MatchedGeometryState
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if let player = controller.player {
|
||||
VideoPlayer(player: player)
|
||||
.matchedGeometryDestination(id: attachment.id)
|
||||
.onAppear {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
ZoomableScrollView {
|
||||
attachmentView
|
||||
.matchedGeometryDestination(id: attachment.id)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
FocusedAttachmentDescriptionView(attachment: attachment)
|
||||
.environment(\.colorScheme, .dark)
|
||||
.matchedGeometryDestination(id: AttachmentDescriptionTextViewID(attachment))
|
||||
.frame(height: 150)
|
||||
.focused($textEditorFocused)
|
||||
}
|
||||
.background(.black)
|
||||
.overlay(alignment: .topLeading, content: {
|
||||
Button {
|
||||
// set the mode to dismissing immediately, so that layout changes due to the keyboard hiding
|
||||
// (which happens before the dismiss animation controller starts running) don't alter the destination frames
|
||||
if textEditorFocused {
|
||||
matchedGeomState.mode = .dismissing
|
||||
}
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "arrow.down.forward.and.arrow.up.backward")
|
||||
}
|
||||
.buttonStyle(DismissFocusedAttachmentButtonStyle())
|
||||
.padding([.top, .leading], 4)
|
||||
})
|
||||
}
|
||||
|
||||
private var attachmentView: some View {
|
||||
ControllerView(controller: { controller.thumbnailController })
|
||||
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DismissFocusedAttachmentButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(.black.opacity(0.5))
|
||||
|
||||
configuration.label
|
||||
.foregroundColor(.white)
|
||||
.imageScale(.large)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentDescriptionTextViewID: Hashable {
|
||||
let attachmentID: UUID!
|
||||
|
||||
init(_ attachment: DraftAttachment) {
|
||||
self.attachmentID = attachment.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(attachmentID)
|
||||
hasher.combine("descriptionTextView")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// PlaceholderController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/6/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||
|
||||
private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView()
|
||||
|
||||
static func makePlaceholderView() -> some View {
|
||||
let components = Calendar.current.dateComponents([.month, .day], from: Date())
|
||||
if components.month == 3 && components.day == 14,
|
||||
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||
Text("Happy π day!")
|
||||
} else if components.month == 4 && components.day == 1 {
|
||||
Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
|
||||
} else if components.month == 9 && components.day == 5 {
|
||||
// https://weirder.earth/@noracodes/109276419847254552
|
||||
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
||||
Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
|
||||
} else if components.month == 9 && components.day == 21 {
|
||||
Text("Do you remember?")
|
||||
} else if components.month == 10 && components.day == 31 {
|
||||
if .random() {
|
||||
Text("Post something spooky!")
|
||||
} else {
|
||||
Text("Any questions?")
|
||||
}
|
||||
} else {
|
||||
Text("What's on your mind?")
|
||||
}
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
placeholderView
|
||||
}
|
||||
}
|
||||
|
||||
// exists to provide access to the type alias since the @State property needs it to be explicit
|
||||
private protocol PlaceholderViewProvider {
|
||||
associatedtype PlaceholderView: View
|
||||
@ViewBuilder
|
||||
static func makePlaceholderView() -> PlaceholderView
|
||||
}
|
|
@ -0,0 +1,195 @@
|
|||
//
|
||||
// PollController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/25/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TuskerComponents
|
||||
|
||||
class PollController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
var draft: Draft { parent.draft }
|
||||
let poll: Poll
|
||||
|
||||
@Published var duration: Duration
|
||||
|
||||
init(parent: ComposeController, poll: Poll) {
|
||||
self.parent = parent
|
||||
self.poll = poll
|
||||
self.duration = .fromTimeInterval(poll.duration) ?? .oneDay
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
PollView()
|
||||
.environmentObject(poll)
|
||||
}
|
||||
|
||||
private func removePoll() {
|
||||
withAnimation {
|
||||
draft.poll = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func moveOptions(indices: IndexSet, newIndex: Int) {
|
||||
// see AttachmentsListController.moveAttachments
|
||||
var array = poll.pollOptions
|
||||
array.move(fromOffsets: indices, toOffset: newIndex)
|
||||
poll.options = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private func removeOption(_ option: PollOption) {
|
||||
var array = poll.pollOptions
|
||||
array.remove(at: poll.options.index(of: option))
|
||||
poll.options = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private var canAddOption: Bool {
|
||||
if let max = parent.mastodonController.instanceFeatures.maxPollOptionsCount {
|
||||
return poll.options.count < max
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func addOption() {
|
||||
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
|
||||
option.poll = poll
|
||||
poll.options.add(option)
|
||||
}
|
||||
|
||||
struct PollView: View {
|
||||
@EnvironmentObject private var controller: PollController
|
||||
@EnvironmentObject private var poll: Poll
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Poll")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: controller.removePoll) {
|
||||
Image(systemName: "xmark")
|
||||
.imageScale(.small)
|
||||
.padding(4)
|
||||
}
|
||||
.accessibilityLabel("Remove poll")
|
||||
.buttonStyle(.plain)
|
||||
.accentColor(buttonForegroundColor)
|
||||
.background(Circle().foregroundColor(buttonBackgroundColor))
|
||||
.hoverEffect()
|
||||
}
|
||||
|
||||
List {
|
||||
ForEach($poll.pollOptions) { $option in
|
||||
PollOptionView(option: option, remove: { controller.removeOption(option) })
|
||||
.frame(height: 36)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.onMove(perform: controller.moveOptions)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollDisabled(true)
|
||||
.frame(height: 44 * CGFloat(poll.options.count))
|
||||
|
||||
Button(action: controller.addOption) {
|
||||
Label {
|
||||
Text("Add Option")
|
||||
} icon: {
|
||||
Image(systemName: "plus")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(!controller.canAddOption)
|
||||
|
||||
HStack {
|
||||
MenuPicker(selection: $poll.multiple, options: [
|
||||
.init(value: true, title: "Allow multiple"),
|
||||
.init(value: false, title: "Single choice"),
|
||||
])
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
MenuPicker(selection: $controller.duration, options: Duration.allCases.map {
|
||||
.init(value: $0, title: Duration.formatter.string(from: $0.timeInterval)!)
|
||||
})
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.foregroundColor(backgroundColor)
|
||||
)
|
||||
#if os(visionOS)
|
||||
.onChange(of: controller.duration) {
|
||||
poll.duration = controller.duration.timeInterval
|
||||
}
|
||||
#else
|
||||
.onChange(of: controller.duration) { newValue in
|
||||
poll.duration = newValue.timeInterval
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
// in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
|
||||
colorScheme == .dark ? controller.parent.config.fillColor : Color(white: 0.95)
|
||||
}
|
||||
|
||||
private var buttonForegroundColor: Color {
|
||||
Color(uiColor: .label)
|
||||
}
|
||||
|
||||
private var buttonBackgroundColor: Color {
|
||||
Color(white: colorScheme == .dark ? 0.1 : 0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PollController {
|
||||
enum Duration: Hashable, Equatable, CaseIterable {
|
||||
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
|
||||
|
||||
static let formatter: DateComponentsFormatter = {
|
||||
let f = DateComponentsFormatter()
|
||||
f.maximumUnitCount = 1
|
||||
f.unitsStyle = .full
|
||||
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
|
||||
return f
|
||||
}()
|
||||
|
||||
static func fromTimeInterval(_ ti: TimeInterval) -> Duration? {
|
||||
for it in allCases where it.timeInterval == ti {
|
||||
return it
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var timeInterval: TimeInterval {
|
||||
switch self {
|
||||
case .fiveMinutes:
|
||||
return 5 * 60
|
||||
case .thirtyMinutes:
|
||||
return 30 * 60
|
||||
case .oneHour:
|
||||
return 60 * 60
|
||||
case .sixHours:
|
||||
return 6 * 60 * 60
|
||||
case .oneDay:
|
||||
return 24 * 60 * 60
|
||||
case .threeDays:
|
||||
return 3 * 24 * 60 * 60
|
||||
case .sevenDays:
|
||||
return 7 * 24 * 60 * 60
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
//
|
||||
// ToolbarController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/7/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class ToolbarController: ViewController {
|
||||
static let height: CGFloat = 44
|
||||
|
||||
unowned let parent: ComposeController
|
||||
|
||||
@Published var minWidth: CGFloat?
|
||||
@Published var realWidth: CGFloat?
|
||||
|
||||
init(parent: ComposeController) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
ToolbarView()
|
||||
}
|
||||
|
||||
func showEmojiPicker() {
|
||||
guard parent.currentInput?.autocompleteState == nil else {
|
||||
return
|
||||
}
|
||||
parent.shouldEmojiAutocompletionBeginExpanded = true
|
||||
parent.currentInput?.beginAutocompletingEmoji()
|
||||
}
|
||||
|
||||
func formatAction(_ format: StatusFormat) -> () -> Void {
|
||||
{ [weak self] in
|
||||
self?.parent.currentInput?.applyFormat(format)
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolbarView: View {
|
||||
@EnvironmentObject private var draft: Draft
|
||||
@EnvironmentObject private var controller: ToolbarController
|
||||
@EnvironmentObject private var composeController: ComposeController
|
||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||
|
||||
#if !os(visionOS)
|
||||
@State private var minWidth: CGFloat?
|
||||
@State private var realWidth: CGFloat?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(visionOS)
|
||||
buttons
|
||||
#else
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
buttons
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: minWidth)
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||
realWidth = width
|
||||
}
|
||||
})
|
||||
}
|
||||
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
|
||||
.frame(height: ToolbarController.height)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||
.overlay(alignment: .top) {
|
||||
Divider()
|
||||
.edgesIgnoringSafeArea([.leading, .trailing])
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||
minWidth = width
|
||||
}
|
||||
})
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var buttons: some View {
|
||||
HStack(spacing: 0) {
|
||||
cwButton
|
||||
|
||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
||||
|
||||
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
||||
localOnlyPicker
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.padding(.leading, 4)
|
||||
#elseif !os(visionOS)
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
}
|
||||
|
||||
if let currentInput = composeController.currentInput,
|
||||
currentInput.toolbarElements.contains(.emojiPicker) {
|
||||
customEmojiButton
|
||||
}
|
||||
|
||||
if let currentInput = composeController.currentInput,
|
||||
currentInput.toolbarElements.contains(.formattingButtons),
|
||||
composeController.config.contentType != .plain {
|
||||
|
||||
Spacer()
|
||||
formatButtons
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cwButton: some View {
|
||||
Button("CW", action: controller.parent.toggleContentWarning)
|
||||
.accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
}
|
||||
|
||||
private var visibilityBinding: Binding<Pachyderm.Visibility> {
|
||||
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
|
||||
// changing the visibility when local-only.
|
||||
if draft.localOnly,
|
||||
composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility {
|
||||
return .constant(.public)
|
||||
} else {
|
||||
return $draft.visibility
|
||||
}
|
||||
}
|
||||
|
||||
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
|
||||
let visibilities: [Pachyderm.Visibility]
|
||||
if !composeController.mastodonController.instanceFeatures.composeDirectStatuses {
|
||||
visibilities = [.public, .unlisted, .private]
|
||||
} else {
|
||||
visibilities = Pachyderm.Visibility.allCases
|
||||
}
|
||||
return visibilities.map { vis in
|
||||
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
private var localOnlyPicker: some View {
|
||||
let domain = composeController.mastodonController.accountInfo!.instanceURL.host!
|
||||
return MenuPicker(selection: $draft.localOnly, options: [
|
||||
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
|
||||
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
|
||||
], buttonStyle: .iconOnly)
|
||||
}
|
||||
|
||||
private var customEmojiButton: some View {
|
||||
Button(action: controller.showEmojiPicker) {
|
||||
Label("Insert custom emoji", systemImage: "face.smiling")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: imageSize))
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||
}
|
||||
|
||||
private var formatButtons: some View {
|
||||
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
||||
Button(action: controller.formatAction(format)) {
|
||||
Image(systemName: format.imageName)
|
||||
.font(.system(size: imageSize))
|
||||
}
|
||||
.accessibilityLabel(format.accessibilityLabel)
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolbarWidthPrefKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat? = nil
|
||||
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
//
|
||||
// Draft.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/22/23.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Pachyderm
|
||||
|
||||
@objc
|
||||
public class Draft: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Draft> {
|
||||
return NSFetchRequest<Draft>(entityName: "Draft")
|
||||
}
|
||||
|
||||
@nonobjc public class func fetchRequest(id: UUID) -> NSFetchRequest<Draft> {
|
||||
let req = NSFetchRequest<Draft>(entityName: "Draft")
|
||||
req.predicate = NSPredicate(format: "id = %@", id as NSUUID)
|
||||
return req
|
||||
}
|
||||
|
||||
@NSManaged public var accountID: String
|
||||
@NSManaged public var contentWarning: String
|
||||
@NSManaged public var contentWarningEnabled: Bool
|
||||
@NSManaged public var editedStatusID: String?
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var initialContentWarning: String?
|
||||
@NSManaged public var initialText: String
|
||||
@NSManaged public var inReplyToID: String?
|
||||
@NSManaged public var language: String? // ISO 639 language code
|
||||
@NSManaged public var lastModified: Date!
|
||||
@NSManaged public var localOnly: Bool
|
||||
@NSManaged public var text: String
|
||||
@NSManaged private var visibilityStr: String
|
||||
|
||||
@NSManaged internal var attachments: NSMutableOrderedSet
|
||||
@NSManaged public var poll: Poll?
|
||||
|
||||
public var visibility: Visibility {
|
||||
get {
|
||||
Visibility(rawValue: visibilityStr) ?? .public
|
||||
}
|
||||
set {
|
||||
visibilityStr = newValue.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
public var draftAttachments: [DraftAttachment] {
|
||||
get {
|
||||
attachments.array as! [DraftAttachment]
|
||||
}
|
||||
set {
|
||||
attachments = NSMutableOrderedSet(array: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
lastModified = Date()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Draft {
|
||||
public var hasContent: Bool {
|
||||
(!text.isEmpty && text != initialText) ||
|
||||
(contentWarningEnabled && !contentWarning.isEmpty && contentWarning != initialContentWarning) ||
|
||||
attachments.count > 0 ||
|
||||
poll?.hasContent == true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,341 @@
|
|||
//
|
||||
// DraftAttachment.swift
|
||||
// CoreData
|
||||
//
|
||||
// Created by Shadowfacts on 4/22/23.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import PencilKit
|
||||
import UniformTypeIdentifiers
|
||||
import Photos
|
||||
import InstanceFeatures
|
||||
import Pachyderm
|
||||
|
||||
private let decoder = PropertyListDecoder()
|
||||
private let encoder = PropertyListEncoder()
|
||||
|
||||
@objc
|
||||
public final class DraftAttachment: NSManagedObject, Identifiable {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<DraftAttachment> {
|
||||
return NSFetchRequest<DraftAttachment>(entityName: "DraftAttachment")
|
||||
}
|
||||
|
||||
@NSManaged internal var assetID: String?
|
||||
@NSManaged public var attachmentDescription: String
|
||||
@NSManaged internal private(set) var drawingData: Data?
|
||||
@NSManaged public var editedAttachmentID: String?
|
||||
@NSManaged private var editedAttachmentKindString: String?
|
||||
@NSManaged public var editedAttachmentURL: URL?
|
||||
@NSManaged public var fileURL: URL?
|
||||
@NSManaged internal var fileType: String?
|
||||
@NSManaged public var id: UUID!
|
||||
|
||||
@NSManaged internal var draft: Draft
|
||||
|
||||
public var drawing: PKDrawing? {
|
||||
get {
|
||||
if let drawingData,
|
||||
let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) {
|
||||
return drawing
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
set {
|
||||
drawingData = try! encoder.encode(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
public var data: AttachmentData {
|
||||
if let editedAttachmentID {
|
||||
return .editing(editedAttachmentID, editedAttachmentKind!, editedAttachmentURL!)
|
||||
} else if let assetID {
|
||||
return .asset(assetID)
|
||||
} else if let drawing {
|
||||
return .drawing(drawing)
|
||||
} else if let fileURL, let fileType {
|
||||
return .file(fileURL, UTType(fileType)!)
|
||||
} else {
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
public var editedAttachmentKind: Attachment.Kind? {
|
||||
get {
|
||||
editedAttachmentKindString.flatMap(Attachment.Kind.init(rawValue:))
|
||||
}
|
||||
set {
|
||||
editedAttachmentKindString = newValue?.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
public enum AttachmentData {
|
||||
case asset(String)
|
||||
case drawing(PKDrawing)
|
||||
case file(URL, UTType)
|
||||
case editing(String, Attachment.Kind, URL)
|
||||
case none
|
||||
}
|
||||
|
||||
public override func prepareForDeletion() {
|
||||
super.prepareForDeletion()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DraftAttachment {
|
||||
var type: AttachmentType {
|
||||
if let editedAttachmentKind {
|
||||
switch editedAttachmentKind {
|
||||
case .image:
|
||||
return .image
|
||||
case .video:
|
||||
return .video
|
||||
case .gifv:
|
||||
return .video
|
||||
case .audio, .unknown:
|
||||
return .unknown
|
||||
}
|
||||
} else if let assetID {
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
|
||||
return .unknown
|
||||
}
|
||||
switch asset.mediaType {
|
||||
case .image:
|
||||
return .image
|
||||
case .video:
|
||||
return .video
|
||||
default:
|
||||
return .unknown
|
||||
}
|
||||
} else if drawingData != nil {
|
||||
return .image
|
||||
} else if let fileType,
|
||||
let type = UTType(fileType) {
|
||||
if type.conforms(to: .image) {
|
||||
return .image
|
||||
} else if type.conforms(to: .movie) {
|
||||
return .video
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
enum AttachmentType {
|
||||
case image, video, unknown
|
||||
}
|
||||
}
|
||||
|
||||
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
|
||||
|
||||
private let imageType = UTType.image.identifier
|
||||
private let heifType = UTType.heif.identifier
|
||||
private let heicType = UTType.heic.identifier
|
||||
private let jpegType = UTType.jpeg.identifier
|
||||
private let pngType = UTType.png.identifier
|
||||
private let mp4Type = UTType.mpeg4Movie.identifier
|
||||
private let quickTimeType = UTType.quickTimeMovie.identifier
|
||||
private let gifType = UTType.gif.identifier
|
||||
|
||||
extension DraftAttachment: NSItemProviderReading {
|
||||
public static var readableTypeIdentifiersForItemProvider: [String] {
|
||||
// todo: is there a better way of handling movies than manually adding all possible UTI types?
|
||||
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
|
||||
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
|
||||
[/*typeIdentifier, */ gifType, heifType, heicType, jpegType, pngType, imageType, mp4Type, quickTimeType]
|
||||
}
|
||||
|
||||
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
|
||||
var data = data
|
||||
var type = UTType(typeIdentifier)!
|
||||
|
||||
// the type is .image in certain circumstances:
|
||||
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works
|
||||
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
|
||||
if type == .image,
|
||||
let image = UIImage(data: data) ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)),
|
||||
let pngData = image.pngData() {
|
||||
data = pngData
|
||||
type = .png
|
||||
}
|
||||
|
||||
// Read the caption from the image itself, if there is one.
|
||||
let caption: String
|
||||
if let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceTypeIdentifierHint: typeIdentifier as CFString] as CFDictionary),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any],
|
||||
// This is the dictionary for TIFF properties, but it's present for other image types too
|
||||
let tiffProperties = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any],
|
||||
let imageDescription = tiffProperties[kCGImagePropertyTIFFImageDescription as String] as? String {
|
||||
caption = imageDescription
|
||||
} else {
|
||||
caption = ""
|
||||
}
|
||||
|
||||
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
|
||||
attachment.id = UUID()
|
||||
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
|
||||
attachment.fileType = type.identifier
|
||||
attachment.attachmentDescription = caption
|
||||
return attachment
|
||||
}
|
||||
|
||||
static var attachmentsDirectory: URL {
|
||||
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
|
||||
return containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments")
|
||||
}
|
||||
|
||||
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL {
|
||||
let directoryURL = attachmentsDirectory
|
||||
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||
let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type)
|
||||
try data.write(to: attachmentURL)
|
||||
return attachmentURL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Exporting
|
||||
|
||||
extension DraftAttachment {
|
||||
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
|
||||
if let assetID {
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
|
||||
completion(.failure(.noAsset))
|
||||
return
|
||||
}
|
||||
if asset.mediaType == .image {
|
||||
let options = PHImageRequestOptions()
|
||||
options.version = .current
|
||||
options.deliveryMode = .highQualityFormat
|
||||
options.resizeMode = .none
|
||||
options.isNetworkAccessAllowed = true
|
||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, dataUTI, orientation, info in
|
||||
guard let data, let dataUTI else {
|
||||
completion(.failure(.missingAssetData))
|
||||
return
|
||||
}
|
||||
let processed = Self.processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
|
||||
completion(.success(processed))
|
||||
}
|
||||
} else if asset.mediaType == .video {
|
||||
let options = PHVideoRequestOptions()
|
||||
options.version = .current
|
||||
options.deliveryMode = .automatic
|
||||
options.isNetworkAccessAllowed = true
|
||||
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
|
||||
if let exportSession {
|
||||
Self.exportVideoData(session: exportSession, features: features, completion: completion)
|
||||
} else if let error = info?[PHImageErrorKey] as? Error {
|
||||
completion(.failure(.videoExport(error)))
|
||||
} else {
|
||||
completion(.failure(.noVideoExportSession))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(.failure(.unknownAssetType))
|
||||
}
|
||||
} else if let drawingData {
|
||||
guard let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) else {
|
||||
completion(.failure(.loadingDrawing))
|
||||
return
|
||||
}
|
||||
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
|
||||
completion(.success((image.pngData()!, .png)))
|
||||
} else if let fileURL, let fileType {
|
||||
let type = UTType(fileType)!
|
||||
|
||||
if type.conforms(to: .movie) {
|
||||
let asset = AVURLAsset(url: fileURL)
|
||||
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
completion(.failure(.noVideoExportSession))
|
||||
return
|
||||
}
|
||||
Self.exportVideoData(session: session, features: features, completion: completion)
|
||||
} else {
|
||||
let fileData: Data
|
||||
do {
|
||||
fileData = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
completion(.failure(.loadingData))
|
||||
return
|
||||
}
|
||||
|
||||
if type != .gif,
|
||||
type.conforms(to: .image) {
|
||||
let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion)
|
||||
completion(.success(result))
|
||||
} else {
|
||||
completion(.success((fileData, type)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(.failure(.noData))
|
||||
}
|
||||
}
|
||||
|
||||
private static func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
|
||||
guard !skipAllConversion else {
|
||||
return (data, type)
|
||||
}
|
||||
|
||||
var data = data
|
||||
var type = type
|
||||
|
||||
let image = CIImage(data: data)!
|
||||
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
||||
|
||||
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
||||
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
|
||||
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
||||
if needsColorSpaceConversion || type == .heic || type == .heif {
|
||||
let context = CIContext()
|
||||
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
||||
if type == .png {
|
||||
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
|
||||
} else {
|
||||
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
|
||||
type = .jpeg
|
||||
}
|
||||
}
|
||||
|
||||
return (data, type)
|
||||
}
|
||||
|
||||
private static func exportVideoData(session: AVAssetExportSession, features: InstanceFeatures, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
|
||||
session.outputFileType = .mp4
|
||||
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
|
||||
if let configuration = features.mediaAttachmentsConfiguration {
|
||||
session.fileLengthLimit = Int64(configuration.videoSizeLimit)
|
||||
}
|
||||
session.exportAsynchronously {
|
||||
guard session.status == .completed else {
|
||||
completion(.failure(.videoExport(session.error!)))
|
||||
return
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: session.outputURL!)
|
||||
completion(.success((data, .mpeg4Movie)))
|
||||
} catch {
|
||||
completion(.failure(.videoExport(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ExportError: Error {
|
||||
case noAsset
|
||||
case unknownAssetType
|
||||
case missingAssetData
|
||||
case videoExport(Error)
|
||||
case noVideoExportSession
|
||||
case loadingDrawing
|
||||
case loadingData
|
||||
case noData
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
|
||||
<attribute name="accountID" attributeType="String"/>
|
||||
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="initialContentWarning" optional="YES" attributeType="String"/>
|
||||
<attribute name="initialText" attributeType="String"/>
|
||||
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||
<attribute name="language" optional="YES" attributeType="String"/>
|
||||
<attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="text" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="visibilityStr" optional="YES" attributeType="String"/>
|
||||
<relationship name="attachments" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="DraftAttachment" inverseName="draft" inverseEntity="DraftAttachment"/>
|
||||
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="draft" inverseEntity="Poll"/>
|
||||
</entity>
|
||||
<entity name="DraftAttachment" representedClassName="ComposeUI.DraftAttachment" syncable="YES">
|
||||
<attribute name="assetID" optional="YES" attributeType="String"/>
|
||||
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="drawingData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="editedAttachmentID" optional="YES" attributeType="String"/>
|
||||
<attribute name="editedAttachmentKindString" optional="YES" attributeType="String"/>
|
||||
<attribute name="editedAttachmentURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="fileType" optional="YES" attributeType="String"/>
|
||||
<attribute name="fileURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="attachments" inverseEntity="Draft"/>
|
||||
</entity>
|
||||
<entity name="Poll" representedClassName="ComposeUI.Poll" syncable="YES">
|
||||
<attribute name="duration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="poll" inverseEntity="Draft"/>
|
||||
<relationship name="options" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
|
||||
</entity>
|
||||
<entity name="PollOption" representedClassName="ComposeUI.PollOption" syncable="YES">
|
||||
<attribute name="text" attributeType="String" defaultValueString=""/>
|
||||
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
|
||||
</entity>
|
||||
</model>
|
|
@ -0,0 +1,217 @@
|
|||
//
|
||||
// DraftsPersistentContainer.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/22/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import OSLog
|
||||
import Pachyderm
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer")
|
||||
|
||||
public class DraftsPersistentContainer: NSPersistentContainer {
|
||||
|
||||
public static let shared = DraftsPersistentContainer()
|
||||
|
||||
public static var captureError: ((any Error) -> Void)?
|
||||
|
||||
private static let managedObjectModel: NSManagedObjectModel = {
|
||||
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
|
||||
return NSManagedObjectModel(contentsOf: url)!
|
||||
}()
|
||||
|
||||
private var lastHistoryToken: NSPersistentHistoryToken!
|
||||
|
||||
init() {
|
||||
super.init(name: "Drafts", managedObjectModel: DraftsPersistentContainer.managedObjectModel)
|
||||
|
||||
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
|
||||
let documentsURL = containerURL.appendingPathComponent("Documents")
|
||||
let storeDesc = NSPersistentStoreDescription(url: documentsURL.appendingPathComponent("drafts").appendingPathExtension("sqlite"))
|
||||
storeDesc.type = NSSQLiteStoreType
|
||||
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||
|
||||
persistentStoreDescriptions = [
|
||||
storeDesc
|
||||
]
|
||||
|
||||
loadPersistentStores { _, error in
|
||||
if let error {
|
||||
DraftsPersistentContainer.captureError?(error)
|
||||
fatalError("Loading persistent store: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
viewContext.automaticallyMergesChangesFromParent = true
|
||||
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||
|
||||
lastHistoryToken = persistentStoreCoordinator.currentPersistentHistoryToken(fromStores: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges(_:)), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
||||
}
|
||||
|
||||
public func save() {
|
||||
guard viewContext.hasChanges else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
logger.error("Failed to save: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
||||
public func migrate(from url: URL, completion: @escaping (Result<(), any Error>) -> Void) {
|
||||
performBackgroundTask { context in
|
||||
let result = DraftsMigrator.migrate(from: url, to: context)
|
||||
completion(result)
|
||||
try! context.save()
|
||||
}
|
||||
}
|
||||
|
||||
public func getDraft(id: UUID) -> Draft? {
|
||||
let req = Draft.fetchRequest(id: id)
|
||||
return try? viewContext.fetch(req).first
|
||||
}
|
||||
|
||||
public func createDraft(
|
||||
accountID: String,
|
||||
text: String,
|
||||
contentWarning: String,
|
||||
inReplyToID: String?,
|
||||
visibility: Visibility,
|
||||
language: String?,
|
||||
localOnly: Bool
|
||||
) -> Draft {
|
||||
let draft = Draft(context: viewContext)
|
||||
draft.accountID = accountID
|
||||
draft.text = text
|
||||
draft.initialText = text
|
||||
draft.contentWarning = contentWarning
|
||||
draft.initialContentWarning = contentWarning
|
||||
draft.contentWarningEnabled = !contentWarning.isEmpty
|
||||
draft.inReplyToID = inReplyToID
|
||||
draft.visibility = visibility
|
||||
draft.language = language
|
||||
draft.localOnly = localOnly
|
||||
save()
|
||||
return draft
|
||||
}
|
||||
|
||||
public func createEditDraft(
|
||||
accountID: String,
|
||||
source: StatusSource,
|
||||
inReplyToID: String?,
|
||||
visibility: Visibility,
|
||||
localOnly: Bool,
|
||||
attachments: [Attachment],
|
||||
poll: Pachyderm.Poll?
|
||||
) -> Draft {
|
||||
let draft = Draft(context: viewContext)
|
||||
draft.accountID = accountID
|
||||
draft.editedStatusID = source.id
|
||||
draft.text = source.text
|
||||
draft.initialText = source.text
|
||||
draft.contentWarning = source.spoilerText
|
||||
draft.contentWarningEnabled = !source.spoilerText.isEmpty
|
||||
draft.initialContentWarning = source.spoilerText
|
||||
draft.inReplyToID = inReplyToID
|
||||
draft.visibility = visibility
|
||||
draft.localOnly = localOnly
|
||||
for attachment in attachments {
|
||||
createEditDraftAttachment(attachment, in: draft)
|
||||
}
|
||||
if let existingPoll = poll {
|
||||
let poll = Poll(context: viewContext)
|
||||
poll.draft = draft
|
||||
draft.poll = poll
|
||||
if let expiresAt = existingPoll.expiresAt,
|
||||
!existingPoll.effectiveExpired {
|
||||
poll.duration = PollController.Duration.allCases.max(by: {
|
||||
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
|
||||
})!.timeInterval
|
||||
} else {
|
||||
poll.duration = PollController.Duration.oneDay.timeInterval
|
||||
}
|
||||
poll.multiple = existingPoll.multiple
|
||||
// rmeove default empty options
|
||||
for opt in poll.pollOptions {
|
||||
viewContext.delete(opt)
|
||||
}
|
||||
for existingOpt in existingPoll.options {
|
||||
let opt = PollOption(context: viewContext)
|
||||
opt.poll = poll
|
||||
poll.options.add(opt)
|
||||
opt.text = existingOpt.title
|
||||
}
|
||||
}
|
||||
save()
|
||||
return draft
|
||||
}
|
||||
|
||||
private func createEditDraftAttachment(_ attachment: Attachment, in draft: Draft) {
|
||||
let draftAttachment = DraftAttachment(context: viewContext)
|
||||
draftAttachment.id = UUID()
|
||||
draftAttachment.attachmentDescription = attachment.description ?? ""
|
||||
draftAttachment.editedAttachmentID = attachment.id
|
||||
draftAttachment.editedAttachmentKind = attachment.kind
|
||||
draftAttachment.editedAttachmentURL = attachment.url
|
||||
draftAttachment.draft = draft
|
||||
draft.attachments.add(draftAttachment)
|
||||
}
|
||||
|
||||
public func removeOrphanedAttachments(completion: @escaping () -> Void) {
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(at: DraftAttachment.attachmentsDirectory, includingPropertiesForKeys: nil),
|
||||
!files.isEmpty else {
|
||||
return
|
||||
}
|
||||
performBackgroundTask { context in
|
||||
let allAttachmentsReq = DraftAttachment.fetchRequest()
|
||||
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
|
||||
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
|
||||
return
|
||||
}
|
||||
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
||||
for url in orphaned {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
||||
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||
return
|
||||
}
|
||||
|
||||
// todo: should this be on a background context?
|
||||
let context = viewContext
|
||||
context.perform {
|
||||
let predicate = NSPredicate(format: "(%@ < token) AND (token <= %@)", self.lastHistoryToken, newHistoryToken)
|
||||
|
||||
let historyRequest = NSPersistentHistoryTransaction.fetchRequest!
|
||||
historyRequest.predicate = predicate
|
||||
let request = NSPersistentHistoryChangeRequest.fetchHistory(withFetch: historyRequest)
|
||||
if let result = try? context.execute(request) as? NSPersistentHistoryResult,
|
||||
let transactions = result.result as? [NSPersistentHistoryTransaction] {
|
||||
for transaction in transactions {
|
||||
guard let userInfo = transaction.objectIDNotification().userInfo else {
|
||||
continue
|
||||
}
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context])
|
||||
}
|
||||
}
|
||||
|
||||
self.lastHistoryToken = newHistoryToken
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// Poll.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/22/23.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
@objc
|
||||
public class Poll: NSManagedObject {
|
||||
|
||||
@NSManaged public var duration: TimeInterval
|
||||
@NSManaged public var multiple: Bool
|
||||
|
||||
@NSManaged public var draft: Draft
|
||||
@NSManaged public var options: NSMutableOrderedSet
|
||||
|
||||
public var pollOptions: [PollOption] {
|
||||
get {
|
||||
options.array as! [PollOption]
|
||||
}
|
||||
set {
|
||||
options = NSMutableOrderedSet(array: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
self.multiple = false
|
||||
self.duration = 24 * 60 * 60 // 1 day
|
||||
if let managedObjectContext {
|
||||
self.options = [
|
||||
PollOption(context: managedObjectContext),
|
||||
PollOption(context: managedObjectContext),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Poll {
|
||||
public var hasContent: Bool {
|
||||
pollOptions.allSatisfy { !$0.text.isEmpty }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
//
|
||||
// PollOption.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/22/23.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
@objc
|
||||
public class PollOption: NSManagedObject, Identifiable {
|
||||
|
||||
public var id: NSManagedObjectID {
|
||||
objectID
|
||||
}
|
||||
|
||||
@NSManaged public var text: String
|
||||
|
||||
@NSManaged public var poll: Poll
|
||||
|
||||
}
|
|
@ -0,0 +1,255 @@
|
|||
//
|
||||
// DraftsMigrator.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/22/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import UniformTypeIdentifiers
|
||||
import Pachyderm
|
||||
import PencilKit
|
||||
import CoreData
|
||||
|
||||
struct DraftsMigrator {
|
||||
private init() {}
|
||||
|
||||
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsMigrator")
|
||||
private static let decoder = PropertyListDecoder()
|
||||
|
||||
static func migrate(from url: URL, to context: NSManagedObjectContext) -> Result<(), any Error> {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let container = try decoder.decode(DraftsContainer.self, from: data)
|
||||
for old in container.drafts.values {
|
||||
let new = Draft(context: context)
|
||||
new.id = old.id
|
||||
new.lastModified = old.lastModified
|
||||
new.accountID = old.accountID
|
||||
new.text = old.text
|
||||
new.contentWarningEnabled = old.contentWarningEnabled
|
||||
new.contentWarning = old.contentWarning
|
||||
new.inReplyToID = old.inReplyToID
|
||||
new.visibility = old.visibility
|
||||
new.localOnly = old.localOnly
|
||||
new.initialText = old.initialText
|
||||
|
||||
if let oldPoll = old.poll {
|
||||
let newPoll = Poll(context: context)
|
||||
newPoll.draft = new
|
||||
new.poll = newPoll
|
||||
newPoll.multiple = oldPoll.multiple
|
||||
newPoll.duration = oldPoll.duration
|
||||
for oldOption in oldPoll.options {
|
||||
let newOption = PollOption(context: context)
|
||||
newOption.text = oldOption.text
|
||||
newOption.poll = newPoll
|
||||
newPoll.options.add(newOption)
|
||||
}
|
||||
}
|
||||
|
||||
for oldAttachment in old.attachments {
|
||||
let newAttachment = DraftAttachment(context: context)
|
||||
newAttachment.draft = new
|
||||
new.attachments.add(newAttachment)
|
||||
newAttachment.id = oldAttachment.id
|
||||
newAttachment.attachmentDescription = oldAttachment.attachmentDescription
|
||||
switch oldAttachment.data {
|
||||
case .asset(let assetID):
|
||||
newAttachment.assetID = assetID
|
||||
case .image(let data, originalType: let type):
|
||||
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: type)
|
||||
newAttachment.fileType = type.identifier
|
||||
case .video(_):
|
||||
fatalError("unreachable, video attachments weren't encodable")
|
||||
case .drawing(let drawing):
|
||||
newAttachment.drawing = drawing
|
||||
case .gif(let data):
|
||||
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: .gif)
|
||||
newAttachment.fileType = UTType.gif.identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
logger.error("Error migrating: \(String(describing: error))")
|
||||
return .failure(error)
|
||||
}
|
||||
return .success(())
|
||||
}
|
||||
|
||||
// MARK: Supporting Types
|
||||
|
||||
struct DraftsContainer: Decodable {
|
||||
let drafts: [UUID: OldDraft]
|
||||
|
||||
init(drafts: [UUID: OldDraft]) {
|
||||
self.drafts = drafts
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft)
|
||||
}
|
||||
|
||||
enum CodingKeys: CodingKey {
|
||||
case drafts
|
||||
}
|
||||
}
|
||||
|
||||
// a container that always succeeds at decoding
|
||||
// so if a single draft can't be decoded, we don't lose all drafts
|
||||
struct SafeDraft: Decodable {
|
||||
let draft: OldDraft?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
self.draft = try? container.decode(OldDraft.self)
|
||||
}
|
||||
}
|
||||
|
||||
struct OldDraft: Decodable {
|
||||
let id: UUID
|
||||
let lastModified: Date
|
||||
let accountID: String
|
||||
let text: String
|
||||
let contentWarningEnabled: Bool
|
||||
let contentWarning: String
|
||||
let attachments: [OldDraftAttachment]
|
||||
let inReplyToID: String?
|
||||
let visibility: Visibility
|
||||
let poll: OldPoll?
|
||||
let localOnly: Bool
|
||||
let initialText: String
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(UUID.self, forKey: .id)
|
||||
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
|
||||
|
||||
self.accountID = try container.decode(String.self, forKey: .accountID)
|
||||
self.text = try container.decode(String.self, forKey: .text)
|
||||
self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled)
|
||||
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
|
||||
self.attachments = try container.decode([OldDraftAttachment].self, forKey: .attachments)
|
||||
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
|
||||
self.visibility = try container.decode(Visibility.self, forKey: .visibility)
|
||||
self.poll = try container.decode(OldPoll?.self, forKey: .poll)
|
||||
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
|
||||
|
||||
self.initialText = try container.decode(String.self, forKey: .initialText)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case lastModified
|
||||
|
||||
case accountID
|
||||
case text
|
||||
case contentWarningEnabled
|
||||
case contentWarning
|
||||
case attachments
|
||||
case inReplyToID
|
||||
case visibility
|
||||
case poll
|
||||
case localOnly
|
||||
|
||||
case initialText
|
||||
}
|
||||
}
|
||||
|
||||
struct OldDraftAttachment: Decodable {
|
||||
let id: UUID
|
||||
let data: OldDraftAttachmentData
|
||||
let attachmentDescription: String
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(UUID.self, forKey: .id)
|
||||
self.data = try container.decode(OldDraftAttachmentData.self, forKey: .data)
|
||||
self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case data
|
||||
case attachmentDescription
|
||||
}
|
||||
}
|
||||
|
||||
enum OldDraftAttachmentData: Decodable {
|
||||
case asset(String)
|
||||
case image(Data, originalType: UTType)
|
||||
case video(URL)
|
||||
case drawing(PKDrawing)
|
||||
case gif(Data)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch try container.decode(String.self, forKey: .type) {
|
||||
case "asset":
|
||||
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
|
||||
self = .asset(identifier)
|
||||
case "image":
|
||||
let data = try container.decode(Data.self, forKey: .imageData)
|
||||
if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) {
|
||||
self = .image(data, originalType: type)
|
||||
} else {
|
||||
guard let image = UIImage(data: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage")
|
||||
}
|
||||
let jpegData = image.jpegData(compressionQuality: 1)!
|
||||
self = .image(jpegData, originalType: .jpeg)
|
||||
}
|
||||
case "drawing":
|
||||
let drawingData = try container.decode(Data.self, forKey: .drawing)
|
||||
let drawing = try PKDrawing(data: drawingData)
|
||||
self = .drawing(drawing)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: CodingKey {
|
||||
case type
|
||||
case imageData
|
||||
case imageType
|
||||
/// The local identifier of the PHAsset for this attachment
|
||||
case assetIdentifier
|
||||
/// The PKDrawing object for this attachment.
|
||||
case drawing
|
||||
}
|
||||
}
|
||||
|
||||
struct OldPoll: Decodable {
|
||||
let options: [OldPollOption]
|
||||
let multiple: Bool
|
||||
let duration: TimeInterval
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.options = try container.decode([OldPollOption].self, forKey: .options)
|
||||
self.multiple = try container.decode(Bool.self, forKey: .multiple)
|
||||
self.duration = try container.decode(TimeInterval.self, forKey: .duration)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case options
|
||||
case multiple
|
||||
case duration
|
||||
}
|
||||
}
|
||||
|
||||
struct OldPollOption: Decodable {
|
||||
let text: String
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
self.text = try decoder.singleValueContainer().decode(String.self)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// KeyboardReader.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/7/23.
|
||||
//
|
||||
|
||||
#if !os(visionOS)
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
class KeyboardReader: ObservableObject {
|
||||
// @Published var isVisible = false
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
|
||||
var isVisible: Bool {
|
||||
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
|
||||
keyboardHeight > 72
|
||||
}
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc func willShow(_ notification: Foundation.Notification) {
|
||||
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
||||
// isVisible = endFrame.height > 72
|
||||
keyboardHeight = endFrame.height
|
||||
}
|
||||
|
||||
@objc func willHide() {
|
||||
// sometimes willHide is called during a SwiftUI view update
|
||||
DispatchQueue.main.async {
|
||||
// self.isVisible = false
|
||||
self.keyboardHeight = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// DismissMode.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/7/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum DismissMode {
|
||||
case cancel, post
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
//
|
||||
// StatusFormat.swift
|
||||
// Tusker
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 1/12/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
|
@ -12,8 +12,8 @@ import Pachyderm
|
|||
enum StatusFormat: Int, CaseIterable {
|
||||
case bold, italics, strikethrough, code
|
||||
|
||||
var insertionResult: FormatInsertionResult? {
|
||||
switch Preferences.shared.statusContentType {
|
||||
func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
|
||||
switch contentType {
|
||||
case .plain:
|
||||
return nil
|
||||
case .markdown:
|
||||
|
@ -23,7 +23,7 @@ enum StatusFormat: Int, CaseIterable {
|
|||
}
|
||||
}
|
||||
|
||||
var imageName: String? {
|
||||
var imageName: String {
|
||||
switch self {
|
||||
case .italics:
|
||||
return "italic"
|
||||
|
@ -31,16 +31,8 @@ enum StatusFormat: Int, CaseIterable {
|
|||
return "bold"
|
||||
case .strikethrough:
|
||||
return "strikethrough"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var title: (String, [NSAttributedString.Key: Any])? {
|
||||
if self == .code {
|
||||
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
|
||||
} else {
|
||||
return nil
|
||||
case .code:
|
||||
return "chevron.left.forwardslash.chevron.right"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,7 +52,7 @@ enum StatusFormat: Int, CaseIterable {
|
|||
|
||||
typealias FormatInsertionResult = (prefix: String, suffix: String, insertionPoint: Int)
|
||||
|
||||
protocol FormatType {
|
||||
fileprivate protocol FormatType {
|
||||
static func format(_ format: StatusFormat) -> FormatInsertionResult
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// OptionalObservedObject.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/15/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@propertyWrapper
|
||||
struct OptionalObservedObject<T: ObservableObject>: DynamicProperty {
|
||||
private class Republisher: ObservableObject {
|
||||
var cancellable: AnyCancellable?
|
||||
var wrapped: T? {
|
||||
didSet {
|
||||
cancellable?.cancel()
|
||||
cancellable = wrapped?.objectWillChange
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [unowned self] _ in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@StateObject private var republisher = Republisher()
|
||||
var wrappedValue: T?
|
||||
|
||||
func update() {
|
||||
republisher.wrapped = wrappedValue
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
//
|
||||
// PKDrawing+Render.swift
|
||||
// Tusker
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 5/9/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
|
@ -11,7 +11,7 @@ import PencilKit
|
|||
|
||||
extension PKDrawing {
|
||||
|
||||
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
|
||||
func imageInLightMode(from rect: CGRect, scale: CGFloat = 1) -> UIImage {
|
||||
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
|
||||
var drawingImage: UIImage!
|
||||
lightTraitCollection.performAsCurrent {
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// ComposeTextViewCaretScrolling.swift
|
||||
// TextViewCaretScrolling.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 11/11/20.
|
||||
|
@ -8,11 +8,11 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
protocol ComposeTextViewCaretScrolling: AnyObject {
|
||||
protocol TextViewCaretScrolling: AnyObject {
|
||||
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
|
||||
}
|
||||
|
||||
extension ComposeTextViewCaretScrolling {
|
||||
extension TextViewCaretScrolling {
|
||||
func ensureCursorVisible(textView: UITextView) {
|
||||
guard textView.isFirstResponder,
|
||||
let range = textView.selectedTextRange,
|
||||
|
@ -39,6 +39,7 @@ extension ComposeTextViewCaretScrolling {
|
|||
|
||||
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
||||
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
||||
scrollView.layoutIfNeeded()
|
||||
}
|
||||
self.caretScrollPositionAnimator = animator
|
||||
animator.startAnimation()
|
|
@ -0,0 +1,183 @@
|
|||
//
|
||||
// UITextInput+Autocomplete.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/5/23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
extension UITextInput {
|
||||
func autocomplete(with string: String, permittedModes: AutocompleteModes, autocompleteState: inout AutocompleteState?) {
|
||||
guard let selectedTextRange,
|
||||
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
|
||||
let text = self.text(in: wholeDocumentRange),
|
||||
let (lastWordStartIndex, _) = findAutocompleteLastWord() else {
|
||||
return
|
||||
}
|
||||
|
||||
let distanceToEnd = self.offset(from: selectedTextRange.start, to: self.endOfDocument)
|
||||
|
||||
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
|
||||
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
|
||||
|
||||
let insertSpace: Bool
|
||||
if distanceToEnd > 0 {
|
||||
let charAfterCursor = text[characterBeforeCursorIndex]
|
||||
insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
|
||||
} else {
|
||||
insertSpace = true
|
||||
}
|
||||
let string = insertSpace ? string + " " : string
|
||||
|
||||
let startPosition = self.position(from: self.beginningOfDocument, offset: text.utf16.distance(from: text.startIndex, to: lastWordStartIndex))!
|
||||
let lastWordRange = self.textRange(from: startPosition, to: selectedTextRange.start)!
|
||||
replace(lastWordRange, withText: string)
|
||||
|
||||
autocompleteState = updateAutocompleteState(permittedModes: permittedModes)
|
||||
|
||||
// keep the cursor at the same position in the text, immediately after what was inserted
|
||||
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
|
||||
let insertSpaceOffset = insertSpace ? 0 : 1
|
||||
let newCursorPosition = self.position(from: self.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)!
|
||||
self.selectedTextRange = self.textRange(from: newCursorPosition, to: newCursorPosition)
|
||||
}
|
||||
|
||||
func updateAutocompleteState(permittedModes: AutocompleteModes) -> AutocompleteState? {
|
||||
guard let selectedTextRange,
|
||||
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
|
||||
let text = self.text(in: wholeDocumentRange),
|
||||
!text.isEmpty,
|
||||
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let triggerChars = permittedModes.triggerChars
|
||||
|
||||
if lastWordStartIndex > text.startIndex {
|
||||
// if the character before the "word" beginning is a valid part of a "word",
|
||||
// we aren't able to autocomplete
|
||||
let c = text[text.index(before: lastWordStartIndex)]
|
||||
if isPermittedForAutocomplete(c) || triggerChars.contains(c) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: self.offset(from: self.beginningOfDocument, to: selectedTextRange.start))
|
||||
|
||||
if lastWordStartIndex >= text.startIndex {
|
||||
let lastWord = text[lastWordStartIndex..<characterBeforeCursorIndex]
|
||||
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
|
||||
|
||||
// periods are only allowed in mentions in the domain part
|
||||
if lastWord.contains(".") {
|
||||
if lastWord.first == "@" && foundFirstAtSign && permittedModes.contains(.mentions) {
|
||||
return .mention(String(exceptFirst))
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
switch lastWord.first {
|
||||
case "@" where permittedModes.contains(.mentions):
|
||||
return .mention(String(exceptFirst))
|
||||
case ":" where permittedModes.contains(.emojis):
|
||||
return .emoji(String(exceptFirst))
|
||||
case "#" where permittedModes.contains(.hashtags):
|
||||
return .hashtag(String(exceptFirst))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? {
|
||||
guard (self as? UIView)?.isFirstResponder == true,
|
||||
let selectedTextRange,
|
||||
selectedTextRange.isEmpty,
|
||||
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
|
||||
let text = self.text(in: wholeDocumentRange),
|
||||
!text.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
|
||||
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
|
||||
|
||||
guard cursorIndex != text.startIndex else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastWordStartIndex = text.index(before: cursorIndex)
|
||||
var foundFirstAtSign = false
|
||||
while true {
|
||||
let c = text[lastWordStartIndex]
|
||||
|
||||
if !isPermittedForAutocomplete(c) {
|
||||
if foundFirstAtSign {
|
||||
if c != "@" {
|
||||
// move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it
|
||||
lastWordStartIndex = text.index(after: lastWordStartIndex)
|
||||
}
|
||||
break
|
||||
} else {
|
||||
if c == "@" {
|
||||
foundFirstAtSign = true
|
||||
} else if c != "." {
|
||||
// periods are allowed for domain names in mentions
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard lastWordStartIndex > text.startIndex else {
|
||||
break
|
||||
}
|
||||
|
||||
lastWordStartIndex = text.index(before: lastWordStartIndex)
|
||||
}
|
||||
|
||||
return (lastWordStartIndex, foundFirstAtSign)
|
||||
}
|
||||
}
|
||||
|
||||
enum AutocompleteState: Equatable {
|
||||
case mention(String)
|
||||
case emoji(String)
|
||||
case hashtag(String)
|
||||
}
|
||||
|
||||
struct AutocompleteModes: OptionSet {
|
||||
static let mentions = AutocompleteModes(rawValue: 1 << 0)
|
||||
static let hashtags = AutocompleteModes(rawValue: 1 << 2)
|
||||
static let emojis = AutocompleteModes(rawValue: 1 << 3)
|
||||
|
||||
static let all: AutocompleteModes = [
|
||||
.mentions,
|
||||
.hashtags,
|
||||
.emojis,
|
||||
]
|
||||
|
||||
let rawValue: Int
|
||||
|
||||
var triggerChars: [Character] {
|
||||
var chars: [Character] = []
|
||||
if contains(.mentions) {
|
||||
chars.append("@")
|
||||
}
|
||||
if contains(.hashtags) {
|
||||
chars.append("#")
|
||||
}
|
||||
if contains(.emojis) {
|
||||
chars.append(":")
|
||||
}
|
||||
return chars
|
||||
}
|
||||
}
|
||||
|
||||
private func isPermittedForAutocomplete(_ c: Character) -> Bool {
|
||||
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// ViewController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public protocol ViewController: ObservableObject {
|
||||
associatedtype ContentView: View
|
||||
|
||||
@ViewBuilder
|
||||
var view: ContentView { get }
|
||||
}
|
||||
|
||||
public struct ControllerView<Controller: ViewController>: View {
|
||||
@StateObject private var controller: Controller
|
||||
|
||||
public init(controller: @escaping () -> Controller) {
|
||||
self._controller = StateObject(wrappedValue: controller())
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
controller.view
|
||||
.environmentObject(controller)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
//
|
||||
// AttachmentDescriptionTextView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/12/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private var placeholder: some View {
|
||||
Text("Describe for the visually impaired…")
|
||||
}
|
||||
|
||||
struct InlineAttachmentDescriptionView: View {
|
||||
@ObservedObject private var attachment: DraftAttachment
|
||||
private let minHeight: CGFloat
|
||||
|
||||
@State private var height: CGFloat?
|
||||
|
||||
init(attachment: DraftAttachment, minHeight: CGFloat) {
|
||||
self.attachment = attachment
|
||||
self.minHeight = minHeight
|
||||
}
|
||||
|
||||
private var placeholderOffset: CGSize {
|
||||
#if os(visionOS)
|
||||
CGSize(width: 8, height: 8)
|
||||
#else
|
||||
CGSize(width: 4, height: 8)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if attachment.attachmentDescription.isEmpty {
|
||||
placeholder
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.offset(placeholderOffset)
|
||||
}
|
||||
|
||||
WrappedTextView(
|
||||
text: $attachment.attachmentDescription,
|
||||
backgroundColor: .clear,
|
||||
textDidChange: self.textDidChange
|
||||
)
|
||||
.frame(height: height ?? minHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private func textDidChange(_ textView: UITextView) {
|
||||
height = max(minHeight, textView.contentSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
struct FocusedAttachmentDescriptionView: View {
|
||||
@ObservedObject var attachment: DraftAttachment
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
WrappedTextView(
|
||||
text: $attachment.attachmentDescription,
|
||||
backgroundColor: .secondarySystemBackground,
|
||||
textDidChange: nil
|
||||
)
|
||||
.edgesIgnoringSafeArea([.bottom, .leading, .trailing])
|
||||
|
||||
if attachment.attachmentDescription.isEmpty {
|
||||
placeholder
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.offset(x: 4, y: 8)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WrappedTextView: UIViewRepresentable {
|
||||
typealias UIViewType = UITextView
|
||||
|
||||
@Binding var text: String
|
||||
let backgroundColor: UIColor
|
||||
let textDidChange: (((UITextView) -> Void))?
|
||||
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let view = UITextView()
|
||||
view.delegate = context.coordinator
|
||||
view.backgroundColor = backgroundColor
|
||||
view.font = .preferredFont(forTextStyle: .body)
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
view.textContainer.lineBreakMode = .byWordWrapping
|
||||
#if os(visionOS)
|
||||
view.borderStyle = .roundedRect
|
||||
view.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
|
||||
#endif
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||
uiView.text = text
|
||||
uiView.isEditable = isEnabled
|
||||
context.coordinator.textView = uiView
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.didChange = textDidChange
|
||||
if let textDidChange {
|
||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||
// the text view knows its new content size
|
||||
DispatchQueue.main.async {
|
||||
textDidChange(uiView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(text: $text, didChange: textDidChange)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
|
||||
weak var textView: UITextView?
|
||||
var text: Binding<String>
|
||||
var didChange: ((UITextView) -> Void)?
|
||||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||
|
||||
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
|
||||
self.text = text
|
||||
self.didChange = didChange
|
||||
|
||||
super.init()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func keyboardDidShow() {
|
||||
guard let textView,
|
||||
textView.isFirstResponder else {
|
||||
return
|
||||
}
|
||||
ensureCursorVisible(textView: textView)
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
text.wrappedValue = textView.text
|
||||
didChange?(textView)
|
||||
|
||||
ensureCursorVisible(textView: textView)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// CurrentAccountView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
struct CurrentAccountView: View {
|
||||
let account: (any AccountProtocol)?
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
|
||||
var body: some View {
|
||||
controller.currentAccountContainerView(AnyView(currentAccount))
|
||||
}
|
||||
|
||||
private var currentAccount: some View {
|
||||
HStack(alignment: .top) {
|
||||
AvatarImageView(
|
||||
url: account?.avatar,
|
||||
size: 50,
|
||||
style: controller.config.avatarStyle,
|
||||
fetchAvatar: controller.fetchAvatar
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
if let account {
|
||||
VStack(alignment: .leading) {
|
||||
controller.displayNameLabel(account, .title2, 24)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(verbatim: "@\(account.acct)")
|
||||
.font(.body.weight(.light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
//
|
||||
// EmojiTextField.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/5/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EmojiTextField: UIViewRepresentable {
|
||||
typealias UIViewType = UITextField
|
||||
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@Binding var text: String
|
||||
let placeholder: String
|
||||
let maxLength: Int?
|
||||
let becomeFirstResponder: Binding<Bool>?
|
||||
let focusNextView: Binding<Bool>?
|
||||
|
||||
init(text: Binding<String>, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
|
||||
self._text = text
|
||||
self.placeholder = placeholder
|
||||
self.maxLength = maxLength
|
||||
self.becomeFirstResponder = becomeFirstResponder
|
||||
self.focusNextView = focusNextView
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UITextField {
|
||||
let view = UITextField()
|
||||
view.borderStyle = .roundedRect
|
||||
view.font = .preferredFont(forTextStyle: .body)
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
view.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
|
||||
.foregroundColor: UIColor.secondaryLabel,
|
||||
])
|
||||
|
||||
context.coordinator.textField = view
|
||||
|
||||
view.delegate = context.coordinator
|
||||
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
|
||||
view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered)
|
||||
|
||||
// otherwise when the text gets too wide it starts expanding the ComposeView
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextField, context: Context) {
|
||||
if text != uiView.text {
|
||||
uiView.text = text
|
||||
}
|
||||
if placeholder != uiView.attributedPlaceholder?.string {
|
||||
uiView.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
|
||||
.foregroundColor: UIColor.secondaryLabel,
|
||||
])
|
||||
}
|
||||
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.maxLength = maxLength
|
||||
context.coordinator.focusNextView = focusNextView
|
||||
|
||||
#if !os(visionOS)
|
||||
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
|
||||
#endif
|
||||
|
||||
if becomeFirstResponder?.wrappedValue == true {
|
||||
DispatchQueue.main.async {
|
||||
uiView.becomeFirstResponder()
|
||||
becomeFirstResponder!.wrappedValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
||||
let controller: ComposeController
|
||||
var text: Binding<String>
|
||||
var focusNextView: Binding<Bool>?
|
||||
var maxLength: Int?
|
||||
|
||||
@Published var autocompleteState: AutocompleteState?
|
||||
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
|
||||
|
||||
weak var textField: UITextField?
|
||||
|
||||
init(controller: ComposeController, text: Binding<String>, focusNextView: Binding<Bool>?, maxLength: Int? = nil) {
|
||||
self.controller = controller
|
||||
self.text = text
|
||||
self.focusNextView = focusNextView
|
||||
self.maxLength = maxLength
|
||||
}
|
||||
|
||||
@objc func didChange(_ textField: UITextField) {
|
||||
text.wrappedValue = textField.text ?? ""
|
||||
}
|
||||
|
||||
@objc func returnKeyPressed() {
|
||||
focusNextView?.wrappedValue = true
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
if let maxLength {
|
||||
return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
controller.currentInput = self
|
||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||
}
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
controller.currentInput = nil
|
||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||
}
|
||||
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||
}
|
||||
|
||||
// MARK: ComposeInput
|
||||
|
||||
var toolbarElements: [ToolbarElement] { [.emojiPicker] }
|
||||
|
||||
var textInputMode: UITextInputMode? {
|
||||
textField?.textInputMode
|
||||
}
|
||||
|
||||
func applyFormat(_ format: StatusFormat) {
|
||||
}
|
||||
|
||||
func beginAutocompletingEmoji() {
|
||||
textField?.insertText(":")
|
||||
}
|
||||
|
||||
func autocomplete(with string: String) {
|
||||
textField?.autocomplete(with: string, permittedModes: .emojis, autocompleteState: &autocompleteState)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
//
|
||||
// HeaderView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import InstanceFeatures
|
||||
|
||||
struct HeaderView: View {
|
||||
let currentAccount: (any AccountProtocol)?
|
||||
let charsRemaining: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
CurrentAccountView(account: currentAccount)
|
||||
.accessibilitySortPriority(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(verbatim: charsRemaining.description)
|
||||
.foregroundColor(charsRemaining < 0 ? .red : .secondary)
|
||||
.font(Font.body.monospacedDigit())
|
||||
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
|
||||
// this should come first, so VO users can back to it from the main compose text view
|
||||
.accessibilitySortPriority(0)
|
||||
}.frame(height: 50)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,221 @@
|
|||
//
|
||||
// LanguagePicker.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 5/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct LanguagePicker: View {
|
||||
@Binding var draftLanguage: String?
|
||||
@Binding var hasChangedSelection: Bool
|
||||
@State private var isShowingSheet = false
|
||||
|
||||
private var codeFromDraft: Locale.LanguageCode? {
|
||||
draftLanguage.map(Locale.LanguageCode.init(_:))
|
||||
}
|
||||
|
||||
private var codeFromActiveInputMode: Locale.LanguageCode? {
|
||||
UITextInputMode.activeInputModes.first.flatMap(Self.codeFromInputMode(_:))
|
||||
}
|
||||
|
||||
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
|
||||
guard let bcp47Lang = mode.primaryLanguage,
|
||||
!bcp47Lang.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: min(3, bcp47Lang.count))]
|
||||
if maybeIso639Code.last == "-" {
|
||||
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
|
||||
}
|
||||
let identifier = String(maybeIso639Code)
|
||||
// mul (for multiple languages) and unk (unknown) are ISO codes, but not ones that akkoma permits, so we ignore them on all platforms
|
||||
guard identifier != "mul",
|
||||
identifier != "und" else {
|
||||
return nil
|
||||
}
|
||||
let code = Locale.LanguageCode(identifier)
|
||||
if code.isISOLanguage {
|
||||
return code
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var codeFromPreferredLanguages: Locale.LanguageCode? {
|
||||
if let identifier = Locale.preferredLanguages.first,
|
||||
case let code = Locale.LanguageCode(identifier),
|
||||
code.isISOLanguage {
|
||||
return code
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var languageCode: Binding<Locale.LanguageCode> {
|
||||
Binding {
|
||||
return codeFromDraft ?? codeFromActiveInputMode ?? codeFromPreferredLanguages ?? .english
|
||||
} set: { newValue in
|
||||
draftLanguage = newValue.identifier
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
isShowingSheet = true
|
||||
} label: {
|
||||
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
|
||||
}
|
||||
.accessibilityLabel("Post Language")
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
.sheet(isPresented: $isShowingSheet) {
|
||||
NavigationStack {
|
||||
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
|
||||
}
|
||||
.presentationDetents([.large, .medium])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
private struct LanguagePickerList: View {
|
||||
@Binding var languageCode: Locale.LanguageCode
|
||||
@Binding var hasChangedSelection: Bool
|
||||
@Binding var isPresented: Bool
|
||||
@Environment(\.composeUIConfig.groupedBackgroundColor) private var groupedBackgroundColor
|
||||
@Environment(\.composeUIConfig.groupedCellBackgroundColor) private var groupedCellBackgroundColor
|
||||
@State private var recentLangs: [Lang] = []
|
||||
@State private var langs: [Lang] = []
|
||||
@State private var filteredLangs: [Lang]?
|
||||
@State private var query = ""
|
||||
|
||||
private var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: "group.space.vaccor.Tusker") ?? .standard
|
||||
}
|
||||
|
||||
private var recentIdentifiers: [String] {
|
||||
get {
|
||||
defaults.object(forKey: "LanguagePickerRecents") as? [String] ?? []
|
||||
}
|
||||
nonmutating set {
|
||||
defaults.set(newValue, forKey: "LanguagePickerRecents")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(recentLangs) { lang in
|
||||
button(for: lang)
|
||||
}
|
||||
.listRowBackground(groupedCellBackgroundColor)
|
||||
} header: {
|
||||
Text("Recently Used")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(filteredLangs ?? langs) { lang in
|
||||
button(for: lang)
|
||||
}
|
||||
.listRowBackground(groupedCellBackgroundColor)
|
||||
} header: {
|
||||
Text("All Languages")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
|
||||
.searchable(text: $query)
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.navigationTitle("Post Language")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// make sure recents always contains the currently selected lang
|
||||
let recents = addRecentLang(languageCode)
|
||||
recentLangs = recents
|
||||
.filter { $0 != "mul" && $0 != "und" }
|
||||
.map { Lang(code: .init($0)) }
|
||||
.sorted { $0.name < $1.name }
|
||||
|
||||
langs = Locale.LanguageCode.isoLanguageCodes
|
||||
.filter { $0.identifier != "mul" && $0.identifier != "und" }
|
||||
.map { Lang(code: $0) }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
#if os(visionOS)
|
||||
.onChange(of: query, initial: true) {
|
||||
filteredLangsChanged(query: query)
|
||||
}
|
||||
#else
|
||||
.onChange(of: query) { newValue in
|
||||
filteredLangsChanged(query: newValue)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func filteredLangsChanged(query: String) {
|
||||
if query.isEmpty {
|
||||
filteredLangs = nil
|
||||
} else {
|
||||
filteredLangs = langs.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(query) || $0.code.identifier.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func addRecentLang(_ code: Locale.LanguageCode) -> [String] {
|
||||
var recents = recentIdentifiers
|
||||
if !recents.contains(languageCode.identifier) {
|
||||
recents.insert(languageCode.identifier, at: 0)
|
||||
if recents.count > 5 {
|
||||
recents = Array(recents[..<5])
|
||||
}
|
||||
recentIdentifiers = recents
|
||||
}
|
||||
return recents
|
||||
}
|
||||
|
||||
private func button(for lang: Lang) -> some View {
|
||||
Button {
|
||||
languageCode = lang.code
|
||||
hasChangedSelection = true
|
||||
isPresented = false
|
||||
addRecentLang(lang.code)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(lang.name)
|
||||
Spacer()
|
||||
if lang.code == languageCode {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Lang: Identifiable {
|
||||
let code: Locale.LanguageCode
|
||||
let name: String
|
||||
|
||||
var id: String {
|
||||
code.identifier
|
||||
}
|
||||
|
||||
init(code: Locale.LanguageCode) {
|
||||
self.code = code
|
||||
self.name = Locale.current.localizedString(forLanguageCode: code.identifier) ?? code.identifier
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,336 @@
|
|||
//
|
||||
// MainTextView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/6/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MainTextView: View {
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
@EnvironmentObject private var draft: Draft
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ScaledMetric private var fontSize = 20
|
||||
|
||||
@State private var hasFirstAppeared = false
|
||||
@State private var height: CGFloat?
|
||||
@State private var updateSelection: ((UITextView) -> Void)?
|
||||
private let minHeight: CGFloat = 150
|
||||
private var effectiveHeight: CGFloat { height ?? minHeight }
|
||||
|
||||
var config: ComposeUIConfig {
|
||||
controller.config
|
||||
}
|
||||
|
||||
private var placeholderOffset: CGSize {
|
||||
#if os(visionOS)
|
||||
CGSize(width: 8, height: 8)
|
||||
#else
|
||||
CGSize(width: 4, height: 8)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var textViewBackgroundColor: UIColor? {
|
||||
#if os(visionOS)
|
||||
nil
|
||||
#else
|
||||
colorScheme == .dark ? UIColor(config.fillColor) : .secondarySystemBackground
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
MainWrappedTextViewRepresentable(
|
||||
text: $draft.text,
|
||||
backgroundColor: textViewBackgroundColor,
|
||||
becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder,
|
||||
updateSelection: $updateSelection,
|
||||
textDidChange: textDidChange
|
||||
)
|
||||
|
||||
if draft.text.isEmpty {
|
||||
ControllerView(controller: { PlaceholderController() })
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.secondary)
|
||||
.offset(placeholderOffset)
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
}
|
||||
.frame(height: effectiveHeight)
|
||||
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
|
||||
}
|
||||
|
||||
private func becomeFirstResponderOnFirstAppearance() {
|
||||
if !hasFirstAppeared {
|
||||
hasFirstAppeared = true
|
||||
controller.mainComposeTextViewBecomeFirstResponder = true
|
||||
if config.textSelectionStartsAtBeginning {
|
||||
updateSelection = { textView in
|
||||
textView.selectedTextRange = textView.textRange(from: textView.beginningOfDocument, to: textView.beginningOfDocument)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func textDidChange(textView: UITextView) {
|
||||
height = max(textView.contentSize.height, minHeight)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
||||
typealias UIViewType = UITextView
|
||||
|
||||
@Binding var text: String
|
||||
let backgroundColor: UIColor?
|
||||
@Binding var becomeFirstResponder: Bool
|
||||
@Binding var updateSelection: ((UITextView) -> Void)?
|
||||
let textDidChange: (UITextView) -> Void
|
||||
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
@Environment(\.isEnabled) private var isEnabled: Bool
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = WrappedTextView(composeController: controller)
|
||||
context.coordinator.textView = textView
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = true
|
||||
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||
|
||||
#if os(visionOS)
|
||||
textView.borderStyle = .roundedRect
|
||||
// yes, the X inset is 4 less than the placeholder offset
|
||||
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
|
||||
#endif
|
||||
|
||||
return textView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||
if text != uiView.text {
|
||||
context.coordinator.skipNextSelectionChangedAutocompleteUpdate = true
|
||||
uiView.text = text
|
||||
}
|
||||
|
||||
uiView.isEditable = isEnabled
|
||||
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
|
||||
|
||||
uiView.backgroundColor = backgroundColor
|
||||
|
||||
context.coordinator.text = $text
|
||||
|
||||
if let updateSelection {
|
||||
updateSelection(uiView)
|
||||
self.updateSelection = nil
|
||||
}
|
||||
|
||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||
// the text view knows its new content size
|
||||
DispatchQueue.main.async {
|
||||
textDidChange(uiView)
|
||||
|
||||
if becomeFirstResponder {
|
||||
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
|
||||
uiView.becomeFirstResponder()
|
||||
// can't update @State vars during the SwiftUI update
|
||||
becomeFirstResponder = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(controller: controller, text: $text, textDidChange: textDidChange)
|
||||
}
|
||||
|
||||
class WrappedTextView: UITextView {
|
||||
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
|
||||
private let composeController: ComposeController
|
||||
|
||||
init(composeController: ComposeController) {
|
||||
self.composeController = composeController
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
if formattingActions.contains(action) {
|
||||
return composeController.config.contentType != .plain
|
||||
}
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
}
|
||||
|
||||
override func toggleBoldface(_ sender: Any?) {
|
||||
(delegate as! Coordinator).applyFormat(.bold)
|
||||
}
|
||||
|
||||
override func toggleItalics(_ sender: Any?) {
|
||||
(delegate as! Coordinator).applyFormat(.italics)
|
||||
}
|
||||
|
||||
override func validate(_ command: UICommand) {
|
||||
super.validate(command)
|
||||
|
||||
if formattingActions.contains(command.action),
|
||||
composeController.config.contentType != .plain {
|
||||
command.attributes.remove(.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
override func paste(_ sender: Any?) {
|
||||
// we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion
|
||||
// and things like URLs end up pasting as attachments
|
||||
if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) {
|
||||
composeController.paste(itemProviders: UIPasteboard.general.itemProviders)
|
||||
} else {
|
||||
super.paste(sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, TextViewCaretScrolling {
|
||||
weak var textView: UITextView?
|
||||
|
||||
let controller: ComposeController
|
||||
var text: Binding<String>
|
||||
let textDidChange: (UITextView) -> Void
|
||||
|
||||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||
|
||||
@Published var autocompleteState: AutocompleteState?
|
||||
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
|
||||
var skipNextSelectionChangedAutocompleteUpdate = false
|
||||
|
||||
init(controller: ComposeController, text: Binding<String>, textDidChange: @escaping (UITextView) -> Void) {
|
||||
self.controller = controller
|
||||
self.text = text
|
||||
self.textDidChange = textDidChange
|
||||
|
||||
super.init()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func keyboardDidShow() {
|
||||
guard let textView,
|
||||
textView.isFirstResponder else {
|
||||
return
|
||||
}
|
||||
ensureCursorVisible(textView: textView)
|
||||
}
|
||||
|
||||
// MARK: UITextViewDelegate
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
text.wrappedValue = textView.text
|
||||
textDidChange(textView)
|
||||
|
||||
ensureCursorVisible(textView: textView)
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
controller.currentInput = self
|
||||
updateAutocompleteState()
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
controller.currentInput = nil
|
||||
updateAutocompleteState()
|
||||
}
|
||||
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
if skipNextSelectionChangedAutocompleteUpdate {
|
||||
skipNextSelectionChangedAutocompleteUpdate = false
|
||||
} else {
|
||||
updateAutocompleteState()
|
||||
}
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
var actions = suggestedActions
|
||||
if controller.config.contentType != .plain,
|
||||
let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) {
|
||||
if range.length > 0 {
|
||||
let formatMenu = suggestedActions[index] as! UIMenu
|
||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [weak self] _ in
|
||||
self?.applyFormat(fmt)
|
||||
}
|
||||
})
|
||||
actions[index] = newFormatMenu
|
||||
} else {
|
||||
actions.remove(at: index)
|
||||
}
|
||||
}
|
||||
if range.length == 0 {
|
||||
actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
|
||||
self?.controller.shouldEmojiAutocompletionBeginExpanded = true
|
||||
self?.beginAutocompletingEmoji()
|
||||
}))
|
||||
}
|
||||
return UIMenu(children: actions)
|
||||
}
|
||||
|
||||
// MARK: ComposeInput
|
||||
|
||||
var toolbarElements: [ToolbarElement] {
|
||||
[.emojiPicker, .formattingButtons]
|
||||
}
|
||||
|
||||
var textInputMode: UITextInputMode? {
|
||||
textView?.textInputMode
|
||||
}
|
||||
|
||||
func autocomplete(with string: String) {
|
||||
textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
|
||||
}
|
||||
|
||||
func applyFormat(_ format: StatusFormat) {
|
||||
guard let textView,
|
||||
textView.isFirstResponder,
|
||||
let insertionResult = format.insertionResult(for: controller.config.contentType) else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentSelectedRange = textView.selectedRange
|
||||
if currentSelectedRange.length == 0 {
|
||||
textView.insertText(insertionResult.prefix + insertionResult.suffix)
|
||||
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
|
||||
} else {
|
||||
let start = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.lowerBound)
|
||||
let end = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.upperBound)
|
||||
let selectedText = textView.text.utf16[start..<end]
|
||||
textView.insertText(insertionResult.prefix + String(Substring(selectedText)) + insertionResult.suffix)
|
||||
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: currentSelectedRange.length)
|
||||
}
|
||||
}
|
||||
|
||||
func beginAutocompletingEmoji() {
|
||||
guard let textView else {
|
||||
return
|
||||
}
|
||||
var insertSpace = false
|
||||
if let text = textView.text,
|
||||
textView.selectedRange.upperBound > 0 {
|
||||
let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound))
|
||||
insertSpace = !text[characterBeforeCursorIndex].isWhitespace
|
||||
}
|
||||
textView.insertText((insertSpace ? " " : "") + ":")
|
||||
}
|
||||
|
||||
private func updateAutocompleteState() {
|
||||
guard let textView else {
|
||||
autocompleteState = nil
|
||||
return
|
||||
}
|
||||
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
//
|
||||
// PollOptionView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/25/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PollOptionView: View {
|
||||
@EnvironmentObject private var controller: PollController
|
||||
@EnvironmentObject private var poll: Poll
|
||||
@ObservedObject private var option: PollOption
|
||||
let remove: () -> Void
|
||||
|
||||
init(option: PollOption, remove: @escaping () -> Void) {
|
||||
self.option = option
|
||||
self.remove = remove
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor)
|
||||
.animation(.default, value: poll.multiple)
|
||||
|
||||
textField
|
||||
|
||||
Button(action: remove) {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
}
|
||||
.accessibilityLabel("Remove option")
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(poll.options.count == 1 ? .gray : .red)
|
||||
.disabled(poll.options.count == 1)
|
||||
.hoverEffect()
|
||||
}
|
||||
}
|
||||
|
||||
private var textField: some View {
|
||||
let index = poll.options.index(of: option)
|
||||
let placeholder = index != NSNotFound ? "Option \(index + 1)" : ""
|
||||
let maxLength = controller.parent.mastodonController.instanceFeatures.maxPollOptionChars
|
||||
return EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: maxLength)
|
||||
}
|
||||
|
||||
struct Checkbox: View {
|
||||
private let radiusFraction: CGFloat
|
||||
private let size: CGFloat = 20
|
||||
private let innerSize: CGFloat
|
||||
private let background: Color
|
||||
|
||||
init(radiusFraction: CGFloat, background: Color) {
|
||||
self.radiusFraction = radiusFraction
|
||||
self.innerSize = self.size - 4
|
||||
self.background = background
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.foregroundColor(.gray)
|
||||
.frame(width: size, height: size)
|
||||
.cornerRadius(radiusFraction * size)
|
||||
|
||||
Rectangle()
|
||||
.foregroundColor(background)
|
||||
.frame(width: innerSize, height: innerSize)
|
||||
.cornerRadius(radiusFraction * innerSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,41 +1,41 @@
|
|||
//
|
||||
// ComposeReplyView.swift
|
||||
// Tusker
|
||||
// ReplyStatusView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 8/22/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
// Created by Shadowfacts on 3/25/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
struct ComposeReplyView: View {
|
||||
let status: StatusMO
|
||||
struct ReplyStatusView: View {
|
||||
let status: any StatusProtocol
|
||||
let rowTopInset: CGFloat
|
||||
let globalFrameOutsideList: CGRect
|
||||
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
@State private var displayNameHeight: CGFloat?
|
||||
@State private var contentHeight: CGFloat?
|
||||
|
||||
@ObservedObject private var preferences = Preferences.shared
|
||||
|
||||
private let horizSpacing: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: horizSpacing) {
|
||||
GeometryReader(content: self.replyAvatarImage)
|
||||
.frame(width: 50)
|
||||
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
AccountDisplayNameLabel(account: status.account, textStyle: .body, emojiSize: 17)
|
||||
controller.displayNameLabel(status.account, .body, 17)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
|
||||
|
||||
Text(verbatim: "@\(status.account.acct)")
|
||||
.font(.system(size: 17, weight: .light))
|
||||
.font(.body.weight(.light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
|
@ -45,8 +45,8 @@ struct ComposeReplyView: View {
|
|||
displayNameHeight = newValue
|
||||
}
|
||||
})
|
||||
|
||||
ComposeReplyContentView(status: status) { newHeight in
|
||||
|
||||
controller.replyContentView(status) { newHeight in
|
||||
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
|
||||
// and it ends up partially behind the header
|
||||
DispatchQueue.main.async {
|
||||
|
@ -76,13 +76,18 @@ struct ComposeReplyView: View {
|
|||
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
|
||||
offset = min(offset, maxOffset)
|
||||
|
||||
return ComposeAvatarImageView(url: status.account.avatar)
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||
.offset(x: 0, y: offset)
|
||||
.accessibilityHidden(true)
|
||||
return AvatarContainerRepresentable(offset: offset) {
|
||||
AvatarImageView(
|
||||
url: status.account.avatar,
|
||||
size: 50,
|
||||
style: controller.config.avatarStyle,
|
||||
fetchAvatar: controller.fetchAvatar
|
||||
)
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private struct DisplayNameHeightPrefKey: PreferenceKey {
|
||||
|
@ -92,8 +97,38 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
|
|||
}
|
||||
}
|
||||
|
||||
//struct ComposeReplyView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeReplyView()
|
||||
// }
|
||||
//}
|
||||
// This whole dance is necessary so that the offset can be animatable from
|
||||
// UIKit animations, like TextViewCaretScrolling.
|
||||
private struct AvatarContainerRepresentable<Content: View>: UIViewControllerRepresentable {
|
||||
let offset: CGFloat
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller(host: UIHostingController(rootView: content))
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: Controller, context: Context) {
|
||||
uiViewController.host.rootView = content
|
||||
uiViewController.host.view.transform = CGAffineTransform(translationX: 0, y: offset)
|
||||
}
|
||||
|
||||
// This extra layer is necessary because applying a transform to the
|
||||
// representable's VC's view doesn't seem to have an effect.
|
||||
class Controller: UIViewController {
|
||||
let host: UIHostingController<Content>
|
||||
|
||||
init(host: UIHostingController<Content>) {
|
||||
self.host = host
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
addChild(host)
|
||||
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(host.view)
|
||||
host.view.frame = view.bounds
|
||||
host.didMove(toParent: self)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// WrappedProgressView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/30/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WrappedProgressView: UIViewRepresentable {
|
||||
typealias UIViewType = UIProgressView
|
||||
|
||||
let value: Int
|
||||
let total: Int
|
||||
|
||||
func makeUIView(context: Context) -> UIProgressView {
|
||||
return UIProgressView(progressViewStyle: .bar)
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIProgressView, context: Context) {
|
||||
if total > 0 {
|
||||
let progress = Float(value) / Float(total)
|
||||
uiView.setProgress(progress, animated: true)
|
||||
} else {
|
||||
uiView.setProgress(0, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
//
|
||||
// ZoomableScrollView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/29/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ZoomableScrollView<Content: View>: UIViewControllerRepresentable {
|
||||
let content: Content
|
||||
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
return Controller(content: content)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: Controller, context: Context) {
|
||||
uiViewController.host.rootView = content
|
||||
}
|
||||
|
||||
class Controller: UIViewController, UIScrollViewDelegate {
|
||||
let scrollView = UIScrollView()
|
||||
let host: UIHostingController<Content>
|
||||
|
||||
private var lastIntrinsicSize: CGSize?
|
||||
private var contentViewTopConstraint: NSLayoutConstraint!
|
||||
private var contentViewLeadingConstraint: NSLayoutConstraint!
|
||||
private var hostBoundsObservation: NSKeyValueObservation?
|
||||
|
||||
init(content: Content) {
|
||||
self.host = UIHostingController(rootView: content)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
scrollView.delegate = self
|
||||
scrollView.bouncesZoom = true
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(scrollView)
|
||||
|
||||
host.sizingOptions = .intrinsicContentSize
|
||||
host.view.backgroundColor = .clear
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
addChild(host)
|
||||
scrollView.addSubview(host.view)
|
||||
host.didMove(toParent: self)
|
||||
|
||||
contentViewLeadingConstraint = host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor)
|
||||
contentViewTopConstraint = host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
contentViewLeadingConstraint,
|
||||
contentViewTopConstraint,
|
||||
])
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
if !host.view.intrinsicContentSize.equalTo(.zero),
|
||||
host.view.intrinsicContentSize != lastIntrinsicSize {
|
||||
self.lastIntrinsicSize = host.view.intrinsicContentSize
|
||||
|
||||
let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom
|
||||
let maxWidth = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right
|
||||
let heightScale = maxHeight / host.view.intrinsicContentSize.height
|
||||
let widthScale = maxWidth / host.view.intrinsicContentSize.width
|
||||
let minScale = min(widthScale, heightScale)
|
||||
let maxScale = minScale >= 1 ? minScale + 2 : 2
|
||||
scrollView.minimumZoomScale = minScale
|
||||
scrollView.maximumZoomScale = maxScale
|
||||
scrollView.zoomScale = minScale
|
||||
}
|
||||
|
||||
centerImage()
|
||||
}
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
return host.view
|
||||
}
|
||||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
centerImage()
|
||||
}
|
||||
|
||||
func centerImage() {
|
||||
let yOffset = max(0, (view.bounds.size.height - host.view.bounds.height * scrollView.zoomScale) / 2)
|
||||
contentViewTopConstraint.constant = yOffset
|
||||
|
||||
let xOffset = max(0, (view.bounds.size.width - host.view.bounds.width * scrollView.zoomScale) / 2)
|
||||
contentViewLeadingConstraint.constant = xOffset
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +1,14 @@
|
|||
//
|
||||
// CharacterCounterTests.swift
|
||||
// PachydermTests
|
||||
// ComposeUITests
|
||||
//
|
||||
// Created by Shadowfacts on 9/29/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Pachyderm
|
||||
@testable import ComposeUI
|
||||
import InstanceFeatures
|
||||
|
||||
class CharacterCounterTests: XCTestCase {
|
||||
|
||||
|
@ -16,32 +17,34 @@ class CharacterCounterTests: XCTestCase {
|
|||
|
||||
override func tearDown() {
|
||||
}
|
||||
|
||||
let features = InstanceFeatures()
|
||||
|
||||
func testCountEmpty() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: ""), 0)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "", for: features), 0)
|
||||
}
|
||||
|
||||
func testCountPlainText() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message"), 26)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄"), 43)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄"), 7)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message", for: features), 26)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄", for: features), 43)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄", for: features), 7)
|
||||
}
|
||||
|
||||
func testCountLinks() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com"), 55)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com"), 57)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com"), 32)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz"), 55)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com", for: features), 55)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com", for: features), 57)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com", for: features), 32)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz", for: features), 55)
|
||||
}
|
||||
|
||||
func testCountLocalMentions() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example"), 14)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name"), 22)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example", for: features), 14)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name", for: features), 22)
|
||||
}
|
||||
|
||||
func testCountRemoteMentions() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social"), 14)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social"), 28)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social", for: features), 14)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social", for: features), 28)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// FuzzyMatcherTests.swift
|
||||
// ComposeUITests
|
||||
//
|
||||
// Created by Shadowfacts on 10/11/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import ComposeUI
|
||||
|
||||
class FuzzyMatcherTests: XCTestCase {
|
||||
|
||||
func testExample() throws {
|
||||
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6)
|
||||
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4)
|
||||
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6)
|
||||
|
||||
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2)
|
||||
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1)
|
||||
|
||||
XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version: 5.7
|
||||
// swift-tools-version: 6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "Duckable",
|
||||
platforms: [
|
||||
.iOS(.v15),
|
||||
.iOS(.v16),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -23,9 +23,12 @@ let package = Package(
|
|||
// 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"]),
|
||||
dependencies: [],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
// .testTarget(
|
||||
// name: "DuckableTests",
|
||||
// dependencies: ["Duckable"]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -7,8 +7,9 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public protocol DuckableViewController: UIViewController {
|
||||
var duckableDelegate: DuckableViewControllerDelegate? { get set }
|
||||
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
||||
|
||||
func duckableViewControllerMayAttemptToDuck()
|
||||
|
||||
|
@ -18,22 +19,25 @@ public protocol DuckableViewController: UIViewController {
|
|||
}
|
||||
|
||||
extension DuckableViewController {
|
||||
public func duckableViewControllerShouldDuck() -> DuckAttemptAction { .duck }
|
||||
public func duckableViewControllerMayAttemptToDuck() {}
|
||||
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
|
||||
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
||||
}
|
||||
|
||||
public protocol DuckableViewControllerDelegate: AnyObject {
|
||||
func duckableViewControllerWillDismiss(animated: Bool)
|
||||
public enum DuckAttemptAction {
|
||||
case duck
|
||||
case dismiss
|
||||
case block
|
||||
}
|
||||
|
||||
extension UIViewController {
|
||||
@available(iOS 16.0, *)
|
||||
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
||||
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false, completion: (() -> Void)? = nil) -> Bool {
|
||||
var cur: UIViewController? = self
|
||||
while let vc = cur {
|
||||
if let container = vc as? DuckableContainerViewController {
|
||||
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
|
||||
container._presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: completion)
|
||||
return true
|
||||
} else {
|
||||
cur = vc.parent
|
||||
|
|
|
@ -63,6 +63,9 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
|||
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||
presented.view.layer.opacity = 0
|
||||
}
|
||||
fadeAnimator.addCompletion { _ in
|
||||
presented.view.layer.opacity = 1
|
||||
}
|
||||
fadeAnimator.startAnimation(afterDelay: 0.3)
|
||||
|
||||
} else {
|
||||
|
@ -80,6 +83,7 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
|||
presented.view.layer.opacity = 0
|
||||
}
|
||||
fadeAnimator.addCompletion { _ in
|
||||
presented.view.layer.opacity = 1
|
||||
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ let duckedCornerRadius: CGFloat = 10
|
|||
let detentHeight: CGFloat = 44
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate {
|
||||
public class DuckableContainerViewController: UIViewController {
|
||||
|
||||
public let child: UIViewController
|
||||
private var bottomConstraint: NSLayoutConstraint!
|
||||
|
@ -58,11 +58,13 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
|||
])
|
||||
}
|
||||
|
||||
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
|
||||
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()
|
||||
#if !os(visionOS)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
#endif
|
||||
let origConstant = placeholder.topConstraint.constant
|
||||
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
|
||||
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||
|
@ -87,17 +89,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
|||
}
|
||||
|
||||
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) {
|
||||
viewController.modalPresentationStyle = .custom
|
||||
viewController.transitioningDelegate = self
|
||||
present(viewController, animated: animated) {
|
||||
self.configureChildForDuckedPlaceholder()
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
public func duckableViewControllerWillDismiss(animated: Bool) {
|
||||
func dismissalTransitionWillBegin() {
|
||||
guard case .presentingDucked(_, _) = state else {
|
||||
return
|
||||
}
|
||||
state = .idle
|
||||
bottomConstraint.isActive = false
|
||||
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
|
@ -136,10 +139,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
|||
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
|
||||
return
|
||||
}
|
||||
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
||||
state = .ducked(viewController, placeholder: placeholder)
|
||||
configureChildForDuckedPlaceholder()
|
||||
dismiss(animated: true)
|
||||
switch viewController.duckableViewControllerShouldDuck() {
|
||||
case .duck:
|
||||
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
||||
state = .ducked(viewController, placeholder: placeholder)
|
||||
configureChildForDuckedPlaceholder()
|
||||
dismiss(animated: true)
|
||||
case .block:
|
||||
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
|
||||
case .dismiss:
|
||||
// duckableViewControllerWillDismiss()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureChildForDuckedPlaceholder() {
|
||||
|
@ -148,6 +159,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
|||
bottomConstraint.isActive = true
|
||||
|
||||
child.view.layer.cornerRadius = duckedCornerRadius
|
||||
child.view.layer.cornerCurve = .continuous
|
||||
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||
child.view.layer.masksToBounds = true
|
||||
}
|
||||
|
@ -181,7 +193,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
|||
@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)
|
||||
let controller = DuckableSheetPresentationController(presentedViewController: presented, presenting: presenting)
|
||||
controller.delegate = self
|
||||
controller.prefersGrabberVisible = true
|
||||
controller.selectedDetentIdentifier = .large
|
||||
|
@ -207,6 +219,14 @@ extension DuckableContainerViewController: UIViewControllerTransitioningDelegate
|
|||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
class DuckableSheetPresentationController: UISheetPresentationController {
|
||||
override func dismissalTransitionWillBegin() {
|
||||
super.dismissalTransitionWillBegin()
|
||||
(self.delegate as! DuckableContainerViewController).dismissalTransitionWillBegin()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
|
||||
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
|
||||
|
@ -236,4 +256,3 @@ extension DuckableContainerViewController: UISheetPresentationControllerDelegate
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -0,0 +1,36 @@
|
|||
// swift-tools-version: 6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "GalleryVC",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
.library(
|
||||
name: "GalleryVC",
|
||||
targets: ["GalleryVC"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../TuskerComponents"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "GalleryVC",
|
||||
dependencies: ["TuskerComponents"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
.testTarget(
|
||||
name: "GalleryVCTests",
|
||||
dependencies: ["GalleryVC"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,92 @@
|
|||
//
|
||||
// FallbackGalleryContentViewController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/18/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import QuickLook
|
||||
|
||||
private class FallbackGalleryContentViewController: QLPreviewController {
|
||||
private let previewItem = GalleryPreviewItem()
|
||||
|
||||
init(url: URL) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.previewItem.previewItemURL = url
|
||||
|
||||
dataSource = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
overrideUserInterfaceStyle = .dark
|
||||
|
||||
navigationItem.rightBarButtonItems = [
|
||||
UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(closePressed))
|
||||
]
|
||||
}
|
||||
|
||||
@objc private func closePressed() {
|
||||
self.dismiss(animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FallbackGalleryContentViewController: QLPreviewControllerDataSource {
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem {
|
||||
previewItem
|
||||
}
|
||||
}
|
||||
|
||||
public class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController {
|
||||
public init(url: URL) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.viewControllers = [FallbackGalleryContentViewController(url: url)]
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
container?.disableGalleryScrollAndZoom()
|
||||
}
|
||||
|
||||
public required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
public weak var container: (any GalleryContentViewControllerContainer)?
|
||||
|
||||
public var contentSize: CGSize {
|
||||
.zero
|
||||
}
|
||||
|
||||
public var activityItemsForSharing: [Any] {
|
||||
[]
|
||||
}
|
||||
|
||||
public var caption: String? {
|
||||
nil
|
||||
}
|
||||
|
||||
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
.fade
|
||||
}
|
||||
}
|
||||
|
||||
private class GalleryPreviewItem: NSObject, QLPreviewItem {
|
||||
var previewItemURL: URL? = nil
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
//
|
||||
// ImageGalleryContentViewController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import TuskerComponents
|
||||
@preconcurrency import VisionKit
|
||||
|
||||
open class ImageGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
public let caption: String?
|
||||
public var image: UIImage {
|
||||
didSet {
|
||||
imageView?.image = image
|
||||
}
|
||||
}
|
||||
let gifController: GIFController?
|
||||
|
||||
private var imageView: GIFImageView!
|
||||
|
||||
@available(iOS 16.0, macCatalyst 17.0, *)
|
||||
private static let analyzer = ImageAnalyzer()
|
||||
private var _analysisInteraction: AnyObject?
|
||||
@available(iOS 16.0, macCatalyst 17.0, *)
|
||||
private var analysisInteraction: ImageAnalysisInteraction? { _analysisInteraction as? ImageAnalysisInteraction }
|
||||
|
||||
public init(image: UIImage, caption: String?, gifController: GIFController?) {
|
||||
self.caption = caption
|
||||
self.image = image
|
||||
self.gifController = gifController
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
preferredContentSize = image.size
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
imageView = GIFImageView(image: image)
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.isUserInteractionEnabled = true
|
||||
view.addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
imageView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
if let gifController {
|
||||
gifController.attach(to: imageView)
|
||||
}
|
||||
|
||||
if gifController == nil,
|
||||
#available(iOS 16.0, macCatalyst 17.0, *) {
|
||||
let interaction = ImageAnalysisInteraction(self)
|
||||
self._analysisInteraction = interaction
|
||||
interaction.preferredInteractionTypes = .automatic
|
||||
imageView.addInteraction(interaction)
|
||||
Task {
|
||||
do {
|
||||
let result = try await ImageGalleryContentViewController.analyzer.analyze(image, configuration: ImageAnalyzer.Configuration([.text, .machineReadableCode]))
|
||||
interaction.analysis = result
|
||||
} catch {
|
||||
// if analysis fails, we just don't show anything
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if let gifController {
|
||||
gifController.startAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
public weak var container: (any GalleryContentViewControllerContainer)?
|
||||
|
||||
public var contentSize: CGSize {
|
||||
image.size
|
||||
}
|
||||
|
||||
open var activityItemsForSharing: [Any] {
|
||||
return [image]
|
||||
}
|
||||
|
||||
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
gifController != nil ? .fromSourceViewWithoutSnapshot : .fromSourceView
|
||||
}
|
||||
|
||||
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
if #available(iOS 16.0, macCatalyst 17.0, *),
|
||||
let analysisInteraction {
|
||||
analysisInteraction.setSupplementaryInterfaceHidden(!visible, animated: animated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, macCatalyst 17.0, *)
|
||||
extension ImageGalleryContentViewController: ImageAnalysisInteractionDelegate {
|
||||
public func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool {
|
||||
return container?.galleryControlsVisible ?? true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
//
|
||||
// LoadingGalleryContentViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 3/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
private let fallbackCaption: String?
|
||||
private let provider: () async -> (any GalleryContentViewController)?
|
||||
private var wrapped: (any GalleryContentViewController)!
|
||||
|
||||
public weak var container: GalleryContentViewControllerContainer?
|
||||
|
||||
public var contentSize: CGSize {
|
||||
wrapped?.contentSize ?? .zero
|
||||
}
|
||||
|
||||
public var activityItemsForSharing: [Any] {
|
||||
wrapped?.activityItemsForSharing ?? []
|
||||
}
|
||||
|
||||
public var caption: String? {
|
||||
wrapped?.caption ?? fallbackCaption
|
||||
}
|
||||
|
||||
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
wrapped?.presentationAnimation ?? .fade
|
||||
}
|
||||
|
||||
public init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
|
||||
self.fallbackCaption = caption
|
||||
self.provider = provider
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
container?.setGalleryContentLoading(true)
|
||||
|
||||
Task {
|
||||
if let wrapped = await provider() {
|
||||
self.wrapped = wrapped
|
||||
wrapped.container = container
|
||||
wrapped.setControlsVisible(container?.galleryControlsVisible ?? false, animated: false, dueToUserInteraction: false)
|
||||
|
||||
addChild(wrapped)
|
||||
wrapped.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(wrapped.view)
|
||||
NSLayoutConstraint.activate([
|
||||
wrapped.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
wrapped.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
wrapped.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
wrapped.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
wrapped.didMove(toParent: self)
|
||||
|
||||
container?.galleryContentChanged()
|
||||
} else {
|
||||
showErrorView()
|
||||
}
|
||||
|
||||
container?.setGalleryContentLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func showErrorView() {
|
||||
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
|
||||
image.tintColor = .secondaryLabel
|
||||
image.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = "Error Loading"
|
||||
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
|
||||
label.textColor = .secondaryLabel
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [
|
||||
image,
|
||||
label,
|
||||
])
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = 8
|
||||
view.addSubview(stackView)
|
||||
NSLayoutConstraint.activate([
|
||||
image.widthAnchor.constraint(equalToConstant: 64),
|
||||
image.heightAnchor.constraint(equalToConstant: 64),
|
||||
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
wrapped?.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
|
||||
}
|
||||
|
||||
public func galleryContentDidAppear() {
|
||||
wrapped?.galleryContentDidAppear()
|
||||
}
|
||||
|
||||
public func galleryContentWillDisappear() {
|
||||
wrapped?.galleryContentWillDisappear()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,496 @@
|
|||
//
|
||||
// VideoControlsViewController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/21/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
class VideoControlsViewController: UIViewController {
|
||||
private static let formatter: DateComponentsFormatter = {
|
||||
let f = DateComponentsFormatter()
|
||||
f.allowedUnits = [.minute, .second]
|
||||
f.zeroFormattingBehavior = .pad
|
||||
return f
|
||||
}()
|
||||
|
||||
private let player: AVPlayer
|
||||
|
||||
private lazy var muteButton: MuteButton = {
|
||||
let button = MuteButton()
|
||||
button.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
|
||||
button.setMuted(false, animated: false)
|
||||
return button
|
||||
}()
|
||||
|
||||
private let timestampLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = "0:00"
|
||||
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var scrubbingControl: VideoScrubbingControl = {
|
||||
let control = VideoScrubbingControl()
|
||||
control.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
control.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
|
||||
control.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
|
||||
control.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
|
||||
return control
|
||||
}()
|
||||
|
||||
private let timeRemainingLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = "-0:00"
|
||||
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var optionsButton = MenuButton { [unowned self] in
|
||||
let imageName: String
|
||||
if #available(iOS 17.0, *) {
|
||||
switch player.defaultRate {
|
||||
case 0.5:
|
||||
imageName = "gauge.with.dots.needle.0percent"
|
||||
case 1:
|
||||
imageName = "gauge.with.dots.needle.33percent"
|
||||
case 1.25:
|
||||
imageName = "gauge.with.dots.needle.50percent"
|
||||
case 2:
|
||||
imageName = "gauge.with.dots.needle.100percent"
|
||||
default:
|
||||
imageName = "gauge.with.dots.needle.67percent"
|
||||
}
|
||||
} else {
|
||||
imageName = "speedometer"
|
||||
}
|
||||
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
|
||||
UIAction(title: speed.displayName, state: self.player.defaultRate == speed.rate ? .on : .off) { [unowned self] _ in
|
||||
self.player.defaultRate = speed.rate
|
||||
if self.player.rate > 0 {
|
||||
self.player.rate = speed.rate
|
||||
}
|
||||
}
|
||||
})
|
||||
return UIMenu(children: [speedMenu])
|
||||
}
|
||||
|
||||
private lazy var hStack: UIStackView = {
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
muteButton,
|
||||
timestampLabel,
|
||||
scrubbingControl,
|
||||
timeRemainingLabel,
|
||||
optionsButton,
|
||||
])
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 8
|
||||
stack.alignment = .center
|
||||
return stack
|
||||
}()
|
||||
|
||||
private var timestampObserverToken: Any?
|
||||
private var scrubberObserverToken: Any?
|
||||
|
||||
private var wasPlayingWhenScrubbingStarted = false
|
||||
private var scrubbingTargetTime: CMTime?
|
||||
private var isSeeking = false
|
||||
|
||||
init(player: AVPlayer) {
|
||||
self.player = player
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let timestampObserverToken {
|
||||
player.removeTimeObserver(timestampObserverToken)
|
||||
}
|
||||
if let scrubberObserverToken {
|
||||
player.removeTimeObserver(scrubberObserverToken)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
hStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(hStack)
|
||||
NSLayoutConstraint.activate([
|
||||
hStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 4),
|
||||
hStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -4),
|
||||
hStack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hStack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
timestampObserverToken = player.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 2), queue: .main) { [unowned self] _ in
|
||||
self.updateTimestamps()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTimestamps() {
|
||||
let current = player.currentTime()
|
||||
timestampLabel.text = VideoControlsViewController.formatter.string(from: current.seconds)!
|
||||
let duration = player.currentItem!.duration
|
||||
if duration != .indefinite {
|
||||
let remaining = duration - current
|
||||
timeRemainingLabel.text = "-" + VideoControlsViewController.formatter.string(from: remaining.seconds)!
|
||||
|
||||
if scrubberObserverToken == nil {
|
||||
let interval = CMTime(value: 1, timescale: CMTimeScale(self.scrubbingControl.bounds.width))
|
||||
if interval.isValid {
|
||||
self.scrubberObserverToken = self.player.addPeriodicTimeObserver(forInterval: interval, queue: .main, using: { _ in
|
||||
self.scrubbingControl.fractionComplete = self.player.currentTime().seconds / duration.seconds
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func scrubbingStarted() {
|
||||
wasPlayingWhenScrubbingStarted = player.rate > 0
|
||||
player.rate = 0
|
||||
}
|
||||
|
||||
@objc private func scrubbingChanged() {
|
||||
let duration = player.currentItem!.duration
|
||||
let time = CMTime(value: CMTimeValue(scrubbingControl.fractionComplete * duration.seconds * 1_000_000_000), timescale: 1_000_000_000)
|
||||
scrubbingTargetTime = time
|
||||
if !isSeeking {
|
||||
seekToScrubbingTime()
|
||||
}
|
||||
}
|
||||
|
||||
private func seekToScrubbingTime() {
|
||||
guard let scrubbingTargetTime else {
|
||||
return
|
||||
}
|
||||
isSeeking = true
|
||||
player.seek(to: scrubbingTargetTime) { finished in
|
||||
if finished {
|
||||
if self.scrubbingTargetTime != scrubbingTargetTime {
|
||||
self.seekToScrubbingTime()
|
||||
} else {
|
||||
self.isSeeking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func scrubbingEnded() {
|
||||
scrubbingChanged()
|
||||
if wasPlayingWhenScrubbingStarted {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func muteButtonPressed() {
|
||||
player.isMuted.toggle()
|
||||
muteButton.setMuted(player.isMuted, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private class VideoScrubbingControl: UIControl {
|
||||
var fractionComplete: Double = 0 {
|
||||
didSet {
|
||||
updateFillLayerMask()
|
||||
}
|
||||
}
|
||||
|
||||
private let trackLayer = CAShapeLayer()
|
||||
private let fillLayer = CAShapeLayer()
|
||||
private let fillMaskLayer = CALayer()
|
||||
|
||||
private var scrubbingStartFraction: Double?
|
||||
private var touchStartLocation: CGPoint?
|
||||
private var animator: UIViewPropertyAnimator?
|
||||
|
||||
#if !os(visionOS)
|
||||
private var feedbackGenerator: UIImpactFeedbackGenerator?
|
||||
#endif
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
trackLayer.fillColor = UIColor.systemGray.cgColor
|
||||
trackLayer.shadowColor = UIColor.black.cgColor
|
||||
layer.addSublayer(trackLayer)
|
||||
|
||||
fillLayer.fillColor = UIColor.white.cgColor
|
||||
fillLayer.mask = fillMaskLayer
|
||||
layer.addSublayer(fillLayer)
|
||||
|
||||
fillMaskLayer.backgroundColor = UIColor.black.cgColor
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSublayers(of layer: CALayer) {
|
||||
super.layoutSublayers(of: layer)
|
||||
|
||||
let trackFrame = CGRect(x: 0, y: (layer.bounds.height - 8) / 2, width: layer.bounds.width, height: 8)
|
||||
|
||||
trackLayer.frame = trackFrame
|
||||
trackLayer.path = CGPath(roundedRect: CGRect(x: 0, y: 0, width: trackFrame.width, height: trackFrame.height), cornerWidth: 4, cornerHeight: 4, transform: nil)
|
||||
trackLayer.shadowPath = trackLayer.path
|
||||
|
||||
fillLayer.frame = trackFrame
|
||||
fillLayer.path = trackLayer.path
|
||||
updateFillLayerMask()
|
||||
}
|
||||
|
||||
private func updateFillLayerMask() {
|
||||
// I don't know where this animation is coming from
|
||||
fillMaskLayer.frame = CGRect(x: 0, y: 0, width: fractionComplete * bounds.width, height: 8)
|
||||
fillMaskLayer.removeAllAnimations()
|
||||
}
|
||||
|
||||
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||
if touch.type == .pencil || touch.type == .indirectPointer {
|
||||
touchStartLocation = .zero
|
||||
scrubbingStartFraction = 0
|
||||
} else {
|
||||
touchStartLocation = touch.location(in: self)
|
||||
scrubbingStartFraction = fractionComplete
|
||||
}
|
||||
|
||||
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
|
||||
animator!.addAnimations {
|
||||
self.transform = CGAffineTransform(scaleX: 1, y: 1.5)
|
||||
}
|
||||
animator!.startAnimation()
|
||||
|
||||
sendActions(for: .editingDidBegin)
|
||||
|
||||
#if !os(visionOS)
|
||||
if #available(iOS 17.5, *) {
|
||||
feedbackGenerator = UIImpactFeedbackGenerator(style: .light, view: self)
|
||||
} else {
|
||||
feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
||||
}
|
||||
feedbackGenerator!.prepare()
|
||||
#endif
|
||||
|
||||
updateScrubbing(for: touch)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||
updateScrubbing(for: touch)
|
||||
return true
|
||||
}
|
||||
|
||||
private func updateScrubbing(for touch: UITouch) {
|
||||
guard let touchStartLocation,
|
||||
let scrubbingStartFraction else {
|
||||
return
|
||||
}
|
||||
let location = touch.location(in: self)
|
||||
let translation = CGPoint(x: location.x - touchStartLocation.x, y: location.y - touchStartLocation.y)
|
||||
let scrubbingAmount = translation.x / bounds.width
|
||||
let unclampedFractionComplete = scrubbingStartFraction + scrubbingAmount
|
||||
let newFractionComplete = max(0, min(1, unclampedFractionComplete))
|
||||
#if !os(visionOS)
|
||||
if newFractionComplete != fractionComplete && (newFractionComplete == 0 || newFractionComplete == 1) {
|
||||
if #available(iOS 17.5, *) {
|
||||
feedbackGenerator!.impactOccurred(intensity: 0.5, at: location)
|
||||
} else {
|
||||
feedbackGenerator!.impactOccurred(intensity: 0.5)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
fractionComplete = newFractionComplete
|
||||
sendActions(for: .editingChanged)
|
||||
|
||||
if unclampedFractionComplete < 0 || unclampedFractionComplete > 1 {
|
||||
let stretchFactor: CGFloat
|
||||
if unclampedFractionComplete < 0 {
|
||||
stretchFactor = 1/(unclampedFractionComplete * bounds.width / 10 - 1) + 1
|
||||
} else {
|
||||
stretchFactor = -1/((unclampedFractionComplete-1) * bounds.width / 10 + 1) + 1
|
||||
}
|
||||
let stretchAmount = 8 * stretchFactor
|
||||
transform = CGAffineTransform(scaleX: 1 + stretchAmount / bounds.width, y: 1 + 0.5 * (1 - stretchFactor))
|
||||
.translatedBy(x: sign(unclampedFractionComplete) * stretchAmount / 2, y: 0)
|
||||
}
|
||||
}
|
||||
|
||||
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
||||
touchStartLocation = nil
|
||||
resetScale()
|
||||
sendActions(for: .editingDidEnd)
|
||||
#if !os(visionOS)
|
||||
feedbackGenerator = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
override func cancelTracking(with event: UIEvent?) {
|
||||
touchStartLocation = nil
|
||||
resetScale()
|
||||
sendActions(for: .editingDidEnd)
|
||||
#if !os(visionOS)
|
||||
feedbackGenerator = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func resetScale() {
|
||||
if let animator,
|
||||
animator.isRunning {
|
||||
animator.isReversed = true
|
||||
animator.startAnimation()
|
||||
} else {
|
||||
animator?.pauseAnimation()
|
||||
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
|
||||
animator!.addAnimations {
|
||||
self.transform = .identity
|
||||
}
|
||||
animator!.startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private class MuteButton: UIControl {
|
||||
private let imageView = UIImageView()
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: 32, height: 32)
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.tintColor = .white
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
])
|
||||
|
||||
addInteraction(UIPointerInteraction(delegate: nil))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setMuted(_ muted: Bool, animated: Bool) {
|
||||
let image = UIImage(systemName: muted ? "speaker.slash.fill" : "speaker.wave.3.fill")!
|
||||
if animated,
|
||||
#available(iOS 17.0, *) {
|
||||
imageView.setSymbolImage(image, contentTransition: .replace.byLayer)
|
||||
} else {
|
||||
imageView.image = image
|
||||
}
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
!(gestureRecognizer is UITapGestureRecognizer)
|
||||
}
|
||||
}
|
||||
|
||||
private class MenuButton: UIControl {
|
||||
private let menuProvider: () -> UIMenu
|
||||
|
||||
private let imageView = UIImageView()
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: 32, height: 32)
|
||||
}
|
||||
|
||||
init(menuProvider: @escaping () -> UIMenu) {
|
||||
self.menuProvider = menuProvider
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
imageView.image = UIImage(systemName: "ellipsis.circle")
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.tintColor = .white
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
])
|
||||
|
||||
isContextMenuInteractionEnabled = true
|
||||
showsMenuAsPrimaryAction = true
|
||||
|
||||
addInteraction(UIPointerInteraction(delegate: nil))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||
UIContextMenuConfiguration(actionProvider: { _ in
|
||||
self.menuProvider()
|
||||
})
|
||||
}
|
||||
|
||||
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: (any UIContextMenuInteractionAnimating)?) {
|
||||
animator?.addAnimations {
|
||||
self.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
|
||||
}
|
||||
}
|
||||
|
||||
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: (any UIContextMenuInteractionAnimating)?) {
|
||||
if let animator {
|
||||
animator.addAnimations {
|
||||
self.transform = .identity
|
||||
}
|
||||
} else {
|
||||
self.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum PlaybackSpeed: CaseIterable {
|
||||
case half, regular, oneAndAQuarter, oneAndAHalf, two
|
||||
|
||||
var rate: Float {
|
||||
switch self {
|
||||
case .half:
|
||||
0.5
|
||||
case .regular:
|
||||
1
|
||||
case .oneAndAQuarter:
|
||||
1.25
|
||||
case .oneAndAHalf:
|
||||
1.5
|
||||
case .two:
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .half:
|
||||
"0.5×"
|
||||
case .regular:
|
||||
"1×"
|
||||
case .oneAndAQuarter:
|
||||
"1.25×"
|
||||
case .oneAndAHalf:
|
||||
"1.5×"
|
||||
case .two:
|
||||
"2×"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,231 @@
|
|||
//
|
||||
// VideoGalleryContentViewController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/19/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
import CoreImage
|
||||
|
||||
open class VideoGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
public let url: URL
|
||||
public let caption: String?
|
||||
public private(set) var item: AVPlayerItem
|
||||
public let player: AVPlayer
|
||||
|
||||
private var presentationSizeObservation: NSKeyValueObservation?
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
private var rateObservation: NSKeyValueObservation?
|
||||
private var hideControlsWorkItem: DispatchWorkItem?
|
||||
private var isShowingError = false
|
||||
|
||||
public init(url: URL, caption: String?) {
|
||||
self.url = url
|
||||
self.caption = caption
|
||||
|
||||
let asset = AVAsset(url: url)
|
||||
self.item = Self.createItem(asset: asset)
|
||||
self.player = AVPlayer(playerItem: item)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
open class func createItem(asset: AVAsset) -> AVPlayerItem {
|
||||
return AVPlayerItem(asset: asset)
|
||||
}
|
||||
|
||||
public func replaceCurrentItem(with item: AVPlayerItem) {
|
||||
self.item = item
|
||||
player.replaceCurrentItem(with: item)
|
||||
updateItemObservations()
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
container?.setGalleryContentLoading(true)
|
||||
|
||||
let playerView = PlayerView(item: item, player: player)
|
||||
playerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(playerView)
|
||||
NSLayoutConstraint.activate([
|
||||
playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
playerView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
preferredContentSize = item.presentationSize
|
||||
|
||||
updateItemObservations()
|
||||
rateObservation = player.observe(\.rate, options: .old, changeHandler: { [unowned self] player, info in
|
||||
if player.rate == 0 {
|
||||
hideControlsWorkItem?.cancel()
|
||||
} else if player.rate > 0 && info.oldValue == 0 {
|
||||
scheduleControlsHide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func updateItemObservations() {
|
||||
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
|
||||
MainActor.assumeIsolated {
|
||||
self.preferredContentSize = item.presentationSize
|
||||
self.container?.galleryContentChanged()
|
||||
}
|
||||
})
|
||||
statusObservation = item.observe(\.status, changeHandler: { [unowned self] item, _ in
|
||||
MainActor.assumeIsolated {
|
||||
if item.status == .readyToPlay {
|
||||
self.container?.setGalleryContentLoading(false)
|
||||
self.statusObservation = nil
|
||||
} else if item.status == .failed,
|
||||
let error = item.error {
|
||||
self.container?.setGalleryContentLoading(false)
|
||||
self.showErrorView(error)
|
||||
self.statusObservation = nil
|
||||
self.overlayVC.setVisible(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func showErrorView(_ error: any Error) {
|
||||
isShowingError = true
|
||||
|
||||
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
|
||||
image.tintColor = .secondaryLabel
|
||||
image.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = "Error Loading"
|
||||
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
|
||||
label.textColor = .secondaryLabel
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
|
||||
let reason = UILabel()
|
||||
reason.text = error.localizedDescription
|
||||
reason.font = .preferredFont(forTextStyle: .subheadline)
|
||||
reason.textColor = .secondaryLabel
|
||||
reason.adjustsFontForContentSizeCategory = true
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [
|
||||
image,
|
||||
label,
|
||||
reason,
|
||||
])
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = 8
|
||||
view.addSubview(stackView)
|
||||
NSLayoutConstraint.activate([
|
||||
image.widthAnchor.constraint(equalToConstant: 64),
|
||||
image.heightAnchor.constraint(equalToConstant: 64),
|
||||
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func scheduleControlsHide() {
|
||||
hideControlsWorkItem = DispatchWorkItem { [weak self] in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self,
|
||||
let container = self.container,
|
||||
container.galleryControlsVisible else {
|
||||
return
|
||||
}
|
||||
container.setGalleryControlsVisible(false, animated: true)
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5), execute: hideControlsWorkItem!)
|
||||
}
|
||||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
public weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
|
||||
public var contentSize: CGSize {
|
||||
item.presentationSize
|
||||
}
|
||||
|
||||
open var activityItemsForSharing: [Any] {
|
||||
// [VideoActivityItemSource(asset: item.asset, url: url)]
|
||||
[]
|
||||
}
|
||||
|
||||
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
isShowingError ? .fade : .fromSourceViewWithoutSnapshot
|
||||
}
|
||||
|
||||
private lazy var overlayVC = VideoOverlayViewController(player: player)
|
||||
public var contentOverlayAccessoryViewController: UIViewController? {
|
||||
overlayVC
|
||||
}
|
||||
|
||||
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
|
||||
|
||||
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
if !isShowingError {
|
||||
overlayVC.setVisible(visible)
|
||||
}
|
||||
|
||||
if !visible {
|
||||
hideControlsWorkItem?.cancel()
|
||||
} else if dueToUserInteraction,
|
||||
player.rate > 0 {
|
||||
scheduleControlsHide()
|
||||
}
|
||||
}
|
||||
|
||||
open func galleryContentDidAppear() {
|
||||
}
|
||||
|
||||
open func galleryContentWillDisappear() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class PlayerView: UIView {
|
||||
override class var layerClass: AnyClass {
|
||||
AVPlayerLayer.self
|
||||
}
|
||||
|
||||
private var playerLayer: AVPlayerLayer {
|
||||
layer as! AVPlayerLayer
|
||||
}
|
||||
|
||||
private let player: AVPlayer
|
||||
private var presentationSizeObservation: NSKeyValueObservation?
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
player.currentItem?.presentationSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
||||
}
|
||||
|
||||
init(item: AVPlayerItem, player: AVPlayer) {
|
||||
self.player = player
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
playerLayer.player = player
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
|
||||
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] _, _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.invalidateIntrinsicContentSize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,218 @@
|
|||
//
|
||||
// VideoOverlayViewController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/26/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
class VideoOverlayViewController: UIViewController {
|
||||
|
||||
private static let playImage = UIImage(systemName: "play.fill")!
|
||||
private static let pauseImage = UIImage(systemName: "pause.fill")!
|
||||
|
||||
private let player: AVPlayer
|
||||
|
||||
private var dimmingView: UIView!
|
||||
private var controlsStack: UIStackView!
|
||||
private var skipBackButton: VideoOverlayButton!
|
||||
private var skipForwardButton: VideoOverlayButton!
|
||||
|
||||
private var rateObservation: NSKeyValueObservation?
|
||||
|
||||
init(player: AVPlayer) {
|
||||
self.player = player
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
dimmingView = UIView()
|
||||
dimmingView.backgroundColor = .black
|
||||
dimmingView.alpha = 0.2
|
||||
dimmingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(dimmingView)
|
||||
NSLayoutConstraint.activate([
|
||||
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
skipBackButton = VideoOverlayButton(image: UIImage(systemName: "gobackward.10")!)
|
||||
skipBackButton.addTarget(self, action: #selector(skipBackPressed), for: .touchUpInside)
|
||||
|
||||
let playPauseButton = VideoOverlayButton(image: VideoOverlayViewController.pauseImage)
|
||||
playPauseButton.addTarget(self, action: #selector(playPausePressed), for: .touchUpInside)
|
||||
|
||||
skipForwardButton = VideoOverlayButton(image: UIImage(systemName: "goforward.10")!)
|
||||
skipForwardButton.addTarget(self, action: #selector(skipForwardPressed), for: .touchUpInside)
|
||||
|
||||
controlsStack = UIStackView(arrangedSubviews: [
|
||||
skipBackButton,
|
||||
playPauseButton,
|
||||
skipForwardButton,
|
||||
])
|
||||
controlsStack.axis = .horizontal
|
||||
controlsStack.alignment = .center
|
||||
controlsStack.spacing = 24
|
||||
controlsStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(controlsStack)
|
||||
NSLayoutConstraint.activate([
|
||||
controlsStack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
controlsStack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
|
||||
skipBackButton.widthAnchor.constraint(equalToConstant: 50),
|
||||
skipBackButton.heightAnchor.constraint(equalToConstant: 50),
|
||||
playPauseButton.widthAnchor.constraint(equalToConstant: 66),
|
||||
playPauseButton.heightAnchor.constraint(equalToConstant: 66),
|
||||
skipForwardButton.widthAnchor.constraint(equalToConstant: 50),
|
||||
skipForwardButton.heightAnchor.constraint(equalToConstant: 50),
|
||||
])
|
||||
|
||||
rateObservation = player.observe(\.rate, changeHandler: { player, _ in
|
||||
MainActor.assumeIsolated {
|
||||
playPauseButton.image = player.rate > 0 ? VideoOverlayViewController.pauseImage : VideoOverlayViewController.playImage
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setVisible(_ visible: Bool) {
|
||||
loadViewIfNeeded()
|
||||
|
||||
view.alpha = visible ? 1 : 0
|
||||
}
|
||||
|
||||
@objc private func playPausePressed() {
|
||||
if player.rate > 0 {
|
||||
player.rate = 0
|
||||
} else {
|
||||
if player.currentTime() >= player.currentItem!.duration {
|
||||
player.seek(to: .zero)
|
||||
}
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func skipBackPressed() {
|
||||
player.seek(to: player.currentTime() - CMTime(value: 10, timescale: 1))
|
||||
}
|
||||
|
||||
@objc private func skipForwardPressed() {
|
||||
player.seek(to: player.currentTime() + CMTime(value: 10, timescale: 1))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class VideoOverlayButton: UIControl {
|
||||
var image: UIImage? {
|
||||
get {
|
||||
imageView.image
|
||||
}
|
||||
set {
|
||||
imageView.image = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private let backgroundView = UIView()
|
||||
private let imageView = UIImageView()
|
||||
|
||||
private var animator: UIViewPropertyAnimator?
|
||||
|
||||
override var isEnabled: Bool {
|
||||
didSet {
|
||||
imageView.tintColor = isEnabled ? .white : .lightGray
|
||||
}
|
||||
}
|
||||
|
||||
init(image: UIImage) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
backgroundView.alpha = 0
|
||||
backgroundView.backgroundColor = .lightGray.withAlphaComponent(0.5)
|
||||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(backgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
backgroundView.topAnchor.constraint(equalTo: topAnchor),
|
||||
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
imageView.image = image
|
||||
imageView.tintColor = .white
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.preferredSymbolConfiguration = .init(scale: .large)
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
|
||||
imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||||
imageView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
||||
imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
||||
])
|
||||
|
||||
addInteraction(UIPointerInteraction(delegate: self))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
backgroundView.layer.cornerRadius = bounds.height / 2
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
bounds.contains(point) ? self : nil
|
||||
}
|
||||
|
||||
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||
if touch.type != .indirectPointer {
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.backgroundView.alpha = 1
|
||||
self.backgroundView.transform = CGAffineTransform(scaleX: 1/0.8, y: 1/0.8)
|
||||
self.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
|
||||
}
|
||||
}
|
||||
return super.beginTracking(touch, with: event)
|
||||
}
|
||||
|
||||
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
||||
if touch?.type != .indirectPointer {
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.backgroundView.alpha = 0
|
||||
self.backgroundView.transform = .identity
|
||||
self.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer is UITapGestureRecognizer {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension VideoOverlayButton: UIPointerInteractionDelegate {
|
||||
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
|
||||
return UIPointerRegion(rect: bounds)
|
||||
}
|
||||
|
||||
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
||||
let preview = UITargetedPreview(view: self)
|
||||
return UIPointerStyle(effect: .highlight(preview), shape: .path(UIBezierPath(ovalIn: frame)))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// GalleryContentViewController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/17/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public protocol GalleryContentViewController: UIViewController {
|
||||
var container: GalleryContentViewControllerContainer? { get set }
|
||||
var contentSize: CGSize { get }
|
||||
var activityItemsForSharing: [Any] { get }
|
||||
var caption: String? { get }
|
||||
var contentOverlayAccessoryViewController: UIViewController? { get }
|
||||
var bottomControlsAccessoryViewController: UIViewController? { get }
|
||||
var presentationAnimation: GalleryContentPresentationAnimation { get }
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
|
||||
func galleryContentDidAppear()
|
||||
func galleryContentWillDisappear()
|
||||
}
|
||||
|
||||
public extension GalleryContentViewController {
|
||||
var contentOverlayAccessoryViewController: UIViewController? {
|
||||
nil
|
||||
}
|
||||
|
||||
var bottomControlsAccessoryViewController: UIViewController? {
|
||||
nil
|
||||
}
|
||||
|
||||
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
.fromSourceView
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
}
|
||||
|
||||
func galleryContentDidAppear() {
|
||||
}
|
||||
|
||||
func galleryContentWillDisappear() {
|
||||
}
|
||||
}
|
||||
|
||||
public enum GalleryContentPresentationAnimation {
|
||||
case fade
|
||||
case fromSourceView
|
||||
case fromSourceViewWithoutSnapshot
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// GalleryContentViewControllerContainer.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 12/28/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public protocol GalleryContentViewControllerContainer: AnyObject {
|
||||
var galleryControlsVisible: Bool { get }
|
||||
|
||||
func setGalleryContentLoading(_ loading: Bool)
|
||||
func galleryContentChanged()
|
||||
func disableGalleryScrollAndZoom()
|
||||
func setGalleryControlsVisible(_ visible: Bool, animated: Bool)
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// GalleryDataSource.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 12/28/23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public protocol GalleryDataSource {
|
||||
func galleryItemsCount() -> Int
|
||||
func galleryContentViewController(forItemAt index: Int) -> GalleryContentViewController
|
||||
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView?
|
||||
func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]?
|
||||
}
|
||||
|
||||
public extension GalleryDataSource {
|
||||
func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]? {
|
||||
nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
//
|
||||
// GalleryDismissAnimationController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/1/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
private let sourceView: UIView
|
||||
private let interactiveTranslation: CGPoint?
|
||||
private let interactiveVelocity: CGPoint?
|
||||
|
||||
init(sourceView: UIView, interactiveTranslation: CGPoint?, interactiveVelocity: CGPoint?) {
|
||||
self.sourceView = sourceView
|
||||
self.interactiveTranslation = interactiveTranslation
|
||||
self.interactiveVelocity = interactiveVelocity
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval {
|
||||
return 0.3
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
|
||||
guard let to = transitionContext.viewController(forKey: .to),
|
||||
let from = transitionContext.viewController(forKey: .from) as? GalleryViewController else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let itemViewController = from.currentItemViewController
|
||||
|
||||
if itemViewController.content.presentationAnimation == .fade || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
|
||||
animateCrossFadeTransition(using: transitionContext)
|
||||
return
|
||||
}
|
||||
|
||||
let container = transitionContext.containerView
|
||||
|
||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||
// is in the window's root presentation.
|
||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||
// container causees it to be removed when the transition completes.
|
||||
if to.view.superview == nil {
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
}
|
||||
|
||||
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
|
||||
nil
|
||||
} else {
|
||||
sourceView.snapshotView(afterScreenUpdates: false)
|
||||
}
|
||||
if let sourceSnapshot {
|
||||
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
|
||||
snapshotContainer.addSubview(sourceSnapshot)
|
||||
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
|
||||
sourceSnapshot.frame = sourceFrameInShapshotContainer
|
||||
sourceSnapshot.layer.opacity = 1
|
||||
self.sourceView.layer.opacity = 0
|
||||
}
|
||||
|
||||
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
|
||||
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
|
||||
|
||||
let origSourceTransform = sourceView.transform
|
||||
let appliedSourceToDestTransform: Bool
|
||||
if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 {
|
||||
appliedSourceToDestTransform = true
|
||||
let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height)
|
||||
let sourceToDestTransform = origSourceTransform
|
||||
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
|
||||
.scaledBy(x: scale, y: scale)
|
||||
sourceView.transform = sourceToDestTransform
|
||||
sourceSnapshot?.transform = sourceToDestTransform
|
||||
} else {
|
||||
appliedSourceToDestTransform = false
|
||||
}
|
||||
|
||||
from.view.frame = container.bounds
|
||||
container.addSubview(from.view)
|
||||
|
||||
let contentContainer = UIView()
|
||||
contentContainer.layer.masksToBounds = true
|
||||
contentContainer.frame = destFrameInContainer
|
||||
container.addSubview(contentContainer)
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.transform = .identity
|
||||
content.view.layer.opacity = 1
|
||||
content.view.frame = contentContainer.bounds
|
||||
contentContainer.addSubview(content.view)
|
||||
|
||||
container.layoutIfNeeded()
|
||||
|
||||
// Hide overlaid controls immediately, to prevent the Live Text button's position
|
||||
// getting caught up in the rest of the animation.
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
content.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
let duration = self.transitionDuration(using: transitionContext)
|
||||
var initialVelocity: CGVector
|
||||
if let interactiveVelocity,
|
||||
let interactiveTranslation,
|
||||
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the spring's initial undershoot
|
||||
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
|
||||
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
|
||||
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
|
||||
let yDistance = sourceFrameInContainer.midY - destFrameInContainer.midY
|
||||
initialVelocity = CGVector(
|
||||
dx: xDistance == 0 ? 0 : interactiveVelocity.x / xDistance,
|
||||
dy: yDistance == 0 ? 0 : interactiveVelocity.y / yDistance
|
||||
)
|
||||
} else {
|
||||
initialVelocity = .zero
|
||||
}
|
||||
initialVelocity.dx = max(-10, min(10, initialVelocity.dx))
|
||||
initialVelocity.dy = max(-10, min(10, initialVelocity.dy))
|
||||
// no bounce for the dismiss animation
|
||||
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: initialVelocity)
|
||||
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
|
||||
|
||||
animator.addAnimations {
|
||||
from.view.layer.opacity = 0
|
||||
|
||||
if appliedSourceToDestTransform {
|
||||
self.sourceView.transform = origSourceTransform
|
||||
sourceSnapshot?.transform = origSourceTransform
|
||||
}
|
||||
|
||||
contentContainer.frame = sourceFrameInContainer
|
||||
// Using sourceSizeWithDestAspectRatioCenteredInContentContainer does not seem to be necessary here.
|
||||
// I guess autoresizing takes care of it?
|
||||
|
||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
// Delay fading out the content because if it's still big while it's semi-transparent,
|
||||
// seeing the stuff behind it looks odd.
|
||||
animator.addAnimations({
|
||||
content.view.layer.opacity = 0
|
||||
}, delayFactor: 0.35)
|
||||
|
||||
if let sourceSnapshot {
|
||||
animator.addAnimations({
|
||||
self.sourceView.layer.opacity = 1
|
||||
sourceSnapshot.layer.opacity = 0
|
||||
}, delayFactor: 0.5)
|
||||
}
|
||||
|
||||
animator.addCompletion { _ in
|
||||
sourceSnapshot?.removeFromSuperview()
|
||||
|
||||
// Having dismissed, we don't need to undo any of the changes to the content VC.
|
||||
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from),
|
||||
let toVC = transitionContext.viewController(forKey: .to) else {
|
||||
return
|
||||
}
|
||||
|
||||
toVC.view.frame = transitionContext.containerView.bounds
|
||||
fromVC.view.frame = transitionContext.containerView.bounds
|
||||
transitionContext.containerView.addSubview(toVC.view)
|
||||
transitionContext.containerView.addSubview(fromVC.view)
|
||||
|
||||
let duration = transitionDuration(using: transitionContext)
|
||||
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
|
||||
animator.addAnimations {
|
||||
fromVC.view.alpha = 0
|
||||
}
|
||||
animator.addCompletion { _ in
|
||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
//
|
||||
// GalleryDismissInteraction.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/1/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
class GalleryDismissInteraction: NSObject {
|
||||
|
||||
private unowned let viewController: GalleryViewController
|
||||
|
||||
private var content: GalleryContentViewController?
|
||||
private var origContentFrameInGallery: CGRect?
|
||||
private var origControlsVisible: Bool?
|
||||
|
||||
private(set) var isActive = false
|
||||
private(set) var dismissVelocity: CGPoint?
|
||||
private(set) var dismissTranslation: CGPoint?
|
||||
|
||||
private var cancelAnimator: UIViewPropertyAnimator?
|
||||
|
||||
init(viewController: GalleryViewController) {
|
||||
self.viewController = viewController
|
||||
super.init()
|
||||
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
|
||||
panRecognizer.delegate = self
|
||||
panRecognizer.allowedScrollTypesMask = .continuous
|
||||
viewController.view.addGestureRecognizer(panRecognizer)
|
||||
}
|
||||
|
||||
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
isActive = true
|
||||
|
||||
origContentFrameInGallery = viewController.view.convert(viewController.currentItemViewController.content.view.bounds, from: viewController.currentItemViewController.content.view)
|
||||
content = viewController.currentItemViewController.takeContent()
|
||||
content!.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content!.view.frame = origContentFrameInGallery!
|
||||
// Make sure the context remains behind the controls
|
||||
content!.view.layer.zPosition = -1000
|
||||
viewController.view.addSubview(content!.view)
|
||||
|
||||
origControlsVisible = viewController.currentItemViewController.controlsVisible
|
||||
if origControlsVisible! {
|
||||
viewController.currentItemViewController.setControlsVisible(false, animated: true, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
case .changed:
|
||||
let translation = recognizer.translation(in: viewController.view)
|
||||
content!.view.frame = origContentFrameInGallery!.offsetBy(dx: translation.x, dy: translation.y)
|
||||
|
||||
case .ended:
|
||||
let translation = recognizer.translation(in: viewController.view)
|
||||
let velocity = recognizer.velocity(in: viewController.view)
|
||||
|
||||
let translationMagnitude = sqrt(translation.x.magnitudeSquared + translation.y.magnitudeSquared)
|
||||
let velocityMagnitude = sqrt(velocity.x.magnitudeSquared + velocity.y.magnitudeSquared)
|
||||
|
||||
if translationMagnitude < 150 && velocityMagnitude < 500 {
|
||||
isActive = false
|
||||
|
||||
cancelAnimator?.stopAnimation(true)
|
||||
|
||||
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: .zero)
|
||||
cancelAnimator = UIViewPropertyAnimator(duration: 0.2, timingParameters: spring)
|
||||
cancelAnimator!.addAnimations {
|
||||
self.content!.view.frame = self.origContentFrameInGallery!
|
||||
self.viewController.currentItemViewController.setControlsVisible(self.origControlsVisible!, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
cancelAnimator!.addCompletion { _ in
|
||||
guard !self.isActive else {
|
||||
// bail in case the animation finishing raced with the user's interaction
|
||||
return
|
||||
}
|
||||
self.content!.view.layer.zPosition = 0
|
||||
self.content!.view.removeFromSuperview()
|
||||
self.viewController.currentItemViewController.addContent()
|
||||
self.content = nil
|
||||
self.origContentFrameInGallery = nil
|
||||
self.origControlsVisible = nil
|
||||
}
|
||||
cancelAnimator!.startAnimation()
|
||||
|
||||
} else {
|
||||
dismissVelocity = velocity
|
||||
dismissTranslation = translation
|
||||
viewController.dismiss(animated: true)
|
||||
|
||||
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
|
||||
isActive = false
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension GalleryDismissInteraction: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
let itemVC = viewController.currentItemViewController
|
||||
if viewController.galleryDataSource.galleryContentTransitionSourceView(forItemAt: itemVC.itemIndex) == nil {
|
||||
return false
|
||||
} else if itemVC.scrollView.zoomScale > itemVC.scrollView.minimumZoomScale {
|
||||
return false
|
||||
} else if !itemVC.scrollAndZoomEnabled {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,574 @@
|
|||
//
|
||||
// GalleryItemViewController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 12/28/23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
@MainActor
|
||||
protocol GalleryItemViewControllerDelegate: AnyObject {
|
||||
func isGalleryBeingPresented() -> Bool
|
||||
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
|
||||
func galleryItemClose(_ item: GalleryItemViewController)
|
||||
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
|
||||
}
|
||||
|
||||
class GalleryItemViewController: UIViewController {
|
||||
private weak var delegate: GalleryItemViewControllerDelegate?
|
||||
|
||||
let itemIndex: Int
|
||||
let content: GalleryContentViewController
|
||||
private var overlayVC: UIViewController?
|
||||
|
||||
private var activityIndicator: UIActivityIndicatorView?
|
||||
private(set) var scrollView: UIScrollView!
|
||||
private var topControlsView: UIView!
|
||||
private var shareButton: UIButton!
|
||||
private var shareButtonLeadingConstraint: NSLayoutConstraint!
|
||||
private var shareButtonTopConstraint: NSLayoutConstraint!
|
||||
private var closeButtonTrailingConstraint: NSLayoutConstraint!
|
||||
private var closeButtonTopConstraint: NSLayoutConstraint!
|
||||
private var bottomControlsView: UIStackView!
|
||||
private(set) var captionTextView: UITextView!
|
||||
|
||||
private var singleTap: UITapGestureRecognizer!
|
||||
private var doubleTap: UITapGestureRecognizer!
|
||||
|
||||
private var contentViewLeadingConstraint: NSLayoutConstraint?
|
||||
private var contentViewTopConstraint: NSLayoutConstraint?
|
||||
|
||||
private(set) var controlsVisible: Bool = true
|
||||
private(set) var scrollAndZoomEnabled = true
|
||||
|
||||
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
||||
|
||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||
return !controlsVisible
|
||||
}
|
||||
|
||||
init(delegate: GalleryItemViewControllerDelegate, itemIndex: Int, content: GalleryContentViewController) {
|
||||
self.delegate = delegate
|
||||
self.itemIndex = itemIndex
|
||||
self.content = content
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
content.container = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
scrollView = UIScrollView()
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.delegate = self
|
||||
// We calculate zoom/position ignoring the safe area, so content insets need to not incorporate it either.
|
||||
// Otherwise, content that fills the screen (extending into the safe area) may still end up scrollable
|
||||
// (this is readily observable with tall images on a landscape iPad).
|
||||
scrollView.contentInsetAdjustmentBehavior = .never
|
||||
|
||||
view.addSubview(scrollView)
|
||||
|
||||
addContent()
|
||||
centerContent()
|
||||
|
||||
overlayVC = content.contentOverlayAccessoryViewController
|
||||
if let overlayVC {
|
||||
overlayVC.view.isHidden = activityIndicator != nil
|
||||
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(overlayVC.view)
|
||||
NSLayoutConstraint.activate([
|
||||
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
topControlsView = UIView()
|
||||
topControlsView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(topControlsView)
|
||||
|
||||
var shareConfig = UIButton.Configuration.gray()
|
||||
shareConfig.cornerStyle = .dynamic
|
||||
shareConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
|
||||
shareConfig.baseForegroundColor = .white
|
||||
shareConfig.image = UIImage(systemName: "square.and.arrow.up")
|
||||
shareConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
|
||||
shareButton = UIButton(configuration: shareConfig)
|
||||
shareButton.addTarget(self, action: #selector(shareButtonPressed), for: .touchUpInside)
|
||||
shareButton.isPointerInteractionEnabled = true
|
||||
shareButton.pointerStyleProvider = { button, effect, shape in
|
||||
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
|
||||
}
|
||||
shareButton.preferredBehavioralStyle = .pad
|
||||
shareButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
updateShareButton()
|
||||
topControlsView.addSubview(shareButton)
|
||||
|
||||
var closeConfig = UIButton.Configuration.gray()
|
||||
closeConfig.cornerStyle = .dynamic
|
||||
closeConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
|
||||
closeConfig.baseForegroundColor = .white
|
||||
closeConfig.image = UIImage(systemName: "xmark")
|
||||
closeConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
|
||||
let closeButton = UIButton(configuration: closeConfig)
|
||||
closeButton.addTarget(self, action: #selector(closeButtonPressed), for: .touchUpInside)
|
||||
closeButton.isPointerInteractionEnabled = true
|
||||
closeButton.pointerStyleProvider = { button, effect, shape in
|
||||
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
|
||||
}
|
||||
closeButton.preferredBehavioralStyle = .pad
|
||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
topControlsView.addSubview(closeButton)
|
||||
|
||||
bottomControlsView = UIStackView()
|
||||
bottomControlsView.translatesAutoresizingMaskIntoConstraints = false
|
||||
bottomControlsView.axis = .vertical
|
||||
bottomControlsView.alignment = .fill
|
||||
bottomControlsView.backgroundColor = .black.withAlphaComponent(0.5)
|
||||
view.addSubview(bottomControlsView)
|
||||
|
||||
if let controlsAccessory = content.bottomControlsAccessoryViewController {
|
||||
addChild(controlsAccessory)
|
||||
bottomControlsView.addArrangedSubview(controlsAccessory.view)
|
||||
controlsAccessory.didMove(toParent: self)
|
||||
|
||||
// Make sure the controls accessory is within the safe area.
|
||||
let spacer = UIView()
|
||||
bottomControlsView.addArrangedSubview(spacer)
|
||||
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
|
||||
spacerTopConstraint.priority = .init(999)
|
||||
spacerTopConstraint.isActive = true
|
||||
}
|
||||
|
||||
captionTextView = UITextView()
|
||||
captionTextView.backgroundColor = .clear
|
||||
captionTextView.textColor = .white
|
||||
captionTextView.isEditable = false
|
||||
captionTextView.isSelectable = true
|
||||
captionTextView.font = .preferredFont(forTextStyle: .body)
|
||||
captionTextView.adjustsFontForContentSizeCategory = true
|
||||
captionTextView.alwaysBounceVertical = true
|
||||
updateCaptionTextView()
|
||||
bottomControlsView.addArrangedSubview(captionTextView)
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
|
||||
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
|
||||
#else
|
||||
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
|
||||
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
|
||||
#endif
|
||||
closeButtonTrailingConstraint = topControlsView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor)
|
||||
shareButtonLeadingConstraint = shareButton.leadingAnchor.constraint(equalTo: topControlsView.leadingAnchor)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
topControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
topControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
topControlsView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
|
||||
shareButtonLeadingConstraint,
|
||||
shareButtonTopConstraint,
|
||||
shareButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
|
||||
shareButton.widthAnchor.constraint(equalTo: shareButton.heightAnchor),
|
||||
|
||||
closeButtonTrailingConstraint,
|
||||
closeButtonTopConstraint,
|
||||
closeButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
|
||||
closeButton.widthAnchor.constraint(equalTo: closeButton.heightAnchor),
|
||||
|
||||
bottomControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
bottomControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
bottomControlsView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
captionTextView.heightAnchor.constraint(equalToConstant: 150),
|
||||
])
|
||||
|
||||
updateTopControlsInsets()
|
||||
|
||||
singleTap = UITapGestureRecognizer(target: self, action: #selector(viewPressed))
|
||||
singleTap.delegate = self
|
||||
doubleTap = UITapGestureRecognizer(target: self, action: #selector(viewDoublePressed))
|
||||
doubleTap.delegate = self
|
||||
doubleTap.numberOfTapsRequired = 2
|
||||
// This is needed to prevent a delay between tapping a button on and the action firing on Catalyst and Designed for iPad
|
||||
doubleTap.delaysTouchesEnded = false
|
||||
// this requirement is needed to make sure the double tap is ever recognized
|
||||
singleTap.require(toFail: doubleTap)
|
||||
view.addGestureRecognizer(singleTap)
|
||||
view.addGestureRecognizer(doubleTap)
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
super.viewSafeAreaInsetsDidChange()
|
||||
|
||||
updateZoomScale(resetZoom: false)
|
||||
// Ensure the transform is correct if the controls are hidden
|
||||
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
||||
|
||||
updateTopControlsInsets()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
// When the scrollView size changes, make sure the zoom scale is up-to-date since it depends on the scrollView's bounds.
|
||||
// This might also fix an issue on macOS (Designed for iPad) where the content isn't placed correctly. See #446
|
||||
if scrollViewSizeForLastZoomScaleUpdate != scrollView.bounds.size {
|
||||
scrollViewSizeForLastZoomScaleUpdate = scrollView.bounds.size
|
||||
updateZoomScale(resetZoom: true)
|
||||
}
|
||||
centerContent()
|
||||
// Ensure the transform is correct if the controls are hidden and their size changed.
|
||||
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if controlsVisible && !captionTextView.isHidden {
|
||||
captionTextView.flashScrollIndicators()
|
||||
}
|
||||
}
|
||||
|
||||
func takeContent() -> GalleryContentViewController {
|
||||
content.willMove(toParent: nil)
|
||||
content.removeFromParent()
|
||||
content.view.removeFromSuperview()
|
||||
return content
|
||||
}
|
||||
|
||||
func addContent() {
|
||||
content.loadViewIfNeeded()
|
||||
|
||||
content.setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
||||
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
if content.parent != self {
|
||||
addChild(content)
|
||||
content.didMove(toParent: self)
|
||||
}
|
||||
if scrollAndZoomEnabled {
|
||||
scrollView.addSubview(content.view)
|
||||
contentViewLeadingConstraint = content.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
|
||||
contentViewLeadingConstraint!.isActive = true
|
||||
contentViewTopConstraint = content.view.topAnchor.constraint(equalTo: scrollView.topAnchor)
|
||||
contentViewTopConstraint!.isActive = true
|
||||
updateZoomScale(resetZoom: true)
|
||||
} else {
|
||||
// If the content was previously added, deactivate the old constraints.
|
||||
contentViewLeadingConstraint?.isActive = false
|
||||
contentViewTopConstraint?.isActive = false
|
||||
|
||||
view.addSubview(content.view)
|
||||
NSLayoutConstraint.activate([
|
||||
content.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
content.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
content.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
content.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
if let overlayVC {
|
||||
NSLayoutConstraint.activate([
|
||||
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
content.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
controlsVisible = visible
|
||||
|
||||
guard let topControlsView,
|
||||
let bottomControlsView else {
|
||||
return
|
||||
}
|
||||
|
||||
func updateControlsViews() {
|
||||
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
|
||||
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
|
||||
content.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
|
||||
}
|
||||
if animated {
|
||||
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
|
||||
animator.addAnimations(updateControlsViews)
|
||||
animator.startAnimation()
|
||||
} else {
|
||||
updateControlsViews()
|
||||
}
|
||||
|
||||
setNeedsUpdateOfHomeIndicatorAutoHidden()
|
||||
}
|
||||
|
||||
func updateZoomScale(resetZoom: Bool) {
|
||||
scrollView.contentSize = content.contentSize
|
||||
|
||||
guard scrollAndZoomEnabled else {
|
||||
scrollView.maximumZoomScale = 1
|
||||
scrollView.minimumZoomScale = 1
|
||||
scrollView.zoomScale = 1
|
||||
return
|
||||
}
|
||||
|
||||
guard content.contentSize.width > 0 && content.contentSize.height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let heightScale = view.bounds.height / content.contentSize.height
|
||||
let widthScale = view.bounds.width / content.contentSize.width
|
||||
let minScale = min(widthScale, heightScale)
|
||||
let maxScale = minScale >= 1 ? minScale + 2 : 2
|
||||
|
||||
scrollView.minimumZoomScale = minScale
|
||||
scrollView.maximumZoomScale = maxScale
|
||||
if resetZoom {
|
||||
scrollView.zoomScale = minScale
|
||||
} else {
|
||||
scrollView.zoomScale = max(minScale, min(maxScale, scrollView.zoomScale))
|
||||
}
|
||||
|
||||
centerContent()
|
||||
}
|
||||
|
||||
private func centerContent() {
|
||||
guard scrollAndZoomEnabled else {
|
||||
return
|
||||
}
|
||||
|
||||
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
|
||||
// which means it's already been scaled by the zoom factor.
|
||||
let yOffset = max(0, (view.bounds.height - content.view.frame.height) / 2)
|
||||
contentViewTopConstraint!.constant = yOffset
|
||||
|
||||
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
|
||||
contentViewLeadingConstraint!.constant = xOffset
|
||||
}
|
||||
|
||||
private func updateShareButton() {
|
||||
shareButton.isEnabled = !content.activityItemsForSharing.isEmpty
|
||||
}
|
||||
|
||||
private func updateCaptionTextView() {
|
||||
guard let caption = content.caption,
|
||||
!caption.isEmpty else {
|
||||
captionTextView.isHidden = true
|
||||
return
|
||||
}
|
||||
captionTextView.text = caption
|
||||
}
|
||||
|
||||
private func updateTopControlsInsets() {
|
||||
let notchedDeviceTopInsets: [CGFloat] = [
|
||||
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
|
||||
48, // iPhone XR, 11
|
||||
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
|
||||
50, // iPhone 12 mini, 13 mini
|
||||
]
|
||||
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
||||
// the notch width is not the same for the iPhones 13,
|
||||
// but what we actually want is the same offset from the edges
|
||||
// since the corner radius didn't change
|
||||
let notchWidth: CGFloat = 210
|
||||
let earWidth = (view.bounds.width - notchWidth) / 2
|
||||
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
|
||||
shareButtonLeadingConstraint.constant = offset
|
||||
closeButtonTrailingConstraint.constant = offset
|
||||
} else if view.safeAreaInsets.top == 0 {
|
||||
// square corner devices
|
||||
shareButtonLeadingConstraint.constant = 8
|
||||
shareButtonTopConstraint.constant = 8
|
||||
closeButtonTrailingConstraint.constant = 8
|
||||
closeButtonTopConstraint.constant = 8
|
||||
} else {
|
||||
// dynamic island devices
|
||||
shareButtonLeadingConstraint.constant = 24
|
||||
shareButtonTopConstraint.constant = 24
|
||||
closeButtonTrailingConstraint.constant = 24
|
||||
closeButtonTopConstraint.constant = 24
|
||||
}
|
||||
}
|
||||
|
||||
private func zoomRectFor(scale: CGFloat, center: CGPoint) -> CGRect {
|
||||
var zoomRect = CGRect.zero
|
||||
zoomRect.size.width = content.view.frame.width / scale
|
||||
zoomRect.size.height = content.view.frame.height / scale
|
||||
let newCenter = scrollView.convert(center, to: content.view)
|
||||
zoomRect.origin.x = newCenter.x - (zoomRect.width / 2)
|
||||
zoomRect.origin.y = newCenter.y - (zoomRect.height / 2)
|
||||
return zoomRect
|
||||
}
|
||||
|
||||
private func animateZoomOut() {
|
||||
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
|
||||
animator.addAnimations {
|
||||
self.scrollView.zoomScale = self.scrollView.minimumZoomScale
|
||||
self.scrollView.layoutIfNeeded()
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
@objc private func viewPressed() {
|
||||
if scrollAndZoomEnabled,
|
||||
scrollView.zoomScale > scrollView.minimumZoomScale {
|
||||
animateZoomOut()
|
||||
} else {
|
||||
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func viewDoublePressed(_ recognizer: UITapGestureRecognizer) {
|
||||
guard scrollAndZoomEnabled else {
|
||||
return
|
||||
}
|
||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||
let point = recognizer.location(in: recognizer.view)
|
||||
let scale = min(
|
||||
max(
|
||||
scrollView.bounds.width / content.contentSize.width,
|
||||
scrollView.bounds.height / content.contentSize.height,
|
||||
scrollView.zoomScale + 0.75
|
||||
),
|
||||
scrollView.maximumZoomScale
|
||||
)
|
||||
let rect = zoomRectFor(scale: scale, center: point)
|
||||
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
|
||||
animator.addAnimations {
|
||||
self.scrollView.zoom(to: rect, animated: false)
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
animator.startAnimation()
|
||||
} else {
|
||||
animateZoomOut()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func closeButtonPressed() {
|
||||
delegate?.galleryItemClose(self)
|
||||
}
|
||||
|
||||
@objc private func shareButtonPressed() {
|
||||
let items = content.activityItemsForSharing
|
||||
guard !items.isEmpty else {
|
||||
return
|
||||
}
|
||||
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: delegate?.galleryItemApplicationActivities(self))
|
||||
activityVC.popoverPresentationController?.sourceView = shareButton
|
||||
present(activityVC, animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension GalleryItemViewController: GalleryContentViewControllerContainer {
|
||||
var galleryControlsVisible: Bool {
|
||||
controlsVisible
|
||||
}
|
||||
|
||||
func setGalleryContentLoading(_ loading: Bool) {
|
||||
if loading {
|
||||
overlayVC?.view.isHidden = true
|
||||
if activityIndicator == nil {
|
||||
let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||
self.activityIndicator = activityIndicator
|
||||
activityIndicator.startAnimating()
|
||||
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(activityIndicator)
|
||||
NSLayoutConstraint.activate([
|
||||
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
])
|
||||
}
|
||||
} else {
|
||||
if let activityIndicator {
|
||||
// If we're in the middle of the presentation animation,
|
||||
// wait until it finishes to hide the loading indicator.
|
||||
// Since the updated content frame won't affect the animation,
|
||||
// make sure the loading indicator remains visible.
|
||||
if let delegate,
|
||||
delegate.isGalleryBeingPresented() {
|
||||
delegate.addPresentationAnimationCompletion { [unowned self] in
|
||||
self.setGalleryContentLoading(false)
|
||||
}
|
||||
} else {
|
||||
activityIndicator.removeFromSuperview()
|
||||
self.activityIndicator = nil
|
||||
self.overlayVC?.view.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func galleryContentChanged() {
|
||||
updateZoomScale(resetZoom: true)
|
||||
updateShareButton()
|
||||
updateCaptionTextView()
|
||||
}
|
||||
|
||||
func disableGalleryScrollAndZoom() {
|
||||
scrollAndZoomEnabled = false
|
||||
updateZoomScale(resetZoom: true)
|
||||
scrollView.isScrollEnabled = false
|
||||
// Make sure the content is re-added with the correct constraints
|
||||
if content.parent == self {
|
||||
addContent()
|
||||
}
|
||||
}
|
||||
|
||||
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
|
||||
setControlsVisible(visible, animated: animated, dueToUserInteraction: false)
|
||||
}
|
||||
}
|
||||
|
||||
extension GalleryItemViewController: UIScrollViewDelegate {
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
if scrollAndZoomEnabled {
|
||||
return content.view
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||
setControlsVisible(true, animated: true, dueToUserInteraction: true)
|
||||
} else {
|
||||
setControlsVisible(false, animated: true, dueToUserInteraction: true)
|
||||
}
|
||||
|
||||
centerContent()
|
||||
scrollView.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
extension GalleryItemViewController: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer == singleTap {
|
||||
let loc = gestureRecognizer.location(in: view)
|
||||
return !topControlsView.frame.contains(loc) && !bottomControlsView.frame.contains(loc)
|
||||
} else if gestureRecognizer == doubleTap {
|
||||
let loc = gestureRecognizer.location(in: content.view)
|
||||
return content.view.bounds.contains(loc)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,221 @@
|
|||
//
|
||||
// GalleryPresentationAnimationController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 12/28/23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
private let sourceView: UIView
|
||||
|
||||
init(sourceView: UIView) {
|
||||
self.sourceView = sourceView
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.4
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let itemViewController = to.currentItemViewController
|
||||
|
||||
if itemViewController.content.presentationAnimation == .fade || UIAccessibility.prefersCrossFadeTransitions {
|
||||
animateCrossFadeTransition(using: transitionContext)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to effectively "fade out" anything that's on top of the source view.
|
||||
// The 0.1 duration makes this happen faster than the rest of the animation,
|
||||
// and so less noticeable.
|
||||
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
|
||||
nil
|
||||
} else {
|
||||
sourceView.snapshotView(afterScreenUpdates: false)
|
||||
}
|
||||
if let sourceSnapshot {
|
||||
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
|
||||
snapshotContainer.addSubview(sourceSnapshot)
|
||||
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
|
||||
sourceSnapshot.frame = sourceFrameInShapshotContainer
|
||||
sourceSnapshot.transform = sourceView.transform
|
||||
sourceSnapshot.layer.opacity = 0
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
sourceSnapshot.layer.opacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
let container = transitionContext.containerView
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
|
||||
container.layoutIfNeeded()
|
||||
// Make sure the zoom scale is updated before getting the content view frame, since it needs to take into account the correct transform.
|
||||
itemViewController.updateZoomScale(resetZoom: true)
|
||||
|
||||
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
|
||||
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
|
||||
|
||||
// Use a transformation to make the actual source view appear to move into the destination frame.
|
||||
// Doing this while having the content view fade-in papers over the z-index change when
|
||||
// there was something overlapping the source view.
|
||||
let origSourceTransform = sourceView.transform
|
||||
let sourceToDestTransform: CGAffineTransform?
|
||||
if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 {
|
||||
// Scale evenly in both dimensions, to prevent the source view appearing to stretch/distort during the animation.
|
||||
let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height)
|
||||
sourceToDestTransform = origSourceTransform
|
||||
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
|
||||
.scaledBy(x: scale, y: scale)
|
||||
} else {
|
||||
sourceToDestTransform = nil
|
||||
}
|
||||
|
||||
// Grab these before taking the content out and changing the transform.
|
||||
let origContentTransform = itemViewController.content.view.transform
|
||||
let origContentFrame = itemViewController.content.view.frame
|
||||
|
||||
// The content container provides the clipping for the content view,
|
||||
// which, in case the source/dest aspect ratios don't match, makes
|
||||
// it look like the content is expanding out from the source rect.
|
||||
let contentContainer = UIView()
|
||||
contentContainer.layer.masksToBounds = true
|
||||
container.insertSubview(contentContainer, belowSubview: to.view)
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.transform = .identity
|
||||
// The fade-in makes the aspect ratio handling look a little bit worse,
|
||||
// but papers over the z-index change and potential corner radius change.
|
||||
content.view.layer.opacity = 0
|
||||
contentContainer.addSubview(content.view)
|
||||
|
||||
// Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
|
||||
let dimmingView = UIView()
|
||||
dimmingView.backgroundColor = .black
|
||||
dimmingView.frame = container.bounds
|
||||
dimmingView.layer.opacity = 0
|
||||
container.insertSubview(dimmingView, belowSubview: contentContainer)
|
||||
|
||||
to.view.backgroundColor = nil
|
||||
to.view.layer.opacity = 0
|
||||
|
||||
contentContainer.frame = sourceFrameInContainer
|
||||
|
||||
let sourceAspectRatio: CGFloat = if sourceFrameInContainer.height > 0 {
|
||||
sourceFrameInContainer.width / sourceFrameInContainer.height
|
||||
} else {
|
||||
0
|
||||
}
|
||||
let destAspectRatio: CGFloat = if destFrameInContainer.height > 0 {
|
||||
destFrameInContainer.width / destFrameInContainer.height
|
||||
} else {
|
||||
0
|
||||
}
|
||||
let sourceSizeWithDestAspectRatioCenteredInContentContainer: CGRect
|
||||
if 0.001 < abs(sourceAspectRatio - destAspectRatio) {
|
||||
// asepct ratios are effectively equal
|
||||
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(origin: .zero, size: sourceFrameInContainer.size)
|
||||
} else if sourceAspectRatio < destAspectRatio {
|
||||
// source aspect ratio is narrow/taller than dest
|
||||
let width = sourceFrameInContainer.height * destAspectRatio
|
||||
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
|
||||
x: -(width - sourceFrameInContainer.width) / 2,
|
||||
y: 0,
|
||||
width: width,
|
||||
height: sourceFrameInContainer.height
|
||||
)
|
||||
} else {
|
||||
// source aspect ratio is wider/shorter than dest
|
||||
let height = sourceFrameInContainer.width / destAspectRatio
|
||||
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
|
||||
x: 0,
|
||||
y: -(height - sourceFrameInContainer.height) / 2,
|
||||
width: sourceFrameInContainer.width,
|
||||
height: height
|
||||
)
|
||||
}
|
||||
content.view.frame = sourceSizeWithDestAspectRatioCenteredInContentContainer
|
||||
|
||||
container.layoutIfNeeded()
|
||||
|
||||
// This needs to take place after the layout, so that the transform is correct.
|
||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||
|
||||
let duration = self.transitionDuration(using: transitionContext)
|
||||
// less bounce on bigger screens
|
||||
let spring = if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
// roughly equivalent to duration: 0.35, bounce: 0.2
|
||||
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 28, initialVelocity: .zero)
|
||||
} else {
|
||||
// roughly equivalent to duration: 0.35, bounce: 0.3
|
||||
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
|
||||
}
|
||||
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
|
||||
|
||||
animator.addAnimations {
|
||||
dimmingView.layer.opacity = 1
|
||||
|
||||
to.view.layer.opacity = 1
|
||||
|
||||
contentContainer.frame = destFrameInContainer
|
||||
content.view.frame = contentContainer.bounds
|
||||
content.view.layer.opacity = 1
|
||||
|
||||
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
|
||||
|
||||
if let sourceToDestTransform {
|
||||
sourceSnapshot?.transform = sourceToDestTransform
|
||||
self.sourceView.transform = sourceToDestTransform
|
||||
}
|
||||
}
|
||||
|
||||
animator.addCompletion { _ in
|
||||
sourceSnapshot?.removeFromSuperview()
|
||||
self.sourceView.layer.opacity = 1
|
||||
if sourceToDestTransform != nil {
|
||||
self.sourceView.transform = origSourceTransform
|
||||
}
|
||||
|
||||
contentContainer.removeFromSuperview()
|
||||
dimmingView.removeFromSuperview()
|
||||
|
||||
to.view.backgroundColor = .black
|
||||
|
||||
// Reset the properties we changed before re-adding the content to the scroll view.
|
||||
// (I would expect UIScrollView to effectively do this itself, but w/e.)
|
||||
content.view.transform = origContentTransform
|
||||
content.view.frame = origContentFrame
|
||||
|
||||
itemViewController.addContent()
|
||||
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
to.view.alpha = 0
|
||||
to.view.frame = transitionContext.containerView.bounds
|
||||
transitionContext.containerView.addSubview(to.view)
|
||||
|
||||
let duration = transitionDuration(using: transitionContext)
|
||||
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
|
||||
animator.addAnimations {
|
||||
to.view.alpha = 1
|
||||
}
|
||||
animator.addCompletion { _ in
|
||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,189 @@
|
|||
//
|
||||
// GalleryViewController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 12/28/23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class GalleryViewController: UIPageViewController {
|
||||
|
||||
let galleryDataSource: GalleryDataSource
|
||||
let initialItemIndex: Int
|
||||
private let _itemsCount: Int
|
||||
private var itemsCount: Int {
|
||||
get {
|
||||
precondition(_itemsCount == galleryDataSource.galleryItemsCount(), "GalleryDataSource item count cannot change")
|
||||
return _itemsCount
|
||||
}
|
||||
}
|
||||
|
||||
var currentItemViewController: GalleryItemViewController {
|
||||
viewControllers![0] as! GalleryItemViewController
|
||||
}
|
||||
|
||||
private var dismissInteraction: GalleryDismissInteraction!
|
||||
private var presentationAnimationCompletionHandlers: [() -> Void] = []
|
||||
|
||||
override public var prefersStatusBarHidden: Bool {
|
||||
true
|
||||
}
|
||||
override public var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
||||
.none
|
||||
}
|
||||
override public var childForHomeIndicatorAutoHidden: UIViewController? {
|
||||
currentItemViewController
|
||||
}
|
||||
|
||||
public init(dataSource: GalleryDataSource, initialItemIndex: Int) {
|
||||
self.galleryDataSource = dataSource
|
||||
self.initialItemIndex = initialItemIndex
|
||||
self._itemsCount = dataSource.galleryItemsCount()
|
||||
precondition(initialItemIndex >= 0 && initialItemIndex < _itemsCount, "initialItemIndex is out of bounds")
|
||||
|
||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [
|
||||
.interPageSpacing: 50
|
||||
])
|
||||
|
||||
modalPresentationStyle = .fullScreen
|
||||
transitioningDelegate = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
dismissInteraction = GalleryDismissInteraction(viewController: self)
|
||||
|
||||
view.backgroundColor = .black
|
||||
overrideUserInterfaceStyle = .dark
|
||||
|
||||
dataSource = self
|
||||
delegate = self
|
||||
|
||||
setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false)
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if animated {
|
||||
// Wait until the transition is no longer in-progress, otherwise things will just get deferred again.
|
||||
DispatchQueue.main.async {
|
||||
self.presentationAnimationCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
if isBeingDismissed {
|
||||
currentItemViewController.content.galleryContentWillDisappear()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeItemVC(index: Int) -> GalleryItemViewController {
|
||||
let content = galleryDataSource.galleryContentViewController(forItemAt: index)
|
||||
return GalleryItemViewController(delegate: self, itemIndex: index, content: content)
|
||||
}
|
||||
|
||||
func presentationAnimationCompleted() {
|
||||
for block in presentationAnimationCompletionHandlers {
|
||||
block()
|
||||
}
|
||||
currentItemViewController.content.galleryContentDidAppear()
|
||||
}
|
||||
}
|
||||
|
||||
extension GalleryViewController: UIPageViewControllerDataSource {
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
guard let viewController = viewController as? GalleryItemViewController else {
|
||||
preconditionFailure("VC must be GalleryItemViewController")
|
||||
}
|
||||
guard viewController.itemIndex > 0 else {
|
||||
return nil
|
||||
}
|
||||
return makeItemVC(index: viewController.itemIndex - 1)
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
||||
guard let viewController = viewController as? GalleryItemViewController else {
|
||||
preconditionFailure("VC must be GalleryItemViewController")
|
||||
}
|
||||
guard viewController.itemIndex < itemsCount - 1 else {
|
||||
return nil
|
||||
}
|
||||
return makeItemVC(index: viewController.itemIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
extension GalleryViewController: UIPageViewControllerDelegate {
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
currentItemViewController.content.galleryContentWillDisappear()
|
||||
let new = pendingViewControllers[0] as! GalleryItemViewController
|
||||
new.setControlsVisible(currentItemViewController.controlsVisible, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
currentItemViewController.content.galleryContentDidAppear()
|
||||
}
|
||||
}
|
||||
|
||||
extension GalleryViewController: GalleryItemViewControllerDelegate {
|
||||
func isGalleryBeingPresented() -> Bool {
|
||||
isBeingPresented
|
||||
}
|
||||
|
||||
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
|
||||
presentationAnimationCompletionHandlers.append(block)
|
||||
}
|
||||
|
||||
func galleryItemClose(_ item: GalleryItemViewController) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]? {
|
||||
galleryDataSource.galleryApplicationActivities(forItemAt: item.itemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
#if os(visionOS)
|
||||
return nil
|
||||
#else
|
||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
|
||||
return GalleryPresentationAnimationController(sourceView: sourceView)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
#if os(visionOS)
|
||||
return nil
|
||||
#else
|
||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
|
||||
let translation: CGPoint?
|
||||
let velocity: CGPoint?
|
||||
if let dismissInteraction,
|
||||
dismissInteraction.isActive {
|
||||
translation = dismissInteraction.dismissTranslation
|
||||
velocity = dismissInteraction.dismissVelocity
|
||||
} else {
|
||||
translation = nil
|
||||
velocity = nil
|
||||
}
|
||||
return GalleryDismissAnimationController(sourceView: sourceView, interactiveTranslation: translation, interactiveVelocity: velocity)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
//
|
||||
// UIView+Utilities.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 11/24/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIView {
|
||||
|
||||
var ancestorForInsertingSnapshot: UIView {
|
||||
var view = self
|
||||
while let superview = view.superview {
|
||||
if superview.layer.masksToBounds {
|
||||
return superview
|
||||
} else if superview is UIScrollView {
|
||||
return self
|
||||
} else {
|
||||
view = superview
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import XCTest
|
||||
@testable import GalleryVC
|
||||
|
||||
final class GalleryVCTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// XCTest Documentation
|
||||
// https://developer.apple.com/documentation/xctest
|
||||
|
||||
// Defining Test Cases and Test Methods
|
||||
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
|
||||
}
|
||||
}
|
|
@ -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,23 @@
|
|||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-url",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/karwa/swift-url.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
// swift-tools-version: 6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "InstanceFeatures",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "InstanceFeatures",
|
||||
targets: ["InstanceFeatures"]),
|
||||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
.package(path: "../Pachyderm"),
|
||||
],
|
||||
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: "InstanceFeatures",
|
||||
dependencies: ["Pachyderm"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
.testTarget(
|
||||
name: "InstanceFeaturesTests",
|
||||
dependencies: ["InstanceFeatures"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
# InstanceFeatures
|
||||
|
||||
A description of this package.
|
|
@ -0,0 +1,397 @@
|
|||
//
|
||||
// InstanceFeatures.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/23/22.
|
||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Pachyderm
|
||||
|
||||
public final class InstanceFeatures: ObservableObject {
|
||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; (pleroma|akkoma) (.*)\\)", options: .caseInsensitive)
|
||||
|
||||
private let _featuresUpdated = PassthroughSubject<Void, Never>()
|
||||
public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
|
||||
|
||||
@Published @_spi(InstanceType) public private(set) var instanceType: InstanceType = .mastodon(.vanilla, nil)
|
||||
@Published public private(set) var maxStatusChars = 500
|
||||
@Published public private(set) var charsReservedPerURL = 23
|
||||
@Published public private(set) var maxPollOptionChars: Int?
|
||||
@Published public private(set) var maxPollOptionsCount: Int?
|
||||
@Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration?
|
||||
@Published public private(set) var translation: Bool = false
|
||||
|
||||
public var localOnlyPosts: Bool {
|
||||
switch instanceType {
|
||||
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
|
||||
return true
|
||||
case .pleroma(.akkoma(_)):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance types that use a separate visibility to indicate local-only posts.
|
||||
public var localOnlyPostsVisibility: Bool {
|
||||
if case .pleroma(.akkoma(_)) = instanceType {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var mastodonAttachmentRestrictions: Bool {
|
||||
instanceType.isMastodon
|
||||
}
|
||||
|
||||
public var pollsAndAttachments: Bool {
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public var boostToOriginalAudience: Bool {
|
||||
instanceType.isPleroma || instanceType.isMastodon
|
||||
}
|
||||
|
||||
public var profilePinnedStatuses: Bool {
|
||||
switch instanceType {
|
||||
case .pixelfed:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public var trends: Bool {
|
||||
instanceType.isMastodon
|
||||
}
|
||||
|
||||
public var profileSuggestions: Bool {
|
||||
instanceType.isMastodon && hasMastodonVersion(3, 4, 0)
|
||||
}
|
||||
|
||||
public var trendingStatusesAndLinks: Bool {
|
||||
instanceType.isMastodon && hasMastodonVersion(3, 5, 0)
|
||||
}
|
||||
|
||||
public var reblogVisibility: Bool {
|
||||
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|
||||
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
|
||||
}
|
||||
|
||||
public var probablySupportsMarkdown: Bool {
|
||||
switch instanceType {
|
||||
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .firefish(_):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var needsLocalOnlyEmojiHack: Bool {
|
||||
if case .mastodon(.glitch, _) = instanceType {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var needsWideColorGamutHack: Bool {
|
||||
if case .mastodon(_, let version) = instanceType {
|
||||
return version < Version(4, 0, 0)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public var canFollowHashtags: Bool {
|
||||
if case .mastodon(_, let version) = instanceType {
|
||||
return version >= Version(4, 0, 0)
|
||||
} else if case .pleroma(.akkoma(let version)) = instanceType {
|
||||
return version >= Version(3, 4, 0)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var filtersV2: Bool {
|
||||
hasMastodonVersion(4, 0, 0)
|
||||
}
|
||||
|
||||
public var notificationsAllowedTypes: Bool {
|
||||
hasMastodonVersion(3, 5, 0)
|
||||
}
|
||||
|
||||
public var pollVotersCount: Bool {
|
||||
instanceType.isMastodon
|
||||
}
|
||||
|
||||
public var createStatusWithLanguage: Bool {
|
||||
instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil))
|
||||
}
|
||||
|
||||
public var editStatuses: Bool {
|
||||
switch instanceType {
|
||||
case .mastodon(_, let v) where v >= Version(3, 5, 0):
|
||||
return true
|
||||
case .pleroma(.vanilla(let v)) where v >= Version(2, 5, 0):
|
||||
return true
|
||||
case .pleroma(.akkoma(_)):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var statusEditNotifications: Bool {
|
||||
// pleroma doesn't seem to support 'update' type notifications, even though it supports edits
|
||||
hasMastodonVersion(3, 5, 0)
|
||||
}
|
||||
|
||||
public var statusNotifications: Bool {
|
||||
// pleroma doesn't support notifications for new posts from an account
|
||||
hasMastodonVersion(3, 3, 0)
|
||||
}
|
||||
|
||||
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public var composeDirectStatuses: Bool {
|
||||
if case .pixelfed = instanceType {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public var searchOperators: Bool {
|
||||
hasMastodonVersion(4, 2, 0)
|
||||
}
|
||||
|
||||
public var hasServerPreferences: Bool {
|
||||
hasMastodonVersion(2, 8, 0)
|
||||
}
|
||||
|
||||
public var listRepliesPolicy: Bool {
|
||||
hasMastodonVersion(3, 3, 0)
|
||||
}
|
||||
|
||||
public var exclusiveLists: Bool {
|
||||
hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil))
|
||||
}
|
||||
|
||||
public var pushNotificationTypeStatus: Bool {
|
||||
hasMastodonVersion(3, 3, 0)
|
||||
}
|
||||
|
||||
public var pushNotificationTypeFollowRequest: Bool {
|
||||
hasMastodonVersion(3, 1, 0)
|
||||
}
|
||||
|
||||
public var pushNotificationTypeUpdate: Bool {
|
||||
hasMastodonVersion(3, 5, 0)
|
||||
}
|
||||
|
||||
public var pushNotificationPolicy: Bool {
|
||||
hasMastodonVersion(3, 5, 0)
|
||||
}
|
||||
|
||||
public var pushNotificationPolicyMissingFromResponse: Bool {
|
||||
switch instanceType {
|
||||
case .mastodon(_, let version):
|
||||
return version >= Version(3, 5, 0) && version < Version(4, 1, 0)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var instanceAnnouncements: Bool {
|
||||
hasMastodonVersion(3, 1, 0)
|
||||
}
|
||||
|
||||
public var emojiReactionNotifications: Bool {
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public var muteNotifications: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public var blockDomains: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public var hideReblogs: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
public func update(instance: InstanceInfo, nodeInfo: NodeInfo?) {
|
||||
let ver = instance.version.lowercased()
|
||||
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
|
||||
if ver.contains("glitch") {
|
||||
instanceType = .mastodon(.glitch, Version(string: ver))
|
||||
} else if nodeInfo?.software.name == "mastodon" {
|
||||
instanceType = .mastodon(.vanilla, Version(string: ver))
|
||||
} else if nodeInfo?.software.name == "hometown" {
|
||||
var mastoVersion: Version?
|
||||
var hometownVersion: Version?
|
||||
let parts = ver.split(separator: "+")
|
||||
if parts.count == 2,
|
||||
let first = Version(string: String(parts[0])) {
|
||||
if first > Version(1, 0, 8) {
|
||||
// like 3.5.5+hometown-1.0.9
|
||||
mastoVersion = first
|
||||
if parts[1].starts(with: "hometown-") {
|
||||
hometownVersion = Version(string: String(parts[1][parts[1].index(parts[1].startIndex, offsetBy: "hometown-".count + 1)...]))
|
||||
}
|
||||
} else {
|
||||
// like "1.0.6+3.5.2"
|
||||
hometownVersion = first
|
||||
mastoVersion = Version(string: String(parts[1]))
|
||||
}
|
||||
} else {
|
||||
mastoVersion = Version(string: ver)
|
||||
}
|
||||
instanceType = .mastodon(.hometown(hometownVersion), mastoVersion)
|
||||
} else if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
|
||||
var pleromaVersion: Version?
|
||||
let type = (ver as NSString).substring(with: match.range(at: 1))
|
||||
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 2)))
|
||||
if type == "akkoma" {
|
||||
instanceType = .pleroma(.akkoma(pleromaVersion))
|
||||
} else {
|
||||
instanceType = .pleroma(.vanilla(pleromaVersion))
|
||||
}
|
||||
} else if ver.contains("pixelfed") {
|
||||
instanceType = .pixelfed
|
||||
} else if nodeInfo?.software.name == "gotosocial" {
|
||||
instanceType = .gotosocial
|
||||
} else if ver.contains("firefish") || ver.contains("iceshrimp") || ver.contains("calckey") {
|
||||
instanceType = .firefish(nodeInfo?.software.version)
|
||||
} else {
|
||||
instanceType = .mastodon(.vanilla, Version(string: ver))
|
||||
}
|
||||
|
||||
maxStatusChars = instance.maxStatusCharacters ?? 500
|
||||
charsReservedPerURL = instance.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
|
||||
if let pollsConfig = instance.pollsConfiguration {
|
||||
maxPollOptionChars = pollsConfig.maxCharactersPerOption
|
||||
maxPollOptionsCount = pollsConfig.maxOptions
|
||||
}
|
||||
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
|
||||
translation = instance.translation
|
||||
|
||||
_featuresUpdated.send()
|
||||
}
|
||||
|
||||
public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||
if case .mastodon(_, let version) = instanceType {
|
||||
return version >= Version(major, minor, patch)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||
switch instanceType {
|
||||
case .pleroma(.vanilla(let version)), .pleroma(.akkoma(let version)):
|
||||
return version >= Version(major, minor, patch)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceFeatures {
|
||||
@_spi(InstanceType) public enum InstanceType {
|
||||
case mastodon(MastodonType, Version?)
|
||||
case pleroma(PleromaType)
|
||||
case pixelfed
|
||||
case gotosocial
|
||||
case firefish(String?)
|
||||
|
||||
var isMastodon: Bool {
|
||||
if case .mastodon(_, _) = self {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isMastodon(_ subtype: MastodonType) -> Bool {
|
||||
if case .mastodon(let t, _) = self,
|
||||
t.equalsIgnoreVersion(subtype) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isPleroma: Bool {
|
||||
if case .pleroma(_) = self {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPleroma(_ subtype: PleromaType) -> Bool {
|
||||
if case .pleroma(let t) = self,
|
||||
t.equalsIgnoreVersion(subtype) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isPixelfed: Bool {
|
||||
if case .pixelfed = self {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(InstanceType) public enum MastodonType {
|
||||
case vanilla
|
||||
case hometown(Version?)
|
||||
case glitch
|
||||
|
||||
func equalsIgnoreVersion(_ other: MastodonType) -> Bool {
|
||||
switch (self, other) {
|
||||
case (.vanilla, .vanilla):
|
||||
return true
|
||||
case (.hometown(_), .hometown(_)):
|
||||
return true
|
||||
case (.glitch, .glitch):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(InstanceType) public enum PleromaType {
|
||||
case vanilla(Version?)
|
||||
case akkoma(Version?)
|
||||
|
||||
func equalsIgnoreVersion(_ other: PleromaType) -> Bool {
|
||||
switch (self, other) {
|
||||
case (.vanilla(_), .vanilla(_)):
|
||||
return true
|
||||
case (.akkoma(_), .akkoma(_)):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// InstanceInfo.swift
|
||||
// InstanceFeatures
|
||||
//
|
||||
// Created by Shadowfacts on 5/28/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
public struct InstanceInfo {
|
||||
public var version: String
|
||||
public var maxStatusCharacters: Int?
|
||||
public var configuration: InstanceV1.Configuration?
|
||||
public var pollsConfiguration: InstanceV1.PollsConfiguration?
|
||||
public var translation: Bool
|
||||
|
||||
public init(
|
||||
version: String,
|
||||
maxStatusCharacters: Int?,
|
||||
configuration: InstanceV1.Configuration?,
|
||||
pollsConfiguration: InstanceV1.PollsConfiguration?,
|
||||
translation: Bool
|
||||
) {
|
||||
self.version = version
|
||||
self.maxStatusCharacters = maxStatusCharacters
|
||||
self.configuration = configuration
|
||||
self.pollsConfiguration = pollsConfiguration
|
||||
self.translation = translation
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceInfo {
|
||||
public init(v1 instance: InstanceV1) {
|
||||
self.init(
|
||||
version: instance.version,
|
||||
maxStatusCharacters: instance.maxStatusCharacters,
|
||||
configuration: instance.configuration,
|
||||
pollsConfiguration: instance.pollsConfiguration,
|
||||
translation: false
|
||||
)
|
||||
}
|
||||
|
||||
public mutating func update(v2: InstanceV2) {
|
||||
translation = v2.configuration.translation.enabled
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
//
|
||||
// Version.swift
|
||||
// InstanceFeatures
|
||||
//
|
||||
// Created by Shadowfacts on 5/14/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
|
||||
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
|
||||
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int
|
||||
|
||||
init(_ major: Int, _ minor: Int, _ patch: Int) {
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.patch = patch
|
||||
}
|
||||
|
||||
init?(string: String) {
|
||||
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
|
||||
match.numberOfRanges == 4 else {
|
||||
return nil
|
||||
}
|
||||
let majorStr = (string as NSString).substring(with: match.range(at: 1))
|
||||
let minorStr = (string as NSString).substring(with: match.range(at: 2))
|
||||
let patchStr = (string as NSString).substring(with: match.range(at: 3))
|
||||
guard let major = Int(majorStr),
|
||||
let minor = Int(minorStr),
|
||||
let patch = Int(patchStr) else {
|
||||
return nil
|
||||
}
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.patch = patch
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
"\(major).\(minor).\(patch)"
|
||||
}
|
||||
|
||||
public static func ==(lhs: Version, rhs: Version) -> Bool {
|
||||
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
|
||||
}
|
||||
|
||||
public static func <(lhs: Version, rhs: Version) -> Bool {
|
||||
if lhs.major < rhs.major {
|
||||
return true
|
||||
} else if lhs.major > rhs.major {
|
||||
return false
|
||||
} else if lhs.minor < rhs.minor {
|
||||
return true
|
||||
} else if lhs.minor > rhs.minor {
|
||||
return false
|
||||
} else if lhs.patch < rhs.patch {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func <(lhs: Version?, rhs: Version) -> Bool {
|
||||
guard let lhs else {
|
||||
// nil is less than or equal to everything
|
||||
return true
|
||||
}
|
||||
return lhs < rhs
|
||||
}
|
||||
|
||||
func >=(lhs: Version?, rhs: Version) -> Bool {
|
||||
guard let lhs else {
|
||||
// nil is less than or equal to everything
|
||||
return false
|
||||
}
|
||||
return lhs >= rhs
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
//
|
||||
// VersionTests.swift
|
||||
// TuskerTests
|
||||
// InstanceFeaturesTests
|
||||
//
|
||||
// Created by Shadowfacts on 4/2/22.
|
||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Tusker
|
||||
@testable import InstanceFeatures
|
||||
|
||||
class VersionTests: XCTestCase {
|
||||
|
|
@ -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,29 @@
|
|||
// swift-tools-version: 6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MatchedGeometryPresentation",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
.library(
|
||||
name: "MatchedGeometryPresentation",
|
||||
targets: ["MatchedGeometryPresentation"]),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "MatchedGeometryPresentation",
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
// .testTarget(
|
||||
// name: "MatchedGeometryPresentationTests",
|
||||
// dependencies: ["MatchedGeometryPresentation"]),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,125 @@
|
|||
//
|
||||
// MatchedGeometryModifiers.swift
|
||||
// MatchGeom
|
||||
//
|
||||
// Created by Shadowfacts on 4/24/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
public func matchedGeometryPresentation<ID: Hashable, Presented: View>(id: Binding<ID?>, backgroundColor: UIColor, @ViewBuilder presenting: () -> Presented) -> some View {
|
||||
self.modifier(MatchedGeometryPresentationModifier(id: id, backgroundColor: backgroundColor, presented: presenting()))
|
||||
}
|
||||
|
||||
public func matchedGeometrySource<ID: Hashable, ID2: Hashable>(id: ID, presentationID: ID2) -> some View {
|
||||
self.modifier(MatchedGeometrySourceModifier(id: AnyHashable(id), presentationID: AnyHashable(presentationID), matched: { AnyView(self) }))
|
||||
}
|
||||
|
||||
public func matchedGeometryDestination<ID: Hashable>(id: ID) -> some View {
|
||||
self.modifier(MatchedGeometryDestinationModifier(id: AnyHashable(id), matched: self))
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchedGeometryPresentationModifier<ID: Hashable, Presented: View>: ViewModifier {
|
||||
@Binding var id: ID?
|
||||
let backgroundColor: UIColor
|
||||
let presented: Presented
|
||||
@StateObject private var state = MatchedGeometryState()
|
||||
|
||||
private var isPresented: Binding<Bool> {
|
||||
Binding {
|
||||
id != nil
|
||||
} set: {
|
||||
if $0 {
|
||||
fatalError()
|
||||
} else {
|
||||
id = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environmentObject(state)
|
||||
.backgroundPreferenceValue(MatchedGeometrySourcesKey.self, { sources in
|
||||
Color.clear
|
||||
.presentViewController(makeVC(allSources: sources), isPresented: isPresented)
|
||||
})
|
||||
}
|
||||
|
||||
private func makeVC(allSources: [SourceKey: (AnyView, CGRect)]) -> () -> UIViewController {
|
||||
return {
|
||||
// force unwrap is safe, this closure is only called when being presented so we must have an id
|
||||
let id = AnyHashable(id!)
|
||||
return MatchedGeometryViewController(
|
||||
presentationID: id,
|
||||
content: presented,
|
||||
state: state,
|
||||
backgroundColor: backgroundColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchedGeometrySourceModifier: ViewModifier {
|
||||
let id: AnyHashable
|
||||
let presentationID: AnyHashable
|
||||
let matched: () -> AnyView
|
||||
@EnvironmentObject private var state: MatchedGeometryState
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
|
||||
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
|
||||
if let newValue {
|
||||
state.sources[SourceKey(presentationID: presentationID, matchedID: id)] = (matched, newValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
.opacity(state.animating && state.presentationID == presentationID ? 0 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchedGeometryDestinationModifier<Matched: View>: ViewModifier {
|
||||
let id: AnyHashable
|
||||
let matched: Matched
|
||||
@EnvironmentObject private var state: MatchedGeometryState
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
|
||||
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
|
||||
if let newValue,
|
||||
// ignore intermediate layouts that may happen while the dismiss animation is happening
|
||||
state.mode != .dismissing {
|
||||
state.destinations[id] = (AnyView(matched), newValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
.opacity(state.animating ? 0 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchedGeometryDestinationFrameKey: PreferenceKey {
|
||||
static let defaultValue: CGRect? = nil
|
||||
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchedGeometrySourcesKey: PreferenceKey {
|
||||
static let defaultValue: [SourceKey: (AnyView, CGRect)] = [:]
|
||||
static func reduce(value: inout Value, nextValue: () -> Value) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceKey: Hashable {
|
||||
let presentationID: AnyHashable
|
||||
let matchedID: AnyHashable
|
||||
}
|
|
@ -0,0 +1,239 @@
|
|||
//
|
||||
// MatchedGeometryViewController.swift
|
||||
// MatchGeom
|
||||
//
|
||||
// Created by Shadowfacts on 4/24/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
private let mass: CGFloat = 1
|
||||
private let presentStiffness: CGFloat = 300
|
||||
private let presentDamping: CGFloat = 20
|
||||
private let dismissStiffness: CGFloat = 200
|
||||
private let dismissDamping: CGFloat = 20
|
||||
|
||||
public class MatchedGeometryState: ObservableObject {
|
||||
@Published var presentationID: AnyHashable?
|
||||
@Published var animating: Bool = false
|
||||
@Published public var mode: Mode = .presenting
|
||||
@Published var sources: [SourceKey: (() -> AnyView, CGRect)] = [:]
|
||||
@Published var currentFrames: [AnyHashable: CGRect] = [:]
|
||||
@Published var destinations: [AnyHashable: (AnyView, CGRect)] = [:]
|
||||
|
||||
public enum Mode: Equatable {
|
||||
case presenting
|
||||
case idle
|
||||
case dismissing
|
||||
}
|
||||
}
|
||||
|
||||
class MatchedGeometryViewController<Content: View>: UIViewController, UIViewControllerTransitioningDelegate {
|
||||
|
||||
let presentationID: AnyHashable
|
||||
let content: Content
|
||||
let state: MatchedGeometryState
|
||||
let backgroundColor: UIColor
|
||||
var contentHost: UIHostingController<ContentContainerView>!
|
||||
var matchedHost: UIHostingController<MatchedContainerView>!
|
||||
|
||||
init(presentationID: AnyHashable, content: Content, state: MatchedGeometryState, backgroundColor: UIColor) {
|
||||
self.presentationID = presentationID
|
||||
self.content = content
|
||||
self.state = state
|
||||
self.backgroundColor = backgroundColor
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
modalPresentationStyle = .custom
|
||||
transitioningDelegate = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
contentHost = UIHostingController(rootView: ContentContainerView(content: content, state: state))
|
||||
contentHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentHost.view.frame = view.bounds
|
||||
contentHost.view.backgroundColor = backgroundColor
|
||||
addChild(contentHost)
|
||||
view.addSubview(contentHost.view)
|
||||
contentHost.didMove(toParent: self)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
state.presentationID = presentationID
|
||||
}
|
||||
|
||||
var currentPresentationSources: [AnyHashable: (() -> AnyView, CGRect)] {
|
||||
Dictionary(uniqueKeysWithValues: state.sources.filter { $0.key.presentationID == presentationID }.map { ($0.key.matchedID, $0.value) })
|
||||
}
|
||||
|
||||
func addMatchedHostingController() {
|
||||
let sources = currentPresentationSources.map { (id: $0.key, view: $0.value.0) }
|
||||
matchedHost = UIHostingController(rootView: MatchedContainerView(sources: sources, state: state))
|
||||
matchedHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
matchedHost.view.frame = view.bounds
|
||||
matchedHost.view.backgroundColor = .clear
|
||||
matchedHost.view.layer.zPosition = 100
|
||||
addChild(matchedHost)
|
||||
view.addSubview(matchedHost.view)
|
||||
matchedHost.didMove(toParent: self)
|
||||
}
|
||||
|
||||
struct ContentContainerView: View {
|
||||
let content: Content
|
||||
let state: MatchedGeometryState
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.environmentObject(state)
|
||||
}
|
||||
}
|
||||
|
||||
struct MatchedContainerView: View {
|
||||
let sources: [(id: AnyHashable, view: () -> AnyView)]
|
||||
@ObservedObject var state: MatchedGeometryState
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ForEach(sources, id: \.id) { (id, view) in
|
||||
matchedView(id: id, source: view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func matchedView(id: AnyHashable, source: () -> AnyView) -> some View {
|
||||
if let frame = state.currentFrames[id],
|
||||
let dest = state.destinations[id]?.0 {
|
||||
ZStack {
|
||||
source()
|
||||
dest
|
||||
.opacity(state.mode == .presenting ? (state.animating ? 1 : 0) : (state.animating ? 0 : 1))
|
||||
}
|
||||
.frame(width: frame.width, height: frame.height)
|
||||
.position(x: frame.midX, y: frame.midY)
|
||||
.ignoresSafeArea()
|
||||
.animation(.interpolatingSpring(mass: Double(mass), stiffness: Double(state.mode == .presenting ? presentStiffness : dismissStiffness), damping: Double(state.mode == .presenting ? presentDamping : dismissDamping), initialVelocity: 0), value: frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIViewControllerTransitioningDelegate
|
||||
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
return MatchedGeometryPresentationAnimationController<Content>()
|
||||
}
|
||||
|
||||
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
return MatchedGeometryDismissAnimationController<Content>()
|
||||
}
|
||||
|
||||
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
||||
return MatchedGeometryPresentationController(presentedViewController: presented, presenting: presenting)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class MatchedGeometryPresentationAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.8
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
let matchedGeomVC = transitionContext.viewController(forKey: .to) as! MatchedGeometryViewController<Content>
|
||||
let container = transitionContext.containerView
|
||||
|
||||
// add the VC to the container, which kicks off layout out the content hosting controller
|
||||
matchedGeomVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
matchedGeomVC.view.frame = container.bounds
|
||||
container.addSubview(matchedGeomVC.view)
|
||||
|
||||
// layout out the content hosting controller and having enough destinations may take a while
|
||||
// so listen for when it's ready, rather than trying to guess at the timing
|
||||
let cancellable = matchedGeomVC.state.$destinations
|
||||
.filter { destinations in matchedGeomVC.currentPresentationSources.allSatisfy { source in destinations.keys.contains(source.key) } }
|
||||
.first()
|
||||
.sink { destinations in
|
||||
matchedGeomVC.addMatchedHostingController()
|
||||
|
||||
// setup the initial state for the animation
|
||||
matchedGeomVC.matchedHost.view.isHidden = true
|
||||
matchedGeomVC.state.mode = .presenting
|
||||
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
|
||||
|
||||
// wait one runloop iteration for the matched hosting controller to be setup
|
||||
DispatchQueue.main.async {
|
||||
matchedGeomVC.matchedHost.view.isHidden = false
|
||||
matchedGeomVC.state.animating = true
|
||||
// get the now-current destinations, in case they've changed since the sunk value was published
|
||||
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
|
||||
}
|
||||
}
|
||||
|
||||
matchedGeomVC.contentHost.view.layer.opacity = 0
|
||||
let spring = UISpringTimingParameters(mass: mass, stiffness: presentStiffness, damping: presentDamping, initialVelocity: .zero)
|
||||
let animator = UIViewPropertyAnimator(duration: self.transitionDuration(using: transitionContext), timingParameters: spring)
|
||||
animator.addAnimations {
|
||||
matchedGeomVC.contentHost.view.layer.opacity = 1
|
||||
}
|
||||
animator.addCompletion { _ in
|
||||
transitionContext.completeTransition(true)
|
||||
matchedGeomVC.state.animating = false
|
||||
matchedGeomVC.state.mode = .idle
|
||||
|
||||
matchedGeomVC.matchedHost?.view.removeFromSuperview()
|
||||
matchedGeomVC.matchedHost?.removeFromParent()
|
||||
cancellable.cancel()
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
class MatchedGeometryDismissAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.8
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
let matchedGeomVC = transitionContext.viewController(forKey: .from) as! MatchedGeometryViewController<Content>
|
||||
|
||||
// recreate the matched host b/c using the current destinations doesn't seem to update the existing one
|
||||
matchedGeomVC.addMatchedHostingController()
|
||||
matchedGeomVC.matchedHost.view.isHidden = true
|
||||
matchedGeomVC.state.mode = .dismissing
|
||||
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
matchedGeomVC.matchedHost.view.isHidden = false
|
||||
matchedGeomVC.state.animating = true
|
||||
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
|
||||
}
|
||||
|
||||
let spring = UISpringTimingParameters(mass: mass, stiffness: dismissStiffness, damping: dismissDamping, initialVelocity: .zero)
|
||||
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: spring)
|
||||
animator.addAnimations {
|
||||
matchedGeomVC.contentHost.view.layer.opacity = 0
|
||||
}
|
||||
animator.addCompletion { _ in
|
||||
transitionContext.completeTransition(true)
|
||||
matchedGeomVC.state.animating = false
|
||||
matchedGeomVC.state.mode = .idle
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
class MatchedGeometryPresentationController: UIPresentationController {
|
||||
override func dismissalTransitionWillBegin() {
|
||||
super.dismissalTransitionWillBegin()
|
||||
delegate?.presentationControllerWillDismiss?(self)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// View+PresentViewController.swift
|
||||
// MatchGeom
|
||||
//
|
||||
// Created by Shadowfacts on 4/24/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func presentViewController(_ makeVC: @escaping () -> UIViewController, isPresented: Binding<Bool>) -> some View {
|
||||
self
|
||||
.background(
|
||||
ViewControllerPresenter(makeVC: makeVC, isPresented: isPresented)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ViewControllerPresenter: UIViewControllerRepresentable {
|
||||
let makeVC: () -> UIViewController
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
return UIViewController()
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
||||
if isPresented {
|
||||
if uiViewController.presentedViewController == nil {
|
||||
let presented = makeVC()
|
||||
presented.presentationController!.delegate = context.coordinator
|
||||
uiViewController.present(presented, animated: true)
|
||||
context.coordinator.didPresent = true
|
||||
}
|
||||
} else {
|
||||
if context.coordinator.didPresent,
|
||||
let presentedViewController = uiViewController.presentedViewController,
|
||||
!presentedViewController.isBeingDismissed {
|
||||
uiViewController.dismiss(animated: true)
|
||||
context.coordinator.didPresent = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(isPresented: $isPresented)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
||||
@Binding var isPresented: Bool
|
||||
var didPresent = false
|
||||
|
||||
init(isPresented: Binding<Bool>) {
|
||||
self._isPresented = isPresented
|
||||
}
|
||||
|
||||
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
||||
isPresented = false
|
||||
didPresent = false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
@ -17,7 +17,7 @@
|
|||
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
|
||||
BuildableName = "Pachyderm.framework"
|
||||
BlueprintName = "Pachyderm"
|
||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||
ReferencedContainer = "container:../../Tusker.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
|
@ -35,7 +35,7 @@
|
|||
BlueprintIdentifier = "PachydermTests"
|
||||
BuildableName = "PachydermTests"
|
||||
BlueprintName = "PachydermTests"
|
||||
ReferencedContainer = "container:Pachyderm">
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
|
@ -56,7 +56,7 @@
|
|||
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
|
||||
BuildableName = "Pachyderm.framework"
|
||||
BlueprintName = "Pachyderm"
|
||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||
ReferencedContainer = "container:../../Tusker.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</LaunchAction>
|
||||
|
@ -72,7 +72,7 @@
|
|||
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
|
||||
BuildableName = "Pachyderm.framework"
|
||||
BlueprintName = "Pachyderm"
|
||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||
ReferencedContainer = "container:../../Tusker.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version: 5.6
|
||||
// swift-tools-version: 6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,8 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "Pachyderm",
|
||||
platforms: [
|
||||
.iOS(.v14),
|
||||
.iOS(.v16),
|
||||
.macOS(.v13),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -16,7 +17,6 @@ let package = Package(
|
|||
],
|
||||
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.
|
||||
|
@ -24,11 +24,15 @@ let package = Package(
|
|||
.target(
|
||||
name: "Pachyderm",
|
||||
dependencies: [
|
||||
.product(name: "WebURL", package: "swift-url"),
|
||||
.product(name: "WebURLFoundationExtras", package: "swift-url"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
.testTarget(
|
||||
name: "PachydermTests",
|
||||
dependencies: ["Pachyderm"]),
|
||||
dependencies: ["Pachyderm"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -11,7 +11,7 @@ import Foundation
|
|||
/**
|
||||
The base Mastodon API client.
|
||||
*/
|
||||
public class Client {
|
||||
public struct Client: Sendable {
|
||||
|
||||
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
|
||||
|
||||
|
@ -19,34 +19,35 @@ public class Client {
|
|||
let session: URLSession
|
||||
|
||||
public var accessToken: String?
|
||||
|
||||
public var appID: String?
|
||||
public var clientID: String?
|
||||
public var clientSecret: String?
|
||||
|
||||
public var timeoutInterval: TimeInterval = 60
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
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")
|
||||
let iso8601 = ISO8601DateFormatter()
|
||||
return formatter
|
||||
}()
|
||||
private static let iso8601Formatter = ISO8601DateFormatter()
|
||||
private static func decodeDate(string: String) -> Date? {
|
||||
// for the next time mastodon accidentally changes date formats >.>
|
||||
return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string)
|
||||
}
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
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) {
|
||||
if let date = Self.decodeDate(string: str) {
|
||||
return date
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||
}
|
||||
})
|
||||
|
||||
return decoder
|
||||
}()
|
||||
|
||||
|
@ -60,9 +61,11 @@ public class Client {
|
|||
return encoder
|
||||
}()
|
||||
|
||||
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||
public init(baseURL: URL, accessToken: String? = nil, clientID: String? = nil, clientSecret: String? = nil, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.accessToken = accessToken
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
self.session = session
|
||||
}
|
||||
|
||||
|
@ -83,7 +86,7 @@ public class Client {
|
|||
completion(.failure(Error(request: request, type: .invalidResponse)))
|
||||
return
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
guard response.statusCode == 200 || request.additionalAcceptableHTTPCodes.contains(response.statusCode) else {
|
||||
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
||||
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
|
||||
completion(.failure(Error(request: request, type: type)))
|
||||
|
@ -104,6 +107,29 @@ public class Client {
|
|||
return task
|
||||
}
|
||||
|
||||
private func error(from response: HTTPURLResponse) -> ErrorType {
|
||||
if response.statusCode == 429,
|
||||
let date = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Self.decodeDate) {
|
||||
return .rateLimited(date)
|
||||
} else {
|
||||
return .unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
run(request) { response in
|
||||
switch response {
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
case .success(let result, let pagination):
|
||||
continuation.resume(returning: (result, pagination))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
||||
components.path = request.endpoint.path
|
||||
|
@ -112,11 +138,17 @@ public class Client {
|
|||
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
||||
urlRequest.httpMethod = request.method.name
|
||||
urlRequest.httpBody = request.body.data
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
for (name, value) in request.headers {
|
||||
urlRequest.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
if let mimeType = request.body.mimeType {
|
||||
urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
if let accessToken = accessToken {
|
||||
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
// We consider authenticated requests to be user-initiated.
|
||||
urlRequest.attribution = .user
|
||||
}
|
||||
return urlRequest
|
||||
}
|
||||
|
@ -129,47 +161,52 @@ public class Client {
|
|||
"scopes" => scopes.scopeString,
|
||||
"website" => website?.absoluteString
|
||||
]))
|
||||
run(request) { result in
|
||||
defer { completion(result) }
|
||||
guard case let .success(application, _) = result else { return }
|
||||
|
||||
self.appID = application.id
|
||||
self.clientID = application.clientID
|
||||
self.clientSecret = application.clientSecret
|
||||
}
|
||||
run(request, completion: completion)
|
||||
}
|
||||
|
||||
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
||||
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
|
||||
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
|
||||
"client_id" => clientID,
|
||||
"client_secret" => clientSecret,
|
||||
"grant_type" => "authorization_code",
|
||||
"code" => authorizationCode,
|
||||
"redirect_uri" => redirectURI
|
||||
"redirect_uri" => redirectURI,
|
||||
"scope" => scopes.scopeString,
|
||||
]))
|
||||
run(request) { result in
|
||||
defer { completion(result) }
|
||||
guard case let .success(loginSettings, _) = result else { return }
|
||||
|
||||
self.accessToken = loginSettings.accessToken
|
||||
}
|
||||
run(request, completion: completion)
|
||||
}
|
||||
|
||||
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)
|
||||
public func revokeAccessToken() async throws {
|
||||
guard let accessToken else {
|
||||
return
|
||||
}
|
||||
let request = Request<Empty>(method: .post, path: "/oauth/revoke", body: ParametersBody([
|
||||
"token" => accessToken,
|
||||
"client_id" => clientID!,
|
||||
"client_secret" => clientSecret!,
|
||||
]))
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
self.run(request) { response in
|
||||
switch response {
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
case .success(_, _):
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public func nodeInfo() async throws -> NodeInfo {
|
||||
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
||||
let wellKnownResults = try await run(wellKnown).0
|
||||
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||
let href = try? URL.ParseStrategy().parse(url.href),
|
||||
href.host == self.baseURL.host() {
|
||||
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
||||
return try await run(nodeInfo).0
|
||||
} else {
|
||||
throw NodeInfoError.noWellKnownLink
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -178,22 +215,32 @@ public class Client {
|
|||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||
}
|
||||
|
||||
public static func getFavourites() -> Request<[Status]> {
|
||||
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
|
||||
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
||||
}
|
||||
|
||||
public static func getInstance() -> Request<Instance> {
|
||||
return Request<Instance>(method: .get, path: "/api/v1/instance")
|
||||
public static func getInstanceV1() -> Request<InstanceV1> {
|
||||
return Request<InstanceV1>(method: .get, path: "/api/v1/instance")
|
||||
}
|
||||
|
||||
public static func getInstanceV2() -> Request<InstanceV2> {
|
||||
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
|
||||
}
|
||||
|
||||
public static func getCustomEmoji() -> Request<[Emoji]> {
|
||||
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
||||
}
|
||||
|
||||
public static func getPreferences() -> Request<Preferences> {
|
||||
return Request(method: .get, path: "/api/v1/preferences")
|
||||
}
|
||||
|
||||
// MARK: - Accounts
|
||||
public static func getAccount(id: String) -> Request<Account> {
|
||||
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
||||
|
@ -290,6 +337,13 @@ public class Client {
|
|||
], attachment))
|
||||
}
|
||||
|
||||
public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request<Attachment> {
|
||||
return Request(method: .put, path: "/api/v1/media/\(id)", body: FormDataBody([
|
||||
"description" => description,
|
||||
"focus" => focus
|
||||
], nil))
|
||||
}
|
||||
|
||||
// MARK: - Mutes
|
||||
public static func getMutes(range: RequestRange) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
||||
|
@ -298,6 +352,10 @@ public class Client {
|
|||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
public static func getNotification(id: String) -> Request<Notification> {
|
||||
return Request(method: .get, path: "/api/v1/notifications/\(id)")
|
||||
}
|
||||
|
||||
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||
"types" => allowedTypes.map { $0.rawValue }
|
||||
|
@ -357,69 +415,110 @@ public class Client {
|
|||
public static func createStatus(text: String,
|
||||
contentType: StatusContentType = .plain,
|
||||
inReplyTo: String? = nil,
|
||||
media: [Attachment]? = nil,
|
||||
mediaIDs: [String]? = nil,
|
||||
sensitive: Bool? = nil,
|
||||
spoilerText: String? = nil,
|
||||
visibility: Status.Visibility? = nil,
|
||||
language: String? = nil,
|
||||
visibility: String? = nil,
|
||||
language: String? = nil, // language supported by mastodon and akkoma
|
||||
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,
|
||||
"content_type" => contentType.mimeType,
|
||||
"in_reply_to_id" => inReplyTo,
|
||||
"sensitive" => sensitive,
|
||||
"spoiler_text" => spoilerText,
|
||||
"visibility" => visibility?.rawValue,
|
||||
"language" => language,
|
||||
"poll[expires_in]" => pollExpiresIn,
|
||||
"poll[multiple]" => pollMultiple,
|
||||
"local_only" => localOnly,
|
||||
] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions))
|
||||
localOnly: Bool? = nil, /* hometown only, not glitch */
|
||||
idempotencyKey: String) -> Request<Status> {
|
||||
var req = Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
||||
"status" => text,
|
||||
"content_type" => contentType.mimeType,
|
||||
"in_reply_to_id" => inReplyTo,
|
||||
"sensitive" => sensitive,
|
||||
"spoiler_text" => spoilerText,
|
||||
"visibility" => visibility,
|
||||
"language" => language,
|
||||
"poll[expires_in]" => pollExpiresIn,
|
||||
"poll[multiple]" => pollMultiple,
|
||||
"local_only" => localOnly,
|
||||
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
|
||||
req.headers["Idempotency-Key"] = idempotencyKey
|
||||
return req
|
||||
}
|
||||
|
||||
public static func editStatus(
|
||||
id: String,
|
||||
text: String,
|
||||
contentType: StatusContentType = .plain,
|
||||
spoilerText: String?,
|
||||
sensitive: Bool,
|
||||
language: String?,
|
||||
mediaIDs: [String],
|
||||
mediaAttributes: [EditStatusMediaAttributes],
|
||||
poll: EditPollParameters?
|
||||
) -> Request<Status> {
|
||||
let params = EditStatusParameters(
|
||||
id: id,
|
||||
text: text,
|
||||
contentType: contentType,
|
||||
spoilerText: spoilerText,
|
||||
sensitive: sensitive,
|
||||
language: language,
|
||||
mediaIDs: mediaIDs,
|
||||
mediaAttributes: mediaAttributes,
|
||||
poll: poll
|
||||
)
|
||||
return Request(method: .put, path: "/api/v1/statuses/\(id)", body: JsonBody(params))
|
||||
}
|
||||
|
||||
// MARK: - Timelines
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
return timeline.request(range: range)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Bookmarks
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
|
||||
request.range = range
|
||||
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 = []
|
||||
public static func getTrendingHashtagsDeprecated(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
if let offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
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 = []
|
||||
public static func getTrendingHashtags(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
if let offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
||||
}
|
||||
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
if let offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
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 = []
|
||||
public static func getTrendingLinks(limit: Int? = nil, offset: Int? = nil) -> Request<[Card]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
if let offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
return Request(method: .get, path: "/api/v1/trends/links", queryParameters: parameters)
|
||||
}
|
||||
|
@ -444,10 +543,16 @@ public class Client {
|
|||
])
|
||||
}
|
||||
|
||||
// MARK: - Hashtags
|
||||
/// Requires Mastodon 4.0.0+
|
||||
public static func getHashtag(name: String) -> Request<Hashtag> {
|
||||
return Request(method: .get, path: "/api/v1/tags/\(name)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Client {
|
||||
public struct Error: LocalizedError {
|
||||
public struct Error: LocalizedError, Sendable {
|
||||
public let requestMethod: Method
|
||||
public let requestEndpoint: Endpoint
|
||||
public let type: ErrorType
|
||||
|
@ -462,13 +567,15 @@ extension Client {
|
|||
self.type = type
|
||||
}
|
||||
|
||||
public var localizedDescription: String {
|
||||
public var errorDescription: 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(429):
|
||||
return "HTTP 429: Rate Limit Exceeded"
|
||||
case .unexpectedStatus(let code):
|
||||
return "HTTP Code \(code)"
|
||||
case .invalidRequest:
|
||||
|
@ -479,15 +586,29 @@ extension Client {
|
|||
return "Invalid Model"
|
||||
case .mastodonError(let code, let error):
|
||||
return "Server Error (\(code)): \(error)"
|
||||
case .rateLimited(let reset):
|
||||
return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))"
|
||||
}
|
||||
}
|
||||
}
|
||||
public enum ErrorType: LocalizedError {
|
||||
public enum ErrorType: LocalizedError, Sendable {
|
||||
case networkError(Swift.Error)
|
||||
case unexpectedStatus(Int)
|
||||
case invalidRequest
|
||||
case invalidResponse
|
||||
case invalidModel(Swift.Error)
|
||||
case mastodonError(Int, String)
|
||||
case rateLimited(Date)
|
||||
}
|
||||
|
||||
enum NodeInfoError: LocalizedError {
|
||||
case noWellKnownLink
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noWellKnownLink:
|
||||
return "No well-known link"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public final class Account: AccountProtocol, Decodable {
|
||||
public final class Account: AccountProtocol, Decodable, Sendable {
|
||||
public let id: String
|
||||
public let username: String
|
||||
public let acct: String
|
||||
|
@ -25,7 +25,7 @@ public final class Account: AccountProtocol, Decodable {
|
|||
public let avatarStatic: URL?
|
||||
public let header: URL?
|
||||
public let headerStatic: URL?
|
||||
public private(set) var emojis: [Emoji]
|
||||
public let emojis: [Emoji]
|
||||
public let moved: Bool?
|
||||
public let movedTo: Account?
|
||||
public let fields: [Field]
|
||||
|
@ -40,8 +40,9 @@ public final class Account: AccountProtocol, Decodable {
|
|||
self.displayName = try container.decode(String.self, forKey: .displayName)
|
||||
self.locked = try container.decode(Bool.self, forKey: .locked)
|
||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
|
||||
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
|
||||
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
|
||||
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
|
||||
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||
self.note = try container.decode(String.self, forKey: .note)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
|
@ -70,12 +71,12 @@ public final class Account: AccountProtocol, Decodable {
|
|||
}
|
||||
}
|
||||
|
||||
public static func authorizeFollowRequest(_ account: Account) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
|
||||
public static func authorizeFollowRequest(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize")
|
||||
}
|
||||
|
||||
public static func rejectFollowRequest(_ account: Account) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject")
|
||||
public static func rejectFollowRequest(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject")
|
||||
}
|
||||
|
||||
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {
|
||||
|
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable {
|
|||
return request
|
||||
}
|
||||
|
||||
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: [
|
||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
"only_media" => onlyMedia,
|
||||
"pinned" => pinned,
|
||||
"exclude_replies" => excludeReplies,
|
||||
|
@ -109,6 +110,12 @@ public final class Account: AccountProtocol, Decodable {
|
|||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/follow")
|
||||
}
|
||||
|
||||
public static func setShowReblogs(_ accountID: String, showReblogs: Bool) -> Request<Relationship> {
|
||||
return Request(method: .post, path: "/api/v1/accounts/\(accountID)/follow", body: ParametersBody([
|
||||
"reblogs" => showReblogs
|
||||
]))
|
||||
}
|
||||
|
||||
public static func unfollow(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
|
||||
}
|
||||
|
@ -165,7 +172,7 @@ extension Account: CustomDebugStringConvertible {
|
|||
}
|
||||
|
||||
extension Account {
|
||||
public struct Field: Codable {
|
||||
public struct Field: Codable, Equatable, Sendable {
|
||||
public let name: String
|
||||
public let value: String
|
||||
public let verifiedAt: Date?
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue