forked from shadowfacts/Tusker
Compare commits
1556 Commits
Author | SHA1 | Date |
---|---|---|
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 | |
Shadowfacts | 2ee34acbad | |
Shadowfacts | 6eee97759e | |
Shadowfacts | f88bf552af | |
Shadowfacts | d2c7664073 | |
Shadowfacts | e91249a876 | |
Shadowfacts | 1eab964c0b | |
Shadowfacts | 2933ac491b | |
Shadowfacts | 2958d2b1ac | |
Shadowfacts | 3262fe002b | |
Shadowfacts | 521e5ad5fc | |
Shadowfacts | 2b651b0bc4 | |
Shadowfacts | 99b3532e64 | |
Shadowfacts | 2ea8e9cf1e | |
Shadowfacts | e8b7446117 | |
Shadowfacts | a47b9c0c75 | |
Shadowfacts | a75862b5cc | |
Shadowfacts | 0738683ee3 | |
Shadowfacts | 155f4036f9 | |
Shadowfacts | 8181090763 | |
Shadowfacts | 6328627a97 | |
Shadowfacts | c6043d60ee | |
Shadowfacts | dd6813c058 | |
Shadowfacts | 2229b332e0 | |
Shadowfacts | 63ed3b6e10 | |
Shadowfacts | ccd1672e72 | |
Shadowfacts | addcc2dacc | |
Shadowfacts | a49e9f2c1f | |
Shadowfacts | b1421767dd | |
Shadowfacts | 8ee916411e | |
Shadowfacts | 9d845bf6c1 | |
Shadowfacts | 9a2c24942a | |
Shadowfacts | cca2a03b2f | |
Shadowfacts | 1a64bfcef8 | |
Shadowfacts | 907810d98a | |
Shadowfacts | 23a4999196 | |
Shadowfacts | 3e0feba273 | |
Shadowfacts | 468a559127 | |
Shadowfacts | c03fc86300 | |
Shadowfacts | a33be0b556 | |
Shadowfacts | 6aee926f00 | |
Shadowfacts | 13640be91d | |
Shadowfacts | 5123cf20c3 | |
Shadowfacts | bf739b9f41 | |
Shadowfacts | 4211806b5f | |
Shadowfacts | 88aada8d35 | |
Shadowfacts | 5623cedab3 | |
Shadowfacts | ccfc8331fb | |
Shadowfacts | 10803408cd | |
Shadowfacts | fb7a7db6e8 | |
Shadowfacts | 78cd1313fe | |
Shadowfacts | db1bbf7148 | |
Shadowfacts | 5f19adf2d0 | |
Shadowfacts | 6f006adbc1 | |
Shadowfacts | 39bff06897 | |
Shadowfacts | 68682ee291 | |
Shadowfacts | 5029b26b40 | |
Shadowfacts | 907cf08400 | |
Shadowfacts | e85d194e5f | |
Shadowfacts | cfeb87d2ba | |
Shadowfacts | e4f3735c9f | |
Shadowfacts | baa9dfe0f1 | |
Shadowfacts | 5e73439e7b | |
Shadowfacts | 4b2776ee81 | |
Shadowfacts | 566df3e285 | |
Shadowfacts | 0653d695d9 | |
Shadowfacts | 4811747790 | |
Shadowfacts | ed2519848c | |
Shadowfacts | b1374b12a3 | |
Shadowfacts | c5a25eecf1 | |
Shadowfacts | a4dbf3ddbb | |
Shadowfacts | be3a61ebc7 | |
Shadowfacts | ababa4b428 | |
Shadowfacts | d75c2558ca | |
Shadowfacts | ac0dedfd3d | |
Shadowfacts | 37563b6afd | |
Shadowfacts | 937afc0dfd | |
Shadowfacts | 94c34e03dd | |
Shadowfacts | 1ad556f9cf | |
Shadowfacts | 019f7d6d6a | |
Shadowfacts | b4384d11f5 | |
Shadowfacts | 2ed8d22899 | |
Shadowfacts | cce6413e2b | |
Shadowfacts | 8fb0fb66e3 | |
Shadowfacts | abe2bbdfd4 | |
Shadowfacts | 1d9efc7fb5 | |
Shadowfacts | b17b7b7a24 | |
Shadowfacts | 18d7917756 | |
Shadowfacts | cc401fce8c | |
Shadowfacts | a5fc35d0b1 | |
Shadowfacts | acd48a6db4 | |
Shadowfacts | b45d3fb80a | |
Shadowfacts | 3ea1ad5622 | |
Shadowfacts | 5898da3234 | |
Shadowfacts | 9dd966f639 | |
Shadowfacts | 48662ef1f3 | |
Shadowfacts | 854d48e54e | |
Shadowfacts | d4c560d7fc | |
Shadowfacts | 91b7ce3008 | |
Shadowfacts | 4dca231a06 | |
Shadowfacts | b81c83a250 | |
Shadowfacts | f9e619d9e7 | |
Shadowfacts | ae7962ae50 | |
Shadowfacts | 5027660b52 | |
Shadowfacts | 358d81b5cf | |
Shadowfacts | 79b9108a8f | |
Shadowfacts | 5ab22e742b | |
Shadowfacts | 4f655bb80a | |
Shadowfacts | e4f1309e2d | |
Shadowfacts | bb40894778 | |
Shadowfacts | 24b3fa1e3f | |
Shadowfacts | 16cd045588 | |
Shadowfacts | 15a7cd5f65 | |
Shadowfacts | e676075d5b | |
Shadowfacts | 967bff063b | |
Shadowfacts | 3cba0bce34 | |
Shadowfacts | 60b182ac18 | |
Shadowfacts | 619878ac85 | |
Shadowfacts | 169f1a0191 | |
Shadowfacts | fa31c28e92 | |
Shadowfacts | f815d4e2e4 | |
Shadowfacts | a3e5b29cfc | |
Shadowfacts | 46cecde014 | |
Shadowfacts | 86143c5887 | |
Shadowfacts | 0a1dc423d4 | |
Shadowfacts | 1cb0f1ae56 | |
Shadowfacts | 9f86158bb7 | |
Shadowfacts | 231b0ea830 | |
Shadowfacts | 4dc108f782 | |
Shadowfacts | 795146cde4 | |
Shadowfacts | 975be17d13 | |
Shadowfacts | 32be76ebee | |
Shadowfacts | d13b517128 | |
Shadowfacts | e0d97cd2a8 | |
Shadowfacts | 8b718ce50b | |
Shadowfacts | ce708e2d16 | |
Shadowfacts | 01467574d0 | |
Shadowfacts | 97a2278634 | |
Shadowfacts | 4b2a263889 | |
Shadowfacts | 1f37a5e7eb | |
Shadowfacts | 77c9fac3ce | |
Shadowfacts | a13d5d5a82 | |
Shadowfacts | 23e4541eb7 | |
Shadowfacts | d4b9f71fd3 | |
Shadowfacts | a9edeaf5b9 | |
Shadowfacts | 1f6074e539 | |
Shadowfacts | df7b62e14b | |
Shadowfacts | cacc8a51cc | |
Shadowfacts | 89ca0629b3 | |
Shadowfacts | 360db07ef2 | |
Shadowfacts | f55a870964 | |
Shadowfacts | 5ee140cdab | |
Shadowfacts | ff4dff1147 | |
Shadowfacts | ba1eed7a85 | |
Shadowfacts | 0c9f6e02bd | |
Shadowfacts | 565d17970f | |
Shadowfacts | dc3c2d027c | |
Shadowfacts | ba2c34fdd6 | |
Shadowfacts | 3691c3f483 | |
Shadowfacts | 9c103103e8 | |
Shadowfacts | 382d8ef2c8 | |
Shadowfacts | 2891f47cb3 | |
Shadowfacts | 3c80ec8b43 | |
Shadowfacts | 478ba3db28 | |
Shadowfacts | f96cd1b5e2 | |
Shadowfacts | 7f4ab57a1d | |
Shadowfacts | 8caf93bf0a | |
Shadowfacts | 9c4b68b09e | |
Shadowfacts | b49e8d0279 | |
Shadowfacts | 71a57e9859 | |
Shadowfacts | 081ef16e5e | |
Shadowfacts | b3ec259ce9 | |
Shadowfacts | 4f48514d1a | |
Shadowfacts | f96acd33f2 | |
Shadowfacts | cde061c77a | |
Shadowfacts | a79b3cfd70 | |
Shadowfacts | 9a35f96c75 | |
Shadowfacts | 60767c6a7e | |
Shadowfacts | 57668886b2 | |
Shadowfacts | ffb5c76f7c | |
Shadowfacts | 00e8dd6345 | |
Shadowfacts | 7904462920 | |
Shadowfacts | 13d649bace | |
Shadowfacts | bebe563e8f | |
Shadowfacts | 4be2258882 | |
Shadowfacts | 40ff8d0a2a | |
Shadowfacts | 0dcb7e71c4 | |
Shadowfacts | 08878f2fb9 | |
Shadowfacts | 3ea7e1057b | |
Shadowfacts | fc8fcb76fd | |
Shadowfacts | eac2a9b19f | |
Shadowfacts | 0ce57d1308 | |
Shadowfacts | 97dec0f9d2 | |
Shadowfacts | b64c748b73 | |
Shadowfacts | 77ab2c3753 | |
Shadowfacts | b90262bfd0 | |
Shadowfacts | 581f4b24bd | |
Shadowfacts | 5f3d9da9f8 | |
Shadowfacts | 41775e5d19 | |
Shadowfacts | 044d34d20f | |
Shadowfacts | f1b1732e5c | |
Shadowfacts | 1da2b17a76 | |
Shadowfacts | e49725e06d | |
Shadowfacts | 669404d6f8 | |
Shadowfacts | 2e21742264 | |
Shadowfacts | 7763d08816 | |
Shadowfacts | 726be85223 | |
Shadowfacts | 19bf6cbf18 | |
Shadowfacts | df07fa85d5 | |
Shadowfacts | e3e55de55b | |
Shadowfacts | 54857a3bf3 | |
Shadowfacts | b28f616e85 | |
Shadowfacts | 97c7104dbc | |
Shadowfacts | 6501343f24 | |
Shadowfacts | fabe339215 | |
Shadowfacts | e1886509d3 | |
Shadowfacts | 8ad48784d9 | |
Shadowfacts | 75e9c9f986 | |
Shadowfacts | a17afe247c | |
Shadowfacts | 81abcfcf7b | |
Shadowfacts | 7e5d8675c2 | |
Shadowfacts | cde3109203 | |
Shadowfacts | fcf95ba8c1 | |
Shadowfacts | f71804f094 | |
Shadowfacts | 83ca7f1321 | |
Shadowfacts | 16a1e4008b | |
Shadowfacts | 518a8eba0a | |
Shadowfacts | 8d56a6450e | |
Shadowfacts | 8896bfbc59 | |
Shadowfacts | 4ca57f8c76 | |
Shadowfacts | c9fa11cc3b | |
Shadowfacts | 0247c50650 | |
Shadowfacts | eca06cb14a | |
Shadowfacts | c07e2cfdd8 | |
Shadowfacts | db7615d26f | |
Shadowfacts | 2f0acad866 | |
Shadowfacts | a2b3fc0628 | |
Shadowfacts | e005b70071 | |
Shadowfacts | b515664db3 | |
Shadowfacts | 948eff1f7e | |
Shadowfacts | f1a39c2faa | |
Shadowfacts | ab8e498cee | |
Shadowfacts | c6da754875 | |
Shadowfacts | 97d5b955a0 | |
Shadowfacts | 80f9800fd6 | |
Shadowfacts | 0485400c1f | |
Shadowfacts | 811aac35d7 | |
Shadowfacts | a77b090435 | |
Shadowfacts | 21874b0966 | |
Shadowfacts | 08c63a2f84 | |
Shadowfacts | 97f00e9d6f | |
Shadowfacts | a97a7e0aea | |
Shadowfacts | cf870916c9 | |
Shadowfacts | 7297566060 | |
Shadowfacts | 4f28fec62a | |
Shadowfacts | c01bc4d840 | |
Shadowfacts | ea6698a2d8 | |
Shadowfacts | 1e950b5ccb | |
Shadowfacts | 3e5a3c81b5 | |
Shadowfacts | a5506aeab6 | |
Shadowfacts | 23b76a7276 | |
Shadowfacts | d8f503351b | |
Shadowfacts | d5887f1f02 | |
Shadowfacts | e04cdd16d6 | |
Shadowfacts | c256fb4cbd | |
Shadowfacts | 21299c8eb8 | |
Shadowfacts | 527706154a | |
Shadowfacts | 07c86b6949 | |
Shadowfacts | 92cf938e99 | |
Shadowfacts | f23d3dfa3f | |
Shadowfacts | 23f9e200dc | |
Shadowfacts | 366834e2e4 | |
Shadowfacts | d409d26478 | |
Shadowfacts | 76fc73de95 | |
Shadowfacts | 40800f964d | |
Shadowfacts | 9f7d16a70e | |
Shadowfacts | c2cb0a0c5a | |
Shadowfacts | 272f35417b | |
Shadowfacts | 848c3dd950 | |
Shadowfacts | dfeb39b31f | |
Shadowfacts | bab5226f2a | |
Shadowfacts | 88cfbfb1f3 | |
Shadowfacts | 49f1d6339f | |
Shadowfacts | 3e7cb443fa | |
Shadowfacts | b5c8a38b9b | |
Shadowfacts | ab19922530 | |
Shadowfacts | 45c844b065 | |
Shadowfacts | 47b838a386 | |
Shadowfacts | 276691efbf | |
Shadowfacts | 0a8d50cc27 | |
Shadowfacts | 11e81acbc1 | |
Shadowfacts | fb2c9b341c | |
Shadowfacts | 810ae71832 | |
Shadowfacts | 001a73af3c | |
Shadowfacts | c8375b742a | |
Shadowfacts | 9feef054fc | |
Shadowfacts | bf87ae7a7d | |
Shadowfacts | f8de6f9e10 | |
Shadowfacts | ab47fa776e | |
Shadowfacts | 7178473f34 | |
Shadowfacts | c8319d8af2 | |
Shadowfacts | 9ff1452c68 | |
Shadowfacts | ce534c4a05 | |
Shadowfacts | 0fddf94292 | |
Shadowfacts | 8276e99d27 | |
Shadowfacts | a5ad8e43b1 | |
Shadowfacts | ce7ce3ac92 | |
Shadowfacts | 99a1c76cb1 | |
Shadowfacts | 603e989879 | |
Shadowfacts | dd82283341 | |
Shadowfacts | af2d9e7eb8 | |
Shadowfacts | 06ad46e639 | |
Shadowfacts | 71f97d41c4 | |
Shadowfacts | df131f32c6 | |
Shadowfacts | 77dece36d0 | |
Shadowfacts | 1a767ff910 | |
Shadowfacts | 220c8050b1 | |
Shadowfacts | d4fa9c96e8 | |
Shadowfacts | 22b5d62ba1 | |
Shadowfacts | b9bdd29986 | |
Shadowfacts | f848bbf7c4 | |
Shadowfacts | 0fe9edfdbc | |
Shadowfacts | 6d2830cf78 | |
Shadowfacts | 7294ff6e1a | |
Shadowfacts | 3fd62552b3 | |
Shadowfacts | fa5abc27f7 | |
Shadowfacts | ccc47e204d | |
Shadowfacts | bf3f735062 | |
Shadowfacts | de0198946e | |
Shadowfacts | 072a77b58e | |
Shadowfacts | eb7fe22863 | |
Shadowfacts | f1511039ef | |
Shadowfacts | 5c479e3bf0 | |
Shadowfacts | 0413f326a0 | |
Shadowfacts | 9d1c3f1410 | |
Shadowfacts | 802a0ac9ba | |
Shadowfacts | 9da986e3b8 | |
Shadowfacts | e6a5b899be | |
Shadowfacts | 60bf3b2e33 | |
Shadowfacts | b465838b71 | |
Shadowfacts | 21bd716844 | |
Shadowfacts | 523fb91b21 | |
Shadowfacts | d8bf770902 | |
Shadowfacts | 10aa32d9cc | |
Shadowfacts | 7474969969 | |
Shadowfacts | 319b5458fc | |
Shadowfacts | f7304a011c | |
Shadowfacts | 94dc5d3177 | |
Shadowfacts | 6d692c2730 | |
Shadowfacts | d0f8691560 | |
Shadowfacts | 9a43ab5a13 | |
Shadowfacts | 01124b76a3 | |
Shadowfacts | 7600954f4b | |
Shadowfacts | 5a5c67e445 | |
Shadowfacts | 68c3affacf | |
Shadowfacts | e40f4faa8e | |
Shadowfacts | b56c6c37ec | |
Shadowfacts | 999118798c | |
Shadowfacts | 84cf755332 | |
Shadowfacts | 5bd7c0ad2b | |
Shadowfacts | 7fe06d42ce | |
Shadowfacts | 20986ba3f0 | |
Shadowfacts | 97a95c435e | |
Shadowfacts | b9555cf7dd | |
Shadowfacts | 590b9f0bcc | |
Shadowfacts | ca2ceaea56 | |
Shadowfacts | 96d8a79d42 | |
Shadowfacts | 11233f7d25 | |
Shadowfacts | a991e0f429 | |
Shadowfacts | bfdce07d81 | |
Shadowfacts | f5953655c5 | |
Shadowfacts | 6bc4993d81 | |
Shadowfacts | 68646c4b4d | |
Shadowfacts | 38b0d57118 | |
Shadowfacts | b38c24b347 | |
Shadowfacts | a6d51cee3c | |
Shadowfacts | 7bdbd9f71a | |
Shadowfacts | b47876dc3d | |
Shadowfacts | 4644475bc7 | |
Shadowfacts | 16ba292afa | |
Shadowfacts | c7f3bac330 | |
Shadowfacts | abb8352c92 | |
Shadowfacts | 59d866aa23 | |
Shadowfacts | ba032412eb | |
Shadowfacts | 5de0c034f4 | |
Shadowfacts | b1d83f2746 | |
Shadowfacts | 658c08010d | |
Shadowfacts | 6a5753fac8 | |
Shadowfacts | 8da89986df | |
Shadowfacts | c7e39cb041 | |
Shadowfacts | b755607895 | |
Shadowfacts | 508eef8c07 | |
Shadowfacts | a18dfc38af | |
Shadowfacts | 95f9fad673 | |
Shadowfacts | 4857b507b1 | |
Shadowfacts | bca7bd3586 | |
Shadowfacts | 9978e392a2 | |
Shadowfacts | cc33cf18f2 | |
Shadowfacts | c5921bc4cb | |
Shadowfacts | 91450ced7c | |
Shadowfacts | 5afd9e83eb | |
Shadowfacts | d05275020f | |
Shadowfacts | c420c236d9 | |
Shadowfacts | d5433e9b91 | |
Shadowfacts | cbbe9ec11f | |
Shadowfacts | 0e06d47687 | |
Shadowfacts | c907b7257a | |
Shadowfacts | 10239d14c9 | |
Shadowfacts | 2344275ff9 | |
Shadowfacts | e0ffa1d9c5 | |
Shadowfacts | 77a6654ff2 | |
Shadowfacts | 43aee0ec67 | |
Shadowfacts | d95ba82e5b | |
Shadowfacts | b6d8232951 | |
Shadowfacts | bb9cef55ea | |
Shadowfacts | 67718d8fe4 | |
Shadowfacts | 71a2029752 | |
Shadowfacts | 6bb1f3b7dc | |
Shadowfacts | 2469d285bc | |
Shadowfacts | 5f410213e2 | |
Shadowfacts | bb3e1b44b1 | |
Shadowfacts | 868df25417 | |
Shadowfacts | 2801f65e67 | |
Shadowfacts | cccde29e6c | |
Shadowfacts | aa0629d202 | |
Shadowfacts | ba209fa4d2 | |
Shadowfacts | d224f47b8c | |
Shadowfacts | ffb0ceba20 | |
Shadowfacts | 22022f5ef6 | |
Shadowfacts | 1ac72bc363 | |
Shadowfacts | dcc8f38f3d | |
Shadowfacts | 8cf217d2ba | |
Shadowfacts | 7d66117fab | |
Shadowfacts | 9c0c1f87f8 | |
Shadowfacts | 7a2d8e78eb | |
Shadowfacts | c15a5fc90f | |
Shadowfacts | 212ce69ffd | |
Shadowfacts | 7470b053c6 | |
Shadowfacts | d1b4b39e86 | |
Shadowfacts | b43f0d5bd9 | |
Shadowfacts | 035034430e | |
Shadowfacts | a703b7cc0a | |
Shadowfacts | e78bec8409 | |
Shadowfacts | 412e4a4dc5 | |
Shadowfacts | 81e10326d3 | |
Shadowfacts | 20f88ef161 | |
Shadowfacts | bce0f8ef18 | |
Shadowfacts | d661870401 | |
Shadowfacts | afa1a733f4 | |
Shadowfacts | 1b186725ce | |
Shadowfacts | 164a8e26c4 | |
Shadowfacts | cadcc1a92a | |
Shadowfacts | bcb3c24027 | |
Shadowfacts | fd6a4ba41c | |
Shadowfacts | 3ab82b2dbb | |
Shadowfacts | 1ed218d5e3 | |
Shadowfacts | 0fee770411 | |
Shadowfacts | 5b116c0d4e | |
Shadowfacts | b7a4f7e30f | |
Shadowfacts | ba1300b1b7 | |
Shadowfacts | 817ef0c2cc | |
Shadowfacts | 18ee621489 | |
Shadowfacts | ddf5094acf | |
Shadowfacts | 133921848d | |
Shadowfacts | 46db70d58b | |
Shadowfacts | 21958eb77f | |
Shadowfacts | b30f149dc9 | |
Shadowfacts | 9b83566482 | |
Shadowfacts | b688631937 | |
Shadowfacts | 4d654358d7 | |
Shadowfacts | 24e90de672 | |
Shadowfacts | 780e8b09b7 | |
Shadowfacts | 2196663d94 | |
Shadowfacts | 7085ac01cb | |
Shadowfacts | 81671d73c7 | |
Shadowfacts | a38c89a17f | |
Shadowfacts | 253fb8d27d | |
Shadowfacts | a682c8f5cc | |
Shadowfacts | d18a4b3c42 | |
Shadowfacts | 426b31d46c | |
Shadowfacts | 5c09b1910f | |
Shadowfacts | fe72d8faec | |
Shadowfacts | b560bcd8dc | |
Shadowfacts | 85ced7ff5f | |
Shadowfacts | 5ac76ef9c4 | |
Shadowfacts | 123a512d3c | |
Shadowfacts | d141ed7d03 | |
Shadowfacts | 95e120afd6 | |
Shadowfacts | ca8a214cf6 | |
Shadowfacts | 7161861d36 | |
Shadowfacts | c6c8f63e39 | |
Shadowfacts | e9962997a6 | |
Shadowfacts | f2ab1778c5 | |
Shadowfacts | 0f71d61b88 | |
Shadowfacts | 80c4fcce82 | |
Shadowfacts | 8f8d50efbd | |
Shadowfacts | 43b4976ed7 | |
Shadowfacts | ff3681627b | |
Shadowfacts | 35d21fb725 | |
Shadowfacts | bbfb3b0a7a | |
Shadowfacts | 8b78a5e7ad | |
Shadowfacts | 66c17006d1 | |
Shadowfacts | 8a911f238b | |
Shadowfacts | 77c44c323f | |
Shadowfacts | c2d1fe45d8 | |
Shadowfacts | 24591cee05 | |
Shadowfacts | 50dd785ef8 | |
Shadowfacts | af2e95ea39 | |
Shadowfacts | 4fa1bd7268 | |
Shadowfacts | ea07e6aef6 | |
Shadowfacts | 5e7a1e5974 | |
Shadowfacts | 9b3cc61dcb | |
Shadowfacts | 0c37b99a68 | |
Shadowfacts | f96d1d780c | |
Shadowfacts | 5a5364ad3b | |
Shadowfacts | 5b70c713b2 | |
Shadowfacts | efb96eddf3 | |
Shadowfacts | 5cb25c8c1f | |
Shadowfacts | 700cc2c67c | |
Shadowfacts | a9e0bffe5f | |
Shadowfacts | 512e0e9053 | |
Shadowfacts | b842389449 | |
Shadowfacts | cc10a13785 | |
Shadowfacts | f9c3ad5921 | |
Shadowfacts | 0960699699 | |
Shadowfacts | c6e06fe9f3 | |
Shadowfacts | 10f6a68065 | |
Shadowfacts | 037b717e60 | |
Shadowfacts | 9fa352d4f8 | |
Shadowfacts | 73345bb927 | |
Shadowfacts | f5385b0a1d | |
Shadowfacts | 46fbbdc99a | |
Shadowfacts | 6ef8c92d09 | |
Shadowfacts | 08b7cf013b | |
Shadowfacts | f702df2f15 | |
Shadowfacts | 92efee6f46 | |
Shadowfacts | facf039f97 | |
Shadowfacts | d7f35cd1e4 | |
Shadowfacts | 332637e0d9 | |
Shadowfacts | 6d6fd3d49d | |
Shadowfacts | b4675a97c7 | |
Shadowfacts | 02e3417c27 | |
Shadowfacts | f5ac2616ad | |
Shadowfacts | 01bb37b0f6 | |
Shadowfacts | a4d43889ce | |
Shadowfacts | 4991da1622 | |
Shadowfacts | f106cc78bb | |
Shadowfacts | 2617d22819 | |
Shadowfacts | dbdf1d39bd | |
Shadowfacts | 54ff3893a6 | |
Shadowfacts | 0168c05259 | |
Shadowfacts | 65e75afa8b | |
Shadowfacts | 90809811c1 | |
Shadowfacts | 0f6e9c97cc | |
Shadowfacts | 98516e3802 | |
Shadowfacts | 68b03838a2 | |
Shadowfacts | 1f0025b101 | |
Shadowfacts | b46f007f64 | |
Shadowfacts | ecab33bdce | |
Shadowfacts | cc0da2ec54 | |
Shadowfacts | a2868739c2 | |
Shadowfacts | 2f75510889 | |
Shadowfacts | 46332cd1b9 | |
Shadowfacts | 21e9ca990d | |
Shadowfacts | 1a02319894 | |
Shadowfacts | 4a95ccccdb | |
Shadowfacts | d3187ce2c4 | |
Shadowfacts | ed0643c4ad | |
Shadowfacts | 1e2947ceba | |
Shadowfacts | ddcb13dd28 | |
Shadowfacts | c71bf3ba23 | |
Shadowfacts | 3e5c441b24 | |
Shadowfacts | 0b6c16b0a6 | |
Shadowfacts | 5f566724bb | |
Shadowfacts | 4a89ae3cfe | |
Shadowfacts | 56a0518c80 | |
Shadowfacts | bf8a294676 | |
Shadowfacts | c069712c22 | |
Shadowfacts | d04957ba41 | |
Shadowfacts | 8cc08cf4c0 | |
Shadowfacts | 1b917f6bed | |
Shadowfacts | 514e569bd5 | |
Shadowfacts | a22059a1a1 | |
Shadowfacts | 2cfefc9432 | |
Shadowfacts | 2f7c7bae5e | |
Shadowfacts | 3f04d74dd6 | |
Shadowfacts | 4dd8c1d692 | |
Shadowfacts | eb9a5aeb42 | |
Shadowfacts | 7465abe0a9 | |
Shadowfacts | 20dab7c77a | |
Shadowfacts | 4e105e0fbc | |
Shadowfacts | d2f1d78aa2 | |
Shadowfacts | 360f52d0cf | |
Shadowfacts | 8c888906c9 | |
Shadowfacts | d611aeb035 | |
Shadowfacts | 0e888d35eb | |
Shadowfacts | 98bb230817 | |
Shadowfacts | 3d6d9b2a91 | |
Shadowfacts | bc9a700383 | |
Shadowfacts | 62c7a30bbc | |
Shadowfacts | abf6ff8115 | |
Shadowfacts | a718721537 | |
Shadowfacts | 4f99d3c6e1 | |
Shadowfacts | a2fc1652d1 | |
Shadowfacts | 77007dcea0 | |
Shadowfacts | dc818524b2 | |
Shadowfacts | d1ba1105b5 | |
Shadowfacts | 89a9bfba47 | |
Shadowfacts | 2798a199aa | |
Shadowfacts | 3d0402c1e0 | |
Shadowfacts | af0c9c92b6 | |
Shadowfacts | 0a7709526f | |
Shadowfacts | 9ec821f6b3 | |
Shadowfacts | 5c4474dc87 | |
Shadowfacts | 829ecf06da | |
Shadowfacts | cb2bb215d3 | |
Shadowfacts | 916c6fba0d | |
Shadowfacts | 8473f32781 | |
Shadowfacts | 240ccf23a4 | |
Shadowfacts | e49859e5ea | |
Shadowfacts | c6d158a8a3 | |
Shadowfacts | 7e90fe2401 | |
Shadowfacts | cab78a4aa4 | |
Shadowfacts | 7da139be4d | |
Shadowfacts | 2444783edf | |
Shadowfacts | 727615a818 | |
Shadowfacts | 6e3089f025 | |
Shadowfacts | e09b0ff4e3 | |
Shadowfacts | 830eea5e95 | |
Shadowfacts | 705fbbe343 | |
Shadowfacts | 12bcf52764 | |
Shadowfacts | f31c909517 | |
Shadowfacts | 781c37fbae | |
Shadowfacts | 930ec7ccff | |
Shadowfacts | de93d6e171 | |
Shadowfacts | 80c79ded3b | |
Shadowfacts | 126b0ae90a | |
Shadowfacts | d6a847bfcc | |
Shadowfacts | 9b33059089 | |
Shadowfacts | 804fdb439d | |
Shadowfacts | 6ba5f70615 | |
Shadowfacts | 54c01be7ff | |
Shadowfacts | 6e964ff601 | |
Shadowfacts | 73d33ae730 | |
Shadowfacts | 434d975767 | |
Shadowfacts | 41a31c23b7 | |
Shadowfacts | 02461ad46c | |
Shadowfacts | 072e68e97b | |
Shadowfacts | 6879acbe02 | |
Shadowfacts | ace503ad3d | |
Shadowfacts | e12a82b476 | |
Shadowfacts | 51cb7c3edf | |
Shadowfacts | 2198e2bf3e | |
Shadowfacts | 6138fc7748 | |
Shadowfacts | dc1eb3d6f0 | |
Shadowfacts | fa1482a152 | |
Shadowfacts | e65ed3e773 | |
Shadowfacts | eca7f31e82 | |
Shadowfacts | 2b22180191 | |
Shadowfacts | 654b5d9c59 | |
Shadowfacts | 777d1f378c | |
Shadowfacts | 3b132ab4dc | |
Shadowfacts | d1083116e0 | |
Shadowfacts | 7b79cec0ed | |
Shadowfacts | 50cbbb86fc | |
Shadowfacts | 5a914ea5a3 | |
Shadowfacts | ca5ac8b826 | |
Shadowfacts | 2b50609e5c | |
Shadowfacts | 57cb0614a9 | |
Shadowfacts | eccb1043db | |
Shadowfacts | 9768097488 | |
Shadowfacts | f5e9f71586 | |
Shadowfacts | 9f8b14d180 | |
Shadowfacts | 10a3cbbe9c | |
Shadowfacts | b917120f17 | |
Shadowfacts | 30ef9cc6d0 | |
Shadowfacts | 948c792e5d | |
Shadowfacts | 2df703ab71 | |
Shadowfacts | 1ec85ca095 | |
Shadowfacts | 5a26739b78 | |
Shadowfacts | 36a78f1a3c | |
Shadowfacts | 1c0291b1dd | |
Shadowfacts | e7d9e3780e | |
Shadowfacts | 83d4af2303 | |
Shadowfacts | 7c5076d01a | |
Shadowfacts | e61823b78f | |
Shadowfacts | 4d52ac4d34 | |
Shadowfacts | aced0a63c9 | |
Shadowfacts | 1e54235ff5 | |
Shadowfacts | e6e5554edf | |
Shadowfacts | 9026f487ec | |
Shadowfacts | c0097ba752 | |
Shadowfacts | f109253bba | |
Shadowfacts | 1fda4248ec | |
Shadowfacts | 7781c5252b | |
Shadowfacts | 7f4bf52050 | |
Shadowfacts | ba0d179de5 | |
Shadowfacts | 71b6f1bdf0 | |
Shadowfacts | 09ec4a920c | |
Shadowfacts | 7edf0fdb93 | |
Shadowfacts | 99e06441f0 | |
Shadowfacts | 85e1e131f6 | |
Shadowfacts | 1d79918a94 | |
Shadowfacts | 340d13b1fa | |
Shadowfacts | cf1000a4df | |
Shadowfacts | b781b56efd | |
Shadowfacts | 10a8a85bfc | |
Shadowfacts | 6d8a014cc7 | |
Shadowfacts | 60c88ded5e | |
Shadowfacts | 1e7a6af0bf | |
Shadowfacts | f8b79ef34f | |
Shadowfacts | 4cf56685b5 | |
Shadowfacts | fdcd2aa540 | |
Shadowfacts | 667d30a710 | |
Shadowfacts | b0f23e46ba | |
Shadowfacts | 9b30b48016 | |
Shadowfacts | bd49683e13 | |
Shadowfacts | c22945b1e7 | |
Shadowfacts | 0a16a2e261 | |
Shadowfacts | b95819cada | |
Shadowfacts | dc1ea1bed9 | |
Shadowfacts | 5f9fe505d5 | |
Shadowfacts | 5b8e97287e | |
Shadowfacts | 49572c1fec | |
Shadowfacts | ebb0770198 |
|
@ -1,3 +1,5 @@
|
|||
Dist.xcconfig
|
||||
Tusker.xcconfig
|
||||
.DS_Store
|
||||
MyPlayground.playground/
|
||||
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
[submodule "Gifu"]
|
||||
path = Gifu
|
||||
url = git://github.com/kaishin/Gifu.git
|
||||
[submodule "Embassy"]
|
||||
path = Embassy
|
||||
url = https://github.com/envoy/Embassy.git
|
||||
|
|
|
@ -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
|
1171
CHANGELOG.md
1171
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,7 @@
|
|||
# Haptic Feedback
|
||||
|
||||
## Selection changed
|
||||
`UISelectionFeedbackGenerator`
|
||||
|
||||
## Actions
|
||||
On success, `UIImpactFeedbackGenerator` with the `.light` style. On error, `UINotificationFeedbackGenerator` with the `.error` type.
|
|
@ -1,355 +0,0 @@
|
|||
# X-Callback-URLs in Tusker
|
||||
|
||||
Tusker supports inter-app-communication using the [X-Callback-URL standard](http://x-callback-url.com/).
|
||||
|
||||
In short, requests are performed by opening the URL `tusker://x-callback-url/[request]` (where `[request]` is one of the requests listed below) with a variety of parameters.
|
||||
|
||||
## Callbacks
|
||||
|
||||
X-Callback-URLs support three types of callbacks: on success, on cancellation, and on error. Callbacks are specified as query parameters whose keys identify which callback (`x-success`, `x-cancel`, and `x-error`) and whose values are other URLs that should be opened to run the callback.
|
||||
|
||||
Data is passed to callbacks by adding additional query parameters to the callback URL. The `x-error` callback always returns a description of the error in the `error` parameter. Other data is provided depending on the request.
|
||||
|
||||
### JSON Responses
|
||||
|
||||
By default, callback data is included in URL query parameters of the callback URL. If the `json=true` parameter is provided, the response data will be encoded as JSON, converted to [Base64](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding), and provided in the `response` query parameter of the callback.
|
||||
|
||||
## Silent Requests
|
||||
|
||||
Tusker X-Callback-URL requests can be performed silently, without user confirmation. Each source app requires user permission on the first attempted silent action.
|
||||
|
||||
To perform a silent request:
|
||||
|
||||
1. Provide the `silent=true` URL query parameter in the request.
|
||||
|
||||
2. Specify the `x-source` parameter. It must be a (human interpretable) name of the source application/service. If `x-source` is not specified, the error callback will be invoked with the error message:
|
||||
|
||||
```
|
||||
Cannot perform silent action without source app, x-source parameter must be specified.
|
||||
```
|
||||
|
||||
3. Depending on the current permission state of the source app, one of several things will happen:
|
||||
1. If the permission is **undecided** (i.e. the user has neither accepted nor rejected the silent action request), an alert will be displayed notifying the user that the source app has requested permission to silently perform actions. After the user either accepts or rejects the request, execution will continue with that permission state.
|
||||
2. **Accepted**: the request will be carried out silently and the appropriate callback executed.
|
||||
3. **Rejected**: the request will be performed with the confirmation UI, as if the `silent` parameter had been false/unprovided.
|
||||
|
||||
The silent actions permission state of a given source app is not exposed in the callback.
|
||||
|
||||
## Other Notes
|
||||
|
||||
#### Instance-Local IDs
|
||||
|
||||
Instance-local IDs are provided for many responses and accept in place of URLs/URIs/qualified names in many requests. When possible, instance-local IDs should be preferred requests using them can often be performed faster because there's no need to perform a search query or make requests to remote instances.
|
||||
|
||||
#### Qualified Usernames
|
||||
|
||||
Qualified username refers to the domain-qualified identifier of an account. For example, `shadowfacts@social.shadowfacts.net`. They do not include a leading `@`.
|
||||
|
||||
#### Dates
|
||||
|
||||
Dates in responses are encoded as Unix timestamps.
|
||||
|
||||
## Requests
|
||||
|
||||
- [Accounts](#accounts)
|
||||
- [`showAccount`](#showaccount)
|
||||
- [`getCurrentUser`](#getcurrentuser)
|
||||
- [`getAccount`](#getaccount)
|
||||
- [`followUser`](#followuser)
|
||||
- [Statuses](#statuses)
|
||||
- [`showStatus`](#showstatus)
|
||||
- [`getStatus`](#getstatus)
|
||||
- [`postStatus`](#poststatus)
|
||||
- [`favoriteStatus`](#favoritestatus)
|
||||
- [`reblogStatus`](#reblogstatus)
|
||||
- [Notifications](#notifications)
|
||||
- [`getNotification`](#getnotification)
|
||||
- [`getNotifications`](#getnotifications)
|
||||
- [`dismissNotification`](#dismissnotification)
|
||||
- [`dismissAllNotifications`](#dismissallnotifications)
|
||||
- [Instances](#instances)
|
||||
- [`getCurrentInstance`](#getcurrentinstance)
|
||||
- [Misc](#misc)
|
||||
- [`search`](#search)
|
||||
|
||||
### Accounts
|
||||
|
||||
#### `showAccount`
|
||||
|
||||
Presents the given account in Tusker.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `accountID` (string) | The instance-local ID of the account | Yes |
|
||||
| `accountURL` (URL) | The URL of the remote account | Yes |
|
||||
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
No data if successful.
|
||||
|
||||
#### `getCurrentUser`
|
||||
|
||||
Retrieves the currently logged-in user.
|
||||
|
||||
##### Request
|
||||
|
||||
No parameters.
|
||||
|
||||
##### Response:
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------------- | --------------------------------------------- | -------- |
|
||||
| `username` (string) | The [qualified username](#qualifiedusernames) | No |
|
||||
| `displayName` (string) | The display name | No |
|
||||
| `locked` (bool) | Whether the user's account is locked | No |
|
||||
| `followers` (int) | The number of followers the user has | No |
|
||||
| `following` (int) | The number of accounts user is following | No |
|
||||
| `url` (URL) | The URL of the user's account | No |
|
||||
| `avatarURL` (URL) | The URL of the user's avatar image | No |
|
||||
| `headerURL` (URL) | The URL of the user's header image | No |
|
||||
|
||||
#### `getAccount`
|
||||
|
||||
Retrieves the given account details. One of `accountID`, `accountURL`, or `acct` must be provided.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `accountID` (string) | The instance-local ID of the account | Yes |
|
||||
| `accountURL` (URL) | The URL/URI of the account | Yes |
|
||||
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------------- | ------------------------------------------- | -------- |
|
||||
| `username` (string) | The qualified username | No |
|
||||
| `displayName` (string) | The display name | No |
|
||||
| `locked` (bool) | Whether the account is locked | No |
|
||||
| `followers` (int) | The number of followers the account has | No |
|
||||
| `following` (int) | The number of accounts account is following | No |
|
||||
| `url` (URL) | The URL of the account | No |
|
||||
| `avatarURL` (URL) | The URL of the account's avatar image | No |
|
||||
| `headerURL` (URL) | The URL of the account's header image | No |
|
||||
|
||||
#### `followUser`
|
||||
|
||||
Follows the given account from the logged-in user's account. One of `accountID`, `accountURL`, or `acct` must be provided.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `accountID` (string) | The instance-local ID of the account | Yes |
|
||||
| `accountURL` (URL) | The URL/URI of the account | Yes |
|
||||
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------- | ------------------------------- | -------- |
|
||||
| `url` (URL) | The URL of the followed account | No |
|
||||
|
||||
### Statuses
|
||||
|
||||
#### `showStatus`
|
||||
|
||||
Presents the given status in Tusker.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ----------------------------------- | -------- |
|
||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
||||
| `statusURL` (URL) | The URL of a remote status | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
No data if successful.
|
||||
|
||||
#### `getStatus`
|
||||
|
||||
Retrieves the given status details. One of `statusID` or `statusURL` must be provided.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
||||
| `statusURL` (URL) | The URL/URI of the status | Yes |
|
||||
| `html` (bool) | Whether to return the content as HTML or plain-text only. Default: `false` (plain-text). | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `url` (URL) | The URL of the status | Yes |
|
||||
| `uri` (string) | The URI of the status | No |
|
||||
| `id` (string) | The instance-local ID of the status | |
|
||||
| `account` (string) | The [qualified username](#qualifiedusernames) of the account that posted (or reblogged if `reblog` is present) the status | No |
|
||||
| `inReplyTo` (string) | The instance-local ID of the status that this status is a reply to | Yes |
|
||||
| `posted` (date) | The date the status was posted | No |
|
||||
| `content` (string) | The content of the status (HTML if the `html` parameter was true, plain-text otherwise) | No |
|
||||
| `reblog` (string) | The **instance-local** ID of the status that this is a reblog of. If not present, this status was not a reblog. | Yes |
|
||||
|
||||
#### `postStatus`
|
||||
|
||||
Posts a status from the logged-in user's account.
|
||||
|
||||
Can be performed silently.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `mentioning` (bool) | The [qualified username](#qualifiedusernames) to mention in the status | Yes |
|
||||
| `text` (string) | The text to post/pre-fill the status text field with | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ---------------------------- | -------- |
|
||||
| `statusURL` (URL) | The URL of the posted status | Yes |
|
||||
| `statusURI` (string) | The URI of the posted status | No |
|
||||
|
||||
#### `favoriteStatus`
|
||||
|
||||
Favorites the given status from the logged-in user's account. One of `statusID` or `statusURL` must be provided.
|
||||
|
||||
Can be performed silently.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ----------------------------------- | -------- |
|
||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
||||
| `statusURL` (URL) | The URL/URI of a status | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------- | -------- |
|
||||
| `statusURL` (URL) | The URL of the favorited status | Yes |
|
||||
| `statusURI` (string) | The URI of the favorited status | No |
|
||||
|
||||
#### `reblogStatus`
|
||||
|
||||
Reblogs the given status from the logged-in user's account. One of `statusID` or `statusURL` must be provided.
|
||||
|
||||
Can be performed silently.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ----------------------------------- | -------- |
|
||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
||||
| `statusURL` (URL) | The URL/URI of a status | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------- | -------- |
|
||||
| `statusURL` (URL) | The URL of the reblogged status | Yes |
|
||||
| `statusURI` (string) | The URI of the reblogged status | No |
|
||||
|
||||
### Notifications
|
||||
|
||||
#### `getNotification`
|
||||
|
||||
Retrieves the given notification details.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------------- | ----------------------------------------- | -------- |
|
||||
| `notificationID` (string) | The instance-local ID of the notification | No |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `kind` (string) | One of `mention`, `reblog`, `favourite`, or `follow` | No |
|
||||
| `date` (date) | The date the notification was created. | No |
|
||||
| `accountID` (string) | The instance-local ID of the account that sent the notification | No |
|
||||
| `statusID` (string) | The instance-local ID of the status associated with the notification. Not applicable for `kind=follow`. | Yes |
|
||||
|
||||
#### `getNotifications`
|
||||
|
||||
Retrieves the most recent notifications.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------- | ---------------------------------------------------- | -------- |
|
||||
| `count` (int) | The number of notifications to retrieve. Default: 20 | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------------ | ---------------------------------------------------------- | -------- |
|
||||
| `notifications` (string) | A comma-delimited array of instance-local notification IDs | No |
|
||||
|
||||
#### `dismissNotification`
|
||||
|
||||
Dismisses the given notification.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ----------------------- | ----------------------------------------- | -------- |
|
||||
| `notification` (string) | The instance-local ID of the notification | No |
|
||||
|
||||
##### Response
|
||||
|
||||
No response data if successful.
|
||||
|
||||
#### `dismissAllNotifications`
|
||||
|
||||
Dismisses all notifications.
|
||||
|
||||
##### Request
|
||||
|
||||
No parameters.
|
||||
|
||||
##### Response
|
||||
|
||||
No data if successful.
|
||||
|
||||
### Instances
|
||||
|
||||
#### `getCurrentInstance`
|
||||
|
||||
Retrieves the current instance details.
|
||||
|
||||
##### Request
|
||||
|
||||
No parameters.
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------------- | ------------------------------------------------------- | -------- |
|
||||
| `uri` (string) | The instance URI | No |
|
||||
| `name` (string) | The instance name | No |
|
||||
| `description` (string) | The instance description | No |
|
||||
| `contactAccount` (string) | The instance-local ID of the instance's contact account | No |
|
||||
|
||||
|
||||
### Misc
|
||||
|
||||
#### `search`
|
||||
Performs a search in Tusker with the given query
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------- | ------------------------ |--------- |
|
||||
| `query` (string) | The search query to use. | No |
|
||||
|
||||
##### Response
|
||||
|
||||
No data if successful.
|
1
Gifu
1
Gifu
|
@ -1 +0,0 @@
|
|||
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007
|
|
@ -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,397 @@
|
|||
//
|
||||
// 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 WebURL
|
||||
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 url = URL(emoji.url),
|
||||
let (data, _) = try? await URLSession.shared.data(from: 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? {
|
||||
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||
// serializing the WebURL as a string and then having Foundation parse it again),
|
||||
// so, if available, use the system parser which doesn't require another round trip.
|
||||
if let url = try? URL.ParseStrategy().parse(string) {
|
||||
url
|
||||
} else if let web = WebURL(string),
|
||||
let url = URL(web) {
|
||||
url
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
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,8 +24,8 @@
|
|||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||
<string>Action</string>
|
||||
<key>NSExtensionServiceRoleType</key>
|
||||
<string>NSExtensionServiceRoleTypeViewer</string>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
||||
|
@ -35,6 +35,8 @@
|
|||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||
<string>Action</string>
|
||||
<key>NSExtensionServiceAllowsFinderPreviewItem</key>
|
||||
<true/>
|
||||
<key>NSExtensionServiceAllowsTouchBarItem</key>
|
||||
|
|
|
@ -1,401 +0,0 @@
|
|||
//
|
||||
// Client.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
The base Mastodon API client.
|
||||
*/
|
||||
public class Client {
|
||||
|
||||
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
|
||||
|
||||
let baseURL: URL
|
||||
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()
|
||||
|
||||
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()
|
||||
decoder.dateDecodingStrategy = .custom({ (decoder) in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let str = try container.decode(String.self)
|
||||
// for the next time mastodon accidentally changes date formats >.>
|
||||
if let date = formatter.date(from: str) {
|
||||
return date
|
||||
} else if let date = iso8601.date(from: str) {
|
||||
return date
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
|
||||
}
|
||||
})
|
||||
|
||||
return decoder
|
||||
}()
|
||||
|
||||
static let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
encoder.dateEncodingStrategy = .formatted(formatter)
|
||||
return encoder
|
||||
}()
|
||||
|
||||
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.accessToken = accessToken
|
||||
self.session = session
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> URLSessionTask? {
|
||||
guard let request = createURLRequest(request: request) else {
|
||||
completion(.failure(Error.invalidRequest))
|
||||
return nil
|
||||
}
|
||||
|
||||
let task = session.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
completion(.failure(.networkError(error)))
|
||||
return
|
||||
}
|
||||
guard let data = data,
|
||||
let response = response as? HTTPURLResponse else {
|
||||
completion(.failure(.invalidResponse))
|
||||
return
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
||||
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
|
||||
completion(.failure(.invalidModel))
|
||||
return
|
||||
}
|
||||
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
||||
|
||||
completion(.success(result, pagination))
|
||||
}
|
||||
task.resume()
|
||||
return task
|
||||
}
|
||||
|
||||
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
||||
components.path = request.path
|
||||
components.queryItems = request.queryParameters.isEmpty ? nil : request.queryParameters.queryItems
|
||||
guard let url = components.url else { return nil }
|
||||
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
||||
urlRequest.httpMethod = request.method.name
|
||||
urlRequest.httpBody = request.body.data
|
||||
urlRequest.setValue(request.body.mimeType, forHTTPHeaderField: "Content-Type")
|
||||
if let accessToken = accessToken {
|
||||
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
return urlRequest
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
|
||||
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
|
||||
"client_name" => name,
|
||||
"redirect_uris" => redirectURI,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
public func getAccessToken(authorizationCode: String, redirectURI: String, 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
|
||||
]))
|
||||
run(request) { result in
|
||||
defer { completion(result) }
|
||||
guard case let .success(loginSettings, _) = result else { return }
|
||||
|
||||
self.accessToken = loginSettings.accessToken
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Self
|
||||
public static func getSelfAccount() -> Request<Account> {
|
||||
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 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 getCustomEmoji() -> Request<[Emoji]> {
|
||||
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
||||
}
|
||||
|
||||
// MARK: - Accounts
|
||||
public static func getAccount(id: String) -> Request<Account> {
|
||||
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
||||
}
|
||||
|
||||
public static func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil) -> Request<[Account]> {
|
||||
return Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [
|
||||
"q" => query,
|
||||
"limit" => limit,
|
||||
"following" => following
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Blocks
|
||||
public static func getBlocks() -> Request<[Account]> {
|
||||
return Request<[Account]>(method: .get, path: "/api/v1/blocks")
|
||||
}
|
||||
|
||||
public static func getDomainBlocks() -> Request<[String]> {
|
||||
return Request<[String]>(method: .get, path: "api/v1/domain_blocks")
|
||||
}
|
||||
|
||||
public static func block(domain: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||
"domain" => domain
|
||||
]))
|
||||
}
|
||||
|
||||
public static func unblock(domain: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||
"domain" => domain
|
||||
]))
|
||||
}
|
||||
|
||||
// MARK: - Filters
|
||||
public static func getFilters() -> Request<[Filter]> {
|
||||
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
|
||||
}
|
||||
|
||||
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
||||
"phrase" => phrase,
|
||||
"irreversible" => irreversible,
|
||||
"whole_word" => wholeWord,
|
||||
"expires_at" => expiresAt
|
||||
] + "context" => context.contextStrings))
|
||||
}
|
||||
|
||||
public static func getFilter(id: String) -> Request<Filter> {
|
||||
return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Follows
|
||||
public static func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func getFollowSuggestions() -> Request<[Account]> {
|
||||
return Request<[Account]>(method: .get, path: "/api/v1/suggestions")
|
||||
}
|
||||
|
||||
public static func followRemote(acct: String) -> Request<Account> {
|
||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
||||
}
|
||||
|
||||
// MARK: - Lists
|
||||
public static func getLists() -> Request<[List]> {
|
||||
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
||||
}
|
||||
|
||||
public static func getList(id: String) -> Request<List> {
|
||||
return Request<List>(method: .get, path: "/api/v1/lists/\(id)")
|
||||
}
|
||||
|
||||
public static func createList(title: String) -> Request<List> {
|
||||
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
|
||||
}
|
||||
|
||||
// MARK: - Media
|
||||
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
||||
return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
|
||||
"description" => description,
|
||||
"focus" => focus
|
||||
], attachment))
|
||||
}
|
||||
|
||||
// MARK: - Mutes
|
||||
public static func getMutes(range: RequestRange) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
public static func getNotifications(excludeTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||
"exclude_types" => excludeTypes.map { $0.rawValue }
|
||||
)
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func clearNotifications() -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
|
||||
}
|
||||
|
||||
// MARK: - Reports
|
||||
public static func getReports() -> Request<[Report]> {
|
||||
return Request<[Report]>(method: .get, path: "/api/v1/reports")
|
||||
}
|
||||
|
||||
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
|
||||
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
|
||||
"account_id" => account.id,
|
||||
"comment" => comment
|
||||
] + "status_ids" => statuses.map { $0.id }))
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
|
||||
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
||||
"q" => query,
|
||||
"resolve" => resolve,
|
||||
"limit" => limit,
|
||||
] + "types" => types?.map { $0.rawValue })
|
||||
}
|
||||
|
||||
// MARK: - Statuses
|
||||
public static func getStatus(id: String) -> Request<Status> {
|
||||
return Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
|
||||
}
|
||||
|
||||
public static func createStatus(text: String,
|
||||
contentType: StatusContentType = .plain,
|
||||
inReplyTo: String? = nil,
|
||||
media: [Attachment]? = nil,
|
||||
sensitive: Bool? = nil,
|
||||
spoilerText: String? = nil,
|
||||
visibility: Status.Visibility? = nil,
|
||||
language: String? = nil,
|
||||
pollOptions: [String]? = nil,
|
||||
pollExpiresIn: Int? = nil,
|
||||
pollMultiple: Bool? = nil) -> 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,
|
||||
] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions))
|
||||
}
|
||||
|
||||
// MARK: - Timelines
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[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")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
// MARK: - Instance
|
||||
public static func getTrends(limit: Int? = nil) -> Request<[Hashtag]> {
|
||||
let parameters: [Parameter]
|
||||
if let limit = limit {
|
||||
parameters = ["limit" => limit]
|
||||
} else {
|
||||
parameters = []
|
||||
}
|
||||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
|
||||
}
|
||||
|
||||
public static func getFeaturedProfiles(local: Bool, order: DirectoryOrder, offset: Int? = nil, limit: Int? = nil) -> Request<[Account]> {
|
||||
var parameters = [
|
||||
"order" => order.rawValue,
|
||||
"local" => local,
|
||||
]
|
||||
if let offset = offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
if let limit = limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
return Request<[Account]>(method: .get, path: "/api/v1/directory", queryParameters: parameters)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Client {
|
||||
public enum Error: LocalizedError {
|
||||
case networkError(Swift.Error)
|
||||
case unexpectedStatus(Int)
|
||||
case invalidRequest
|
||||
case invalidResponse
|
||||
case invalidModel
|
||||
case mastodonError(String)
|
||||
|
||||
public var localizedDescription: String {
|
||||
switch self {
|
||||
case .networkError(let error):
|
||||
return "Network Error: \(error.localizedDescription)"
|
||||
// todo: support more status codes
|
||||
case .unexpectedStatus(413):
|
||||
return "HTTP 413: Payload Too Large"
|
||||
case .unexpectedStatus(let code):
|
||||
return "HTTP Code \(code)"
|
||||
case .invalidRequest:
|
||||
return "Invalid Request"
|
||||
case .invalidResponse:
|
||||
return "Invalid Response"
|
||||
case .invalidModel:
|
||||
return "Invalid Model"
|
||||
case .mastodonError(let error):
|
||||
return "Server Error: \(error)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
|
@ -1,48 +0,0 @@
|
|||
//
|
||||
// Emoji.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Emoji: Codable {
|
||||
public let shortcode: String
|
||||
public let url: URL
|
||||
public let staticURL: URL
|
||||
public let visibleInPicker: Bool
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.shortcode = try container.decode(String.self, forKey: .shortcode)
|
||||
if let url = try? container.decode(URL.self, forKey: .url) {
|
||||
self.url = url
|
||||
} else {
|
||||
let str = try container.decode(String.self, forKey: .url)
|
||||
self.url = URL(string: str.replacingOccurrences(of: " ", with: "%20"))!
|
||||
}
|
||||
if let url = try? container.decode(URL.self, forKey: .staticURL) {
|
||||
self.staticURL = url
|
||||
} else {
|
||||
let staticStr = try container.decode(String.self, forKey: .staticURL)
|
||||
self.staticURL = URL(string: staticStr.replacingOccurrences(of: " ", with: "%20"))!
|
||||
}
|
||||
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case shortcode
|
||||
case url
|
||||
case staticURL = "static_url"
|
||||
case visibleInPicker = "visible_in_picker"
|
||||
}
|
||||
}
|
||||
|
||||
extension Emoji: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
return ":\(shortcode):"
|
||||
}
|
||||
}
|
|
@ -1,84 +0,0 @@
|
|||
//
|
||||
// Hashtag.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Hashtag: Codable {
|
||||
public let name: String
|
||||
public let url: URL
|
||||
public let history: [History]?
|
||||
|
||||
public init(name: String, url: URL) {
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.history = nil
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case url
|
||||
case history
|
||||
}
|
||||
}
|
||||
|
||||
extension Hashtag {
|
||||
public class History: Codable {
|
||||
public let day: Date
|
||||
public let uses: Int
|
||||
public let accounts: Int
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
if let day = try? container.decode(Date.self, forKey: .day) {
|
||||
self.day = day
|
||||
} else if let unixTimestamp = try? container.decode(Double.self, forKey: .day) {
|
||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
||||
} else if let str = try? container.decode(String.self, forKey: .day),
|
||||
let unixTimestamp = Double(str) {
|
||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .day, in: container, debugDescription: "day must be either date or UNIX timestamp")
|
||||
}
|
||||
|
||||
if let uses = try? container.decode(Int.self, forKey: .uses) {
|
||||
self.uses = uses
|
||||
} else if let str = try? container.decode(String.self, forKey: .uses),
|
||||
let uses = Int(str) {
|
||||
self.uses = uses
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .uses, in: container, debugDescription: "uses must either be int or string containing int")
|
||||
}
|
||||
|
||||
if let accounts = try? container.decode(Int.self, forKey: .accounts) {
|
||||
self.accounts = accounts
|
||||
} else if let str = try? container.decode(String.self, forKey: .accounts),
|
||||
let accounts = Int(str) {
|
||||
self.accounts = accounts
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .accounts, in: container, debugDescription: "accounts must either be int or string containing int")
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case day
|
||||
case uses
|
||||
case accounts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Hashtag: Equatable, Hashable {
|
||||
public static func ==(lhs: Hashtag, rhs: Hashtag) -> Bool {
|
||||
return lhs.name == rhs.name
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
//
|
||||
// Instance.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Instance: Decodable {
|
||||
public let uri: String
|
||||
public let title: String
|
||||
public let description: String
|
||||
public let email: String?
|
||||
public let version: String
|
||||
public let urls: [String: URL]
|
||||
public let thumbnail: URL?
|
||||
public let languages: [String]?
|
||||
public let stats: Stats?
|
||||
|
||||
// pleroma doesn't currently implement these
|
||||
public let contactAccount: Account?
|
||||
|
||||
// MARK: Unofficial additions to the Mastodon API.
|
||||
public let maxStatusCharacters: Int?
|
||||
|
||||
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.uri = try container.decode(String.self, forKey: .uri)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
self.description = try container.decode(String.self, forKey: .description)
|
||||
self.email = try container.decodeIfPresent(String.self, forKey: .email)
|
||||
self.version = try container.decode(String.self, forKey: .version)
|
||||
if let urls = try? container.decodeIfPresent([String: URL].self, forKey: .urls) {
|
||||
self.urls = urls
|
||||
} else {
|
||||
self.urls = [:]
|
||||
}
|
||||
|
||||
self.languages = try? container.decodeIfPresent([String].self, forKey: .languages)
|
||||
self.contactAccount = try? container.decodeIfPresent(Account.self, forKey: .contactAccount)
|
||||
|
||||
self.stats = try? container.decodeIfPresent(Stats.self, forKey: .stats)
|
||||
self.thumbnail = try? container.decodeIfPresent(URL.self, forKey: .thumbnail)
|
||||
if let maxStatusCharacters = try? container.decodeIfPresent(Int.self, forKey: .maxStatusCharacters) {
|
||||
self.maxStatusCharacters = maxStatusCharacters
|
||||
} else if let str = try? container.decodeIfPresent(String.self, forKey: .maxStatusCharacters),
|
||||
let maxStatusCharacters = Int(str, radix: 10) {
|
||||
self.maxStatusCharacters = maxStatusCharacters
|
||||
} else {
|
||||
self.maxStatusCharacters = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case uri
|
||||
case title
|
||||
case description
|
||||
case email
|
||||
case version
|
||||
case urls
|
||||
case thumbnail
|
||||
case languages
|
||||
case stats
|
||||
|
||||
case contactAccount = "contact_account"
|
||||
|
||||
case maxStatusCharacters = "max_toot_chars"
|
||||
}
|
||||
}
|
||||
|
||||
extension Instance {
|
||||
public class Stats: Decodable {
|
||||
public let domainCount: Int?
|
||||
public let statusCount: Int?
|
||||
public let userCount: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case domainCount = "domain_count"
|
||||
case statusCount = "status_count"
|
||||
case userCount = "user_count"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
//
|
||||
// List.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class List: Decodable, Equatable, Hashable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
|
||||
public var timeline: Timeline {
|
||||
return .list(id: id)
|
||||
}
|
||||
|
||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func update(_ list: List, title: String) -> Request<List> {
|
||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
|
||||
}
|
||||
|
||||
public static func delete(_ list: List) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
|
||||
}
|
||||
|
||||
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
||||
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
//
|
||||
// MastodonError.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct MastodonError: Decodable, CustomStringConvertible {
|
||||
var description: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case description = "error"
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
//
|
||||
// Mention.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Mention: Codable {
|
||||
public let url: URL
|
||||
public let username: String
|
||||
public let acct: String
|
||||
public let id: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url
|
||||
case username
|
||||
case acct
|
||||
case id
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
//
|
||||
// PushSubscription.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class PushSubscription: Decodable {
|
||||
public let id: String
|
||||
public let endpoint: URL
|
||||
public let serverKey: String
|
||||
// TODO: WTF is this?
|
||||
// public let alerts
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case endpoint
|
||||
case serverKey = "server_key"
|
||||
// case alerts
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
//
|
||||
// Relationship.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Relationship: Decodable {
|
||||
public let id: String
|
||||
public let following: Bool
|
||||
public let followedBy: Bool
|
||||
public let blocking: Bool
|
||||
public let muting: Bool
|
||||
public let mutingNotifications: Bool
|
||||
public let followRequested: Bool
|
||||
public let domainBlocking: Bool
|
||||
public let showingReblogs: Bool
|
||||
public let endorsed: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case following
|
||||
case followedBy = "followed_by"
|
||||
case blocking
|
||||
case muting
|
||||
case mutingNotifications = "muting_notifications"
|
||||
case followRequested = "requested"
|
||||
case domainBlocking = "domain_blocking"
|
||||
case showingReblogs = "showing_reblogs"
|
||||
case endorsed
|
||||
}
|
||||
}
|
|
@ -1,149 +0,0 @@
|
|||
//
|
||||
// Status.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class Status: /*StatusProtocol,*/ Decodable {
|
||||
public let id: String
|
||||
public let uri: String
|
||||
public let url: URL?
|
||||
public let account: Account
|
||||
public let inReplyToID: String?
|
||||
public let inReplyToAccountID: String?
|
||||
public let reblog: Status?
|
||||
public let content: String
|
||||
public let createdAt: Date
|
||||
public let emojis: [Emoji]
|
||||
// TODO: missing from pleroma
|
||||
// public let repliesCount: Int
|
||||
public let reblogsCount: Int
|
||||
public let favouritesCount: Int
|
||||
public let reblogged: Bool?
|
||||
public let favourited: Bool?
|
||||
public let muted: Bool?
|
||||
public let sensitive: Bool
|
||||
public let spoilerText: String
|
||||
public let visibility: Visibility
|
||||
public let attachments: [Attachment]
|
||||
public let mentions: [Mention]
|
||||
public let hashtags: [Hashtag]
|
||||
public let application: Application?
|
||||
public let language: String?
|
||||
public let pinned: Bool?
|
||||
public let bookmarked: Bool?
|
||||
public let card: Card?
|
||||
public let poll: Poll?
|
||||
|
||||
public var applicationName: String? { application?.name }
|
||||
|
||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
|
||||
}
|
||||
|
||||
public static func getCard(_ status: Status) -> Request<Card> {
|
||||
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
|
||||
}
|
||||
|
||||
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func delete(_ status: Status) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
|
||||
}
|
||||
|
||||
public static func reblog(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
|
||||
}
|
||||
|
||||
public static func unreblog(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
|
||||
}
|
||||
|
||||
public static func favourite(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
|
||||
}
|
||||
|
||||
public static func unfavourite(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
|
||||
}
|
||||
|
||||
public static func pin(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/pin")
|
||||
}
|
||||
|
||||
public static func unpin(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unpin")
|
||||
}
|
||||
|
||||
public static func bookmark(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/bookmark")
|
||||
}
|
||||
|
||||
public static func unbookmark(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
|
||||
}
|
||||
|
||||
public static func muteConversation(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/mute")
|
||||
}
|
||||
|
||||
public static func unmuteConversation(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case uri
|
||||
case url
|
||||
case account
|
||||
case inReplyToID = "in_reply_to_id"
|
||||
case inReplyToAccountID = "in_reply_to_account_id"
|
||||
case reblog
|
||||
case content
|
||||
case createdAt = "created_at"
|
||||
case emojis
|
||||
// case repliesCount = "replies_count"
|
||||
case reblogsCount = "reblogs_count"
|
||||
case favouritesCount = "favourites_count"
|
||||
case reblogged
|
||||
case favourited
|
||||
case muted
|
||||
case sensitive
|
||||
case spoilerText = "spoiler_text"
|
||||
case visibility
|
||||
case attachments = "media_attachments"
|
||||
case mentions
|
||||
case hashtags = "tags"
|
||||
case application
|
||||
case language
|
||||
case pinned
|
||||
case bookmarked
|
||||
case card
|
||||
case poll
|
||||
}
|
||||
}
|
||||
|
||||
extension Status {
|
||||
public enum Visibility: String, Codable, CaseIterable {
|
||||
case `public`
|
||||
case unlisted
|
||||
case `private`
|
||||
case direct
|
||||
}
|
||||
}
|
||||
|
||||
extension Status: Identifiable {}
|
|
@ -1,19 +0,0 @@
|
|||
//
|
||||
// Pachyderm.h
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
//! Project version number for Pachyderm.
|
||||
FOUNDATION_EXPORT double PachydermVersionNumber;
|
||||
|
||||
//! Project version string for Pachyderm.
|
||||
FOUNDATION_EXPORT const unsigned char PachydermVersionString[];
|
||||
|
||||
// In this header, you should import all the public headers of your framework using statements like #import <Pachyderm/PublicHeader.h>
|
||||
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
//
|
||||
// InstanceType.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/11/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum InstanceType {
|
||||
case mastodon, pleroma
|
||||
}
|
||||
|
||||
public extension Instance {
|
||||
var instanceType: InstanceType {
|
||||
let lowercased = version.lowercased()
|
||||
if lowercased.contains("pleroma") {
|
||||
return .pleroma
|
||||
} else {
|
||||
return .mastodon
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
//
|
||||
// NotificationGroup.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/5/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class NotificationGroup {
|
||||
public let notifications: [Notification]
|
||||
public let id: String
|
||||
public let kind: Notification.Kind
|
||||
public let statusState: StatusState?
|
||||
|
||||
init?(notifications: [Notification]) {
|
||||
guard !notifications.isEmpty else { return nil }
|
||||
self.notifications = notifications
|
||||
self.id = notifications.first!.id
|
||||
self.kind = notifications.first!.kind
|
||||
if kind == .mention {
|
||||
self.statusState = .unknown
|
||||
} else {
|
||||
self.statusState = nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
var groups = [[Notification]]()
|
||||
for notification in notifications {
|
||||
if allowedTypes.contains(notification.kind) {
|
||||
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
|
||||
groups[groups.count - 1].append(notification)
|
||||
continue
|
||||
} else if groups.count >= 2 {
|
||||
let secondToLastGroup = groups[groups.count - 2]
|
||||
if allowedTypes.contains(groups[groups.count - 1][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
|
||||
groups[groups.count - 2].append(notification)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.append([notification])
|
||||
}
|
||||
return groups.map {
|
||||
NotificationGroup(notifications: $0)!
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension NotificationGroup: Identifiable {}
|
|
@ -1,22 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
|
@ -1,34 +0,0 @@
|
|||
//
|
||||
// PachydermTests.swift
|
||||
// PachydermTests
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Pachyderm
|
||||
|
||||
class PachydermTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
func testExample() {
|
||||
// This is an example of a functional test case.
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
func testPerformanceExample() {
|
||||
// This is an example of a performance test case.
|
||||
self.measure {
|
||||
// Put the code you want to measure the time of here.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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) -> 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 += 23 // 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,19 +1,19 @@
|
|||
//
|
||||
// StatusFormat.swift
|
||||
// Tusker
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 1/12/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
enum StatusFormat: CaseIterable {
|
||||
case italics, bold, strikethrough, code
|
||||
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,26 +23,16 @@ enum StatusFormat: CaseIterable {
|
|||
}
|
||||
}
|
||||
|
||||
var image: UIImage? {
|
||||
let name: String
|
||||
var imageName: String {
|
||||
switch self {
|
||||
case .italics:
|
||||
name = "italic"
|
||||
return "italic"
|
||||
case .bold:
|
||||
name = "bold"
|
||||
return "bold"
|
||||
case .strikethrough:
|
||||
name = "strikethrough"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return UIImage(systemName: name)
|
||||
}
|
||||
|
||||
var title: (String, [NSAttributedString.Key: Any])? {
|
||||
if self == .code {
|
||||
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
|
||||
} else {
|
||||
return nil
|
||||
return "strikethrough"
|
||||
case .code:
|
||||
return "chevron.left.forwardslash.chevron.right"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,7 +52,7 @@ enum StatusFormat: 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,
|
||||
|
@ -37,8 +37,9 @@ extension ComposeTextViewCaretScrolling {
|
|||
rectToMakeVisible.origin.y -= cursorRect.height
|
||||
rectToMakeVisible.size.height *= 3
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
//
|
||||
// ReplyStatusView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/25/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
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?
|
||||
|
||||
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 {
|
||||
controller.displayNameLabel(status.account, .body, 17)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
|
||||
Text(verbatim: "@\(status.account.acct)")
|
||||
.font(.body.weight(.light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: DisplayNameHeightPrefKey.self, value: proxy.size.height)
|
||||
.onPreferenceChange(DisplayNameHeightPrefKey.self) { newValue in
|
||||
displayNameHeight = newValue
|
||||
}
|
||||
})
|
||||
|
||||
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 {
|
||||
contentHeight = newHeight
|
||||
}
|
||||
}
|
||||
.frame(height: contentHeight ?? 0)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 50, alignment: .top)
|
||||
}
|
||||
|
||||
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
|
||||
// using a coordinate space declared outside of the List doesn't work, so we do the math ourselves
|
||||
let globalFrame = geometry.frame(in: .global)
|
||||
let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY)
|
||||
|
||||
// add rowTopInset so that the image is always at least rowTopInset away from the top
|
||||
var offset = scrollOffset + rowTopInset
|
||||
|
||||
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
|
||||
offset = max(offset, 0)
|
||||
|
||||
// subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view
|
||||
let maxOffset = max((contentHeight ?? 0) + (displayNameHeight ?? 0) - 50, 0)
|
||||
|
||||
// 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 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 {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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,34 @@
|
|||
// 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: "Duckable",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "Duckable",
|
||||
targets: ["Duckable"]),
|
||||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
// .package(url: /* package url */, from: "1.0.0"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "Duckable",
|
||||
dependencies: [],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
// .testTarget(
|
||||
// name: "DuckableTests",
|
||||
// dependencies: ["Duckable"]),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
# Duckable
|
||||
|
||||
A package that allows modally-presented view controllers to be 'ducked' to make the content behind them accessible (à la Mail.app).
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// API.swift
|
||||
// Duckable
|
||||
//
|
||||
// Created by Shadowfacts on 11/7/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public protocol DuckableViewController: UIViewController {
|
||||
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
||||
|
||||
func duckableViewControllerMayAttemptToDuck()
|
||||
|
||||
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
|
||||
|
||||
func duckableViewControllerDidFinishAnimatingDuck()
|
||||
}
|
||||
|
||||
extension DuckableViewController {
|
||||
public func duckableViewControllerShouldDuck() -> DuckAttemptAction { .duck }
|
||||
public func duckableViewControllerMayAttemptToDuck() {}
|
||||
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
|
||||
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
||||
}
|
||||
|
||||
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, 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: completion)
|
||||
return true
|
||||
} else {
|
||||
cur = vc.parent
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// DetentIdentifier.swift
|
||||
// Duckable
|
||||
//
|
||||
// Created by Shadowfacts on 11/7/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UISheetPresentationController.Detent.Identifier {
|
||||
static let bottom = Self("\(Bundle.main.bundleIdentifier!).bottom")
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
//
|
||||
// DuckAnimationController.swift
|
||||
// Duckable
|
||||
//
|
||||
// Created by Shadowfacts on 11/7/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
let owner: DuckableContainerViewController
|
||||
let needsShrinkAnimation: Bool
|
||||
|
||||
init(owner: DuckableContainerViewController, needsShrinkAnimation: Bool) {
|
||||
self.owner = owner
|
||||
self.needsShrinkAnimation = needsShrinkAnimation
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.2
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard case .ducked(let duckable, placeholder: let placeholder) = owner.state,
|
||||
let presented = transitionContext.viewController(forKey: .from) else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard transitionContext.isAnimated else {
|
||||
transitionContext.completeTransition(true)
|
||||
return
|
||||
}
|
||||
|
||||
let container = transitionContext.containerView
|
||||
|
||||
|
||||
if needsShrinkAnimation {
|
||||
|
||||
duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0.2)
|
||||
|
||||
let presentedFrameInContainer = container.convert(presented.view.bounds, from: presented.view)
|
||||
let heightToSlide = container.bounds.height - container.safeAreaInsets.bottom - detentHeight - presentedFrameInContainer.minY
|
||||
|
||||
let slideAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1)
|
||||
slideAnimator.addAnimations {
|
||||
container.transform = CGAffineTransform(translationX: 0, y: heightToSlide + 10)
|
||||
}
|
||||
slideAnimator.addCompletion { _ in
|
||||
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
slideAnimator.startAnimation()
|
||||
|
||||
placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10)
|
||||
let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||
placeholder.view.transform = .identity
|
||||
container.transform = CGAffineTransform(translationX: 0, y: heightToSlide)
|
||||
}
|
||||
bounceAnimator.startAnimation(afterDelay: 0.3)
|
||||
|
||||
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||
presented.view.layer.opacity = 0
|
||||
}
|
||||
fadeAnimator.addCompletion { _ in
|
||||
presented.view.layer.opacity = 1
|
||||
}
|
||||
fadeAnimator.startAnimation(afterDelay: 0.3)
|
||||
|
||||
} else {
|
||||
|
||||
duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0)
|
||||
|
||||
placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10)
|
||||
let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||
placeholder.view.transform = .identity
|
||||
container.transform = CGAffineTransform(translationX: 0, y: -10)
|
||||
}
|
||||
bounceAnimator.startAnimation(afterDelay: 0.2)
|
||||
|
||||
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||
presented.view.layer.opacity = 0
|
||||
}
|
||||
fadeAnimator.addCompletion { _ in
|
||||
presented.view.layer.opacity = 1
|
||||
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
fadeAnimator.startAnimation(afterDelay: 0.2)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,258 @@
|
|||
//
|
||||
// DuckableContainerViewController.swift
|
||||
// Duckable
|
||||
//
|
||||
// Created by Shadowfacts on 11/7/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
let duckedCornerRadius: CGFloat = 10
|
||||
let detentHeight: CGFloat = 44
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
public class DuckableContainerViewController: UIViewController {
|
||||
|
||||
public let child: UIViewController
|
||||
private var bottomConstraint: NSLayoutConstraint!
|
||||
private(set) var state = State.idle
|
||||
|
||||
public var duckedViewController: DuckableViewController? {
|
||||
if case .ducked(let vc, placeholder: _) = state {
|
||||
return vc
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public init(child: UIViewController) {
|
||||
self.child = child
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
swizzleSheetController()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .black
|
||||
|
||||
child.beginAppearanceTransition(true, animated: false)
|
||||
addChild(child)
|
||||
child.didMove(toParent: self)
|
||||
child.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(child.view)
|
||||
child.endAppearanceTransition()
|
||||
|
||||
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
NSLayoutConstraint.activate([
|
||||
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
child.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
bottomConstraint,
|
||||
])
|
||||
}
|
||||
|
||||
func _presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
|
||||
guard case .idle = state else {
|
||||
if animated,
|
||||
case .ducked(_, placeholder: let placeholder) = state {
|
||||
#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) {
|
||||
placeholder.topConstraint.constant = origConstant - 20
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
|
||||
placeholder.topConstraint.constant = origConstant
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if isDucked {
|
||||
state = .ducked(viewController, placeholder: createPlaceholderForDuckedViewController(viewController))
|
||||
configureChildForDuckedPlaceholder()
|
||||
} else {
|
||||
state = .presentingDucked(viewController, isFirstPresentation: true)
|
||||
doPresentDuckable(viewController, animated: animated, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||
viewController.modalPresentationStyle = .custom
|
||||
viewController.transitioningDelegate = self
|
||||
present(viewController, animated: animated) {
|
||||
self.configureChildForDuckedPlaceholder()
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
func dismissalTransitionWillBegin() {
|
||||
guard case .presentingDucked(_, _) = state else {
|
||||
return
|
||||
}
|
||||
state = .idle
|
||||
bottomConstraint.isActive = false
|
||||
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
bottomConstraint.isActive = true
|
||||
child.view.layer.cornerRadius = 0
|
||||
setOverrideTraitCollection(nil, forChild: child)
|
||||
}
|
||||
|
||||
func createPlaceholderForDuckedViewController(_ viewController: DuckableViewController) -> DuckedPlaceholderViewController {
|
||||
let placeholder = DuckedPlaceholderViewController(for: viewController, owner: self)
|
||||
placeholder.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
placeholder.beginAppearanceTransition(true, animated: false)
|
||||
self.addChild(placeholder)
|
||||
placeholder.didMove(toParent: self)
|
||||
self.view.addSubview(placeholder.view)
|
||||
placeholder.endAppearanceTransition()
|
||||
|
||||
let placeholderTopConstraint = placeholder.view.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight)
|
||||
placeholder.topConstraint = placeholderTopConstraint
|
||||
NSLayoutConstraint.activate([
|
||||
placeholder.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
|
||||
placeholder.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
|
||||
placeholder.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
|
||||
placeholderTopConstraint
|
||||
])
|
||||
|
||||
// otherwise the layout changes get lumped in with the system animation
|
||||
UIView.performWithoutAnimation {
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
return placeholder
|
||||
}
|
||||
|
||||
func duckViewController() {
|
||||
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
|
||||
return
|
||||
}
|
||||
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() {
|
||||
bottomConstraint.isActive = false
|
||||
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
|
||||
bottomConstraint.isActive = true
|
||||
|
||||
child.view.layer.cornerRadius = duckedCornerRadius
|
||||
child.view.layer.cornerCurve = .continuous
|
||||
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||
child.view.layer.masksToBounds = true
|
||||
}
|
||||
|
||||
@objc func unduckViewController() {
|
||||
guard case .ducked(let viewController, placeholder: let placeholder) = state else {
|
||||
return
|
||||
}
|
||||
state = .presentingDucked(viewController, isFirstPresentation: false)
|
||||
doPresentDuckable(viewController, animated: true) {
|
||||
placeholder.view.removeFromSuperview()
|
||||
placeholder.willMove(toParent: nil)
|
||||
placeholder.removeFromParent()
|
||||
}
|
||||
}
|
||||
|
||||
func sheetOffsetDidChange() {
|
||||
if case .presentingDucked(let duckable, isFirstPresentation: _) = state {
|
||||
duckable.duckableViewControllerMayAttemptToDuck()
|
||||
}
|
||||
}
|
||||
|
||||
enum State {
|
||||
case idle
|
||||
case presentingDucked(DuckableViewController, isFirstPresentation: Bool)
|
||||
case ducked(DuckableViewController, placeholder: DuckedPlaceholderViewController)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
|
||||
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
||||
let controller = DuckableSheetPresentationController(presentedViewController: presented, presenting: presenting)
|
||||
controller.delegate = self
|
||||
controller.prefersGrabberVisible = true
|
||||
controller.selectedDetentIdentifier = .large
|
||||
controller.largestUndimmedDetentIdentifier = .bottom
|
||||
controller.detents = [
|
||||
.custom(identifier: .bottom, resolver: { context in
|
||||
return detentHeight
|
||||
}),
|
||||
.large(),
|
||||
]
|
||||
return controller
|
||||
}
|
||||
|
||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
if case .ducked(_, placeholder: _) = state {
|
||||
return DuckAnimationController(
|
||||
owner: self,
|
||||
needsShrinkAnimation: isDetentChangingDueToGrabberAction
|
||||
)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
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?) {
|
||||
guard let snapshot = child.view.snapshotView(afterScreenUpdates: false) else {
|
||||
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
|
||||
return
|
||||
}
|
||||
snapshot.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.view.addSubview(snapshot)
|
||||
NSLayoutConstraint.activate([
|
||||
snapshot.leadingAnchor.constraint(equalTo: child.view.leadingAnchor),
|
||||
snapshot.trailingAnchor.constraint(equalTo: child.view.trailingAnchor),
|
||||
snapshot.topAnchor.constraint(equalTo: child.view.topAnchor),
|
||||
snapshot.bottomAnchor.constraint(equalTo: child.view.bottomAnchor),
|
||||
])
|
||||
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
|
||||
transitionCoordinator!.animate { context in
|
||||
snapshot.layer.opacity = 0
|
||||
} completion: { _ in
|
||||
snapshot.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
public func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
|
||||
if sheetPresentationController.selectedDetentIdentifier == .bottom {
|
||||
duckViewController()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
//
|
||||
// DuckedPlaceholderView.swift
|
||||
// Duckable
|
||||
//
|
||||
// Created by Shadowfacts on 11/7/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
class DuckedPlaceholderViewController: UIViewController {
|
||||
private unowned let owner: DuckableContainerViewController
|
||||
private let navBar = UINavigationBar()
|
||||
|
||||
var topConstraint: NSLayoutConstraint!
|
||||
|
||||
init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) {
|
||||
self.owner = owner
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
let item = UINavigationItem()
|
||||
item.title = duckableViewController.navigationItem.title
|
||||
item.titleView = duckableViewController.navigationItem.titleView
|
||||
navBar.setItems([item], animated: false)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setBackgroundColor()
|
||||
view.layer.cornerRadius = duckedCornerRadius
|
||||
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
view.layer.shadowColor = UIColor.black.cgColor
|
||||
view.layer.shadowOpacity = 0.05
|
||||
|
||||
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(placeholderTapped)))
|
||||
|
||||
let appearance = UINavigationBarAppearance()
|
||||
appearance.configureWithTransparentBackground()
|
||||
navBar.standardAppearance = appearance
|
||||
navBar.isUserInteractionEnabled = false
|
||||
navBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(navBar)
|
||||
NSLayoutConstraint.activate([
|
||||
navBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
navBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
navBar.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
setBackgroundColor()
|
||||
}
|
||||
|
||||
private func setBackgroundColor() {
|
||||
// when just using .systemBackground and setting the override trait collection for the placeholder VC,
|
||||
// the color doesn't change until after the dismiss animation occurs (but only when tapping the grabber to duck, not when swiping)
|
||||
view.backgroundColor = .systemBackground.resolvedColor(with: UITraitCollection(traitsFrom: [traitCollection, UITraitCollection(userInterfaceLevel: .elevated)]))
|
||||
}
|
||||
|
||||
@objc private func placeholderTapped() {
|
||||
owner.unduckViewController()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// Swizzler.swift
|
||||
// Duckable
|
||||
//
|
||||
// Created by Shadowfacts on 11/7/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
private var hasInitialized = false
|
||||
var isDetentChangingDueToGrabberAction = false
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
func swizzleSheetController() {
|
||||
guard !hasInitialized else {
|
||||
return
|
||||
}
|
||||
hasInitialized = true
|
||||
|
||||
var originalIMP: IMP?
|
||||
let imp = imp_implementationWithBlock({ (self: UISheetPresentationController, param: AnyObject) in
|
||||
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UISheetPresentationController, AnyObject) -> Void).self)
|
||||
isDetentChangingDueToGrabberAction = true
|
||||
original(self, param)
|
||||
isDetentChangingDueToGrabberAction = false
|
||||
} as @convention(block) (UISheetPresentationController, AnyObject) -> Void)
|
||||
let sel = [":", "PrimaryAction", "GrabberDidTrigger", "dropShadowView", "_"].reversed().joined()
|
||||
originalIMP = class_replaceMethod(UISheetPresentationController.self, Selector(sel), imp, "v@:@")
|
||||
if originalIMP == nil {
|
||||
os_log(.fault, log: .default, "Unable to initialize Duckable grabber tap hook")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -0,0 +1,32 @@
|
|||
// 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"]),
|
||||
],
|
||||
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",
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
.testTarget(
|
||||
name: "GalleryVCTests",
|
||||
dependencies: ["GalleryVC"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// 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 canAnimateFromSourceView: Bool { 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 canAnimateFromSourceView: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
}
|
||||
|
||||
func galleryContentDidAppear() {
|
||||
}
|
||||
|
||||
func galleryContentWillDisappear() {
|
||||
}
|
||||
}
|
|
@ -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,140 @@
|
|||
//
|
||||
// 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.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
|
||||
animateCrossFadeTransition(using: transitionContext)
|
||||
return
|
||||
}
|
||||
|
||||
let container = transitionContext.containerView
|
||||
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
|
||||
} else {
|
||||
appliedSourceToDestTransform = false
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
from.view.frame = container.bounds
|
||||
container.addSubview(from.view)
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.layer.masksToBounds = true
|
||||
container.addSubview(content.view)
|
||||
|
||||
content.view.frame = destFrameInContainer
|
||||
content.view.layer.opacity = 1
|
||||
|
||||
container.layoutIfNeeded()
|
||||
|
||||
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 springs 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
|
||||
}
|
||||
content.view.frame = sourceFrameInContainer
|
||||
content.view.layer.opacity = 0
|
||||
|
||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
animator.addCompletion { _ in
|
||||
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,83 @@
|
|||
//
|
||||
// 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?
|
||||
|
||||
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!
|
||||
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)
|
||||
|
||||
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,570 @@
|
|||
//
|
||||
// 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
|
||||
|
||||
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,136 @@
|
|||
//
|
||||
// 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.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions {
|
||||
animateCrossFadeTransition(using: transitionContext)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
container.insertSubview(content.view, belowSubview: to.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: content.view)
|
||||
|
||||
to.view.backgroundColor = nil
|
||||
to.view.layer.opacity = 0
|
||||
content.view.frame = sourceFrameInContainer
|
||||
content.view.layer.opacity = 0
|
||||
|
||||
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)
|
||||
// rougly equivalent to duration: 0.35, bounce: 0.3
|
||||
let spring = 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
|
||||
|
||||
content.view.frame = destFrameInContainer
|
||||
content.view.layer.opacity = 1
|
||||
|
||||
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
|
||||
|
||||
if let sourceToDestTransform {
|
||||
self.sourceView.transform = sourceToDestTransform
|
||||
}
|
||||
}
|
||||
|
||||
animator.addCompletion { _ in
|
||||
dimmingView.removeFromSuperview()
|
||||
|
||||
to.view.backgroundColor = .black
|
||||
|
||||
if sourceToDestTransform != nil {
|
||||
self.sourceView.transform = origSourceTransform
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue