Compare commits
1852 Commits
private-be
...
develop
Author | SHA1 | Date | |
---|---|---|---|
33c641cd85 | |||
2fc889a5f9 | |||
a28798bf52 | |||
988b9b4793 | |||
8c67828185 | |||
2e3891c468 | |||
fc458fc5f3 | |||
16dabd5d9f | |||
558675842a | |||
a7049d224c | |||
56a408355d | |||
1d81510899 | |||
1be3cb77b6 | |||
042110ec5e | |||
a2ffe1bbf1 | |||
96f5ea8af1 | |||
e9e08bdadd | |||
c7c363782b | |||
c929c40cc9 | |||
abbe0f82e6 | |||
f102ebbfc8 | |||
bf9ec17513 | |||
fabb5cd257 | |||
64c377c663 | |||
86e1403230 | |||
370b589a6b | |||
4fb7cacf31 | |||
da4787946d | |||
25e4e35b1f | |||
fb39a93569 | |||
99a12c58de | |||
ec3678d90d | |||
f46150422a | |||
221ea05629 | |||
d87e9e6d92 | |||
8c8da2988d | |||
ac78fe2807 | |||
fbbcb0d07d | |||
a7924feb76 | |||
cb535196e2 | |||
238f246d64 | |||
20e7d9ed10 | |||
7c43261f9c | |||
a35b72d256 | |||
666d2c468a | |||
4ea61542a0 | |||
bee3e53be7 | |||
96fdef0558 | |||
5ce9892a9b | |||
54376ac585 | |||
e0e9d4a185 | |||
6730575aed | |||
c68902b34b | |||
26c483fc9a | |||
a68d2ce952 | |||
adaf8dc217 | |||
572c5a0824 | |||
e469d207b4 | |||
82ec120871 | |||
242c60d74d | |||
20692b0630 | |||
9990d50e3e | |||
ad5f45c620 | |||
c564bb4112 | |||
670047af6f | |||
e8a492ef7d | |||
583d9b97dd | |||
88176fe599 | |||
19c3008c8f | |||
51f9f421b8 | |||
b700e17d7e | |||
dc01804359 | |||
a5066140fd | |||
351efe4b58 | |||
c716f03784 | |||
fa828a5eae | |||
56d12295ba | |||
a442197adf | |||
a99fb7f0b0 | |||
f44dae632c | |||
0dcb67c44e | |||
e869fdc38f | |||
ec9673f6c0 | |||
8cc9849b36 | |||
5c86feccb9 | |||
8006b0add9 | |||
b9e3d8ec5e | |||
2fb76e322a | |||
57990f8339 | |||
01cf597b5d | |||
381f3ee737 | |||
5be80d8e68 | |||
12bab71b17 | |||
02fd724b0b | |||
7d47f1f259 | |||
cad074bcc3 | |||
8243e06e95 | |||
f4b51c06c1 | |||
5f6699749c | |||
ec50dd6bb6 | |||
c99c397cf6 | |||
814f64b3e2 | |||
3a3af77907 | |||
93e72e1cb6 | |||
522e7830e5 | |||
263210ac3c | |||
506d2ad8a9 | |||
f9c0506590 | |||
3f4917931b | |||
b7166771cf | |||
40230c5478 | |||
68bd9e0bed | |||
3e28c012d7 | |||
57c023c973 | |||
cc696e58fc | |||
59af29ff64 | |||
59fb69525b | |||
1bd4d144a3 | |||
b54d34ebfc | |||
d1ffab3e42 | |||
d873b157ee | |||
d7be2048af | |||
3d1f506684 | |||
cd8f0e7926 | |||
960ba84683 | |||
2eead1f9de | |||
b663335c6d | |||
9ce6bd566f | |||
9547bd2913 | |||
9b2e6140a3 | |||
6de255681c | |||
805e5eddd0 | |||
4945a234e7 | |||
230696f456 | |||
c113903980 | |||
0e95cd0adf | |||
494708a362 | |||
3a21983b98 | |||
1817247077 | |||
0d9eed73dd | |||
59d43fd3f6 | |||
d321c31776 | |||
ce10c7d6e2 | |||
37b9673b12 | |||
7c7af945e4 | |||
cb32c66a59 | |||
4249ab30ca | |||
67e9c1245e | |||
3d9a1086b6 | |||
fda0c18794 | |||
dffa5d8f75 | |||
9891b601a8 | |||
a8f6aa6ed7 | |||
348dcc558c | |||
703f6f695b | |||
fdbfe49a7c | |||
3f0dd599b3 | |||
5d9974ddf8 | |||
f001e8edcd | |||
17c67a3d5d | |||
54fadaa270 | |||
ff433c4270 | |||
71fd804fd7 | |||
198b201a51 | |||
66626c8f62 | |||
727f28e39f | |||
07b6bf33cb | |||
d0758dc73c | |||
b85c0eb95d | |||
eea0ef258c | |||
18f6445a7c | |||
c5f42719a0 | |||
eb89aec00f | |||
61576bce58 | |||
f7d4737782 | |||
3dd0f3a154 | |||
145ffbfcf0 | |||
bcf2a2f026 | |||
1358152dec | |||
2e2279ba8c | |||
60dadf599c | |||
90537f9d12 | |||
8b0c2f80b6 | |||
42423f36db | |||
176eb7c011 | |||
da9ca78a8b | |||
b470ee6401 | |||
fccd4e427c | |||
f25031afd4 | |||
ca65f84137 | |||
d4057adf4d | |||
007937d2d7 | |||
5f040ed390 | |||
870d0c8404 | |||
47b9ac890a | |||
50b84350d9 | |||
cdc64f1b2c | |||
2913098e74 | |||
ce99352e90 | |||
8322d3a36c | |||
a818457f8c | |||
1f6644b703 | |||
412c5ee91d | |||
dcc5f7f716 | |||
9fefc9e8f8 | |||
d1af911241 | |||
5abd265195 | |||
3cb0f46533 | |||
c367a2e9f1 | |||
3eceffbb6b | |||
7c3a00a40d | |||
45a90fb4a2 | |||
8557e110a8 | |||
c2232a5e14 | |||
e6d9a33dbf | |||
d8fccc8f1b | |||
6528070f1c | |||
09c6a87e19 | |||
cd0d8fffcb | |||
1b6f0c07fd | |||
2f31b50a5b | |||
cee4e15b06 | |||
888f44366c | |||
c88076eec0 | |||
afe47437e4 | |||
4dc484c3c2 | |||
0f2a85b108 | |||
5e55ce75c2 | |||
eec2adbfd9 | |||
a848f6e425 | |||
44896d305e | |||
6c70ed4b4e | |||
e3c480131a | |||
575166f5b4 | |||
c60aa3e3f3 | |||
75f0d12c82 | |||
5cf2bc4fbf | |||
908b499f8f | |||
67c7905acf | |||
eacafe87b3 | |||
2a53b24487 | |||
9df3c33c6c | |||
d4e82d6e7a | |||
06ba758309 | |||
2c56902389 | |||
cb3fd43dbd | |||
3d15759fb9 | |||
5620b6ab78 | |||
09999175f7 | |||
f2a9f890ff | |||
093994b474 | |||
3d0de5af04 | |||
966a906436 | |||
844d4056e3 | |||
00ef131bb6 | |||
d6be6f14dc | |||
2ccf028bc2 | |||
3eeffada1f | |||
0499255be7 | |||
f909c1da10 | |||
81543965ae | |||
96d42756d5 | |||
f6e57d664f | |||
c33be1cbf3 | |||
6d99156bd9 | |||
ca764811ed | |||
a589bb2863 | |||
6f35fd2676 | |||
e83cef1c8c | |||
b89df3f27b | |||
4ecc16a93b | |||
8960873ff3 | |||
043a708515 | |||
c6b230414e | |||
f5e9f66f76 | |||
ee5f9a62ff | |||
a92cf8c812 | |||
756874949a | |||
798e0c0cf1 | |||
3f370945e6 | |||
a759731eba | |||
405d5def7c | |||
1f9806d02f | |||
c43c951b92 | |||
00c44c612f | |||
e5c4fceacd | |||
70227a7fa1 | |||
cb5488dcaa | |||
910e18fb5e | |||
66af946766 | |||
6784ed7fdf | |||
66f0ba6891 | |||
ee7bf5138c | |||
c32181818a | |||
4665df228d | |||
c7a56a9f61 | |||
39251b9aa2 | |||
db534e5993 | |||
e94bee4fc8 | |||
216e58e5ec | |||
a4d13ad03b | |||
05cfecb797 | |||
132fcfa099 | |||
475b9911b1 | |||
7825ccbb3d | |||
f87da10a29 | |||
1eec70449d | |||
19ca930ee8 | |||
2e31d34e9d | |||
8a339ec171 | |||
c7d79422bd | |||
baf96a8b06 | |||
bc516a6326 | |||
1cd6af1236 | |||
9f6910ba73 | |||
9cf4975bfd | |||
ee992bc0bf | |||
ff8a83ca2d | |||
4c957b86ae | |||
ff11835333 | |||
9353bbb56c | |||
edc887dd4c | |||
68dad77f81 | |||
840b83012a | |||
e150856e91 | |||
42a3f6c880 | |||
7a47b09b39 | |||
241e6f7e3a | |||
f02afaac26 | |||
bdd4a4d755 | |||
94c1eb2c81 | |||
b03991ae1d | |||
f98589b419 | |||
9fad2a882a | |||
ec76754270 | |||
d0bb197e8c | |||
efd90bca3e | |||
3efa017942 | |||
c5226f6374 | |||
281585cdf0 | |||
6d4ab4d54b | |||
9e429463b2 | |||
51db0066ac | |||
9763edef47 | |||
442f57bfc4 | |||
ae7101bb30 | |||
490d48c635 | |||
69ee3bb4f0 | |||
46b455c3d1 | |||
e522e30ce5 | |||
c73784aa81 | |||
7affa09e5e | |||
7435d02f6e | |||
2467297f04 | |||
cf317e15e9 | |||
bcae60316b | |||
1a2fa10708 | |||
f79c2feea6 | |||
7ec87d7853 | |||
f5704e561b | |||
d6faf3a37b | |||
b0a6952643 | |||
06b58cfb9c | |||
afcec24f86 | |||
3f90a0df04 | |||
395ce6523d | |||
cced930549 | |||
7b2bd1a7af | |||
f447150bbc | |||
08bd78d51b | |||
f0ec372f50 | |||
d2c28ada7f | |||
375ad25919 | |||
abf0568398 | |||
2386f545e2 | |||
908c4ee085 | |||
23e5e87915 | |||
b4693252be | |||
f3cf2dd8ec | |||
d96ec2a732 | |||
b8fe0454b5 | |||
1166c6e639 | |||
eda552c7c9 | |||
841c08be2c | |||
eafb506d64 | |||
fe00015248 | |||
509ed305cd | |||
c05107bccd | |||
4fcc32ca4b | |||
6857529d06 | |||
42e29862ac | |||
3ecee61013 | |||
f9aee46bbe | |||
1cf3ce48ce | |||
072bb0daf0 | |||
d36e0ad27d | |||
a80cbe79c2 | |||
cf71fc3f98 | |||
be977dbea9 | |||
f327cfd197 | |||
4bb01becd2 | |||
64fcc87516 | |||
62e528fc22 | |||
030fd4467d | |||
489840019e | |||
9af8c06b1c | |||
55e0573a5c | |||
ac142ae11c | |||
99a58e2c33 | |||
c740fb1c1f | |||
175001d561 | |||
d481ef6c9f | |||
3caa419659 | |||
074b028015 | |||
bab0dd3294 | |||
8a3acc6889 | |||
d37c5dde2f | |||
53260555f6 | |||
70524dd642 | |||
b6232a9f1e | |||
41481f465a | |||
527e7129af | |||
229b51686c | |||
e156a97861 | |||
bdec14c463 | |||
ec0509c645 | |||
4500e9be27 | |||
a2cc3a0436 | |||
dc654812b1 | |||
f122383d0b | |||
0f6492a051 | |||
b235f0e826 | |||
27d44340e8 | |||
fc26c9fb54 | |||
ba60f92223 | |||
c489d018bd | |||
a9a518c6c1 | |||
b4bdf8b0dc | |||
94f71541f8 | |||
c2402303cc | |||
5cef76e494 | |||
bf27b8fd47 | |||
32b8d27949 | |||
fb5581ae67 | |||
cd01d2f8c3 | |||
65c3c8026d | |||
534f83e716 | |||
93c859a3c4 | |||
4d183fe0b2 | |||
fd72390a22 | |||
5a4323067a | |||
43d8434e17 | |||
e8576277e0 | |||
7f0a9d8d5a | |||
51f4a780e2 | |||
180a8eb18d | |||
eb61043867 | |||
e09935125f | |||
e8ef9345e9 | |||
28c1a9092b | |||
5e609aa40d | |||
158940f8e6 | |||
141e8b96a5 | |||
108a02826f | |||
be1ca70ebf | |||
34edd8a13f | |||
23f383a7f9 | |||
99caaa0f28 | |||
0f70c9059e | |||
6d7074e71d | |||
13809b91d1 | |||
16f6dc84c9 | |||
cdfb06f4a7 | |||
4e98e569eb | |||
6d3ffd7dd3 | |||
ca7fe74a90 | |||
380f878d81 | |||
1c36312850 | |||
de946be008 | |||
b40d815274 | |||
bc7500bde9 | |||
676e603ffc | |||
01bbfc31f2 | |||
a846954dcd | |||
53302e3b26 | |||
c0301ce7e7 | |||
14f32f24fa | |||
19db78e352 | |||
9d01bbabd7 | |||
a93a4fccc1 | |||
1da25300ca | |||
cb47443649 | |||
86862825f6 | |||
e6f1968609 | |||
4c5da1b5a9 | |||
e57ef210fd | |||
dcdfe853e1 | |||
34e57c297b | |||
6c2c2e6ae7 | |||
aae3bd0bba | |||
2b5d4681e3 | |||
e4eff2d362 | |||
37311e5f17 | |||
af5a0b7bbd | |||
3aa45cb365 | |||
a07b398cbe | |||
2ccec2f4df | |||
0de9a9fd37 | |||
bd21e88e8b | |||
2464e2530f | |||
44021d3ad2 | |||
a46eaafbcf | |||
eb496243c7 | |||
6e5e0c3bb5 | |||
dfc8234908 | |||
157c8629a9 | |||
bde21fbc6c | |||
74820e8922 | |||
f7a9075b77 | |||
4af56e48bf | |||
978486bc15 | |||
27dd8a1927 | |||
78196e14c3 | |||
a0eb5dc596 | |||
e4c22a0205 | |||
c4bf5d406d | |||
53d43b5707 | |||
b1564d822e | |||
a8a2f0a26c | |||
46e1205327 | |||
6a2de2be55 | |||
db6ba0c62c | |||
16029dc161 | |||
31a0db014a | |||
5be8005e24 | |||
ad4e112e96 | |||
7a2dc7d3c4 | |||
0948371f83 | |||
3ba1a00257 | |||
1b42cd7816 | |||
a2fe0dfb78 | |||
bf1ed57180 | |||
6821f1b9a0 | |||
7ae741cd83 | |||
fe9ad83ddc | |||
6b7c828cc9 | |||
2be1ee19de | |||
3f15a453bd | |||
53611d80d6 | |||
4614b25f33 | |||
519446c5a8 | |||
4b52cafb9a | |||
1ca84a3b95 | |||
b28792eb29 | |||
9c3be68e1c | |||
df9ce81060 | |||
173eda1757 | |||
b2b15b8b6e | |||
f448090c2a | |||
232e3285ae | |||
ebc127c921 | |||
41665b1060 | |||
3a3b7aaee4 | |||
f2485f0ba1 | |||
75caf2c1eb | |||
f1a6a405c2 | |||
88105f22a0 | |||
9c368f295e | |||
04deb08bcf | |||
f704d15dd7 | |||
297af7b905 | |||
6c0564e0ee | |||
3d232d81ba | |||
3109aafd20 | |||
105a01811a | |||
33999fe895 | |||
7f12479514 | |||
0eb000224e | |||
3c9692d5b2 | |||
50bfaf7236 | |||
385f31728d | |||
bcd487d311 | |||
8f8e2a2aea | |||
54034ff727 | |||
ee5db96c9e | |||
f825760fe9 | |||
a339884d1f | |||
1de586f907 | |||
bd162afdcc | |||
956b817045 | |||
28ee0908d7 | |||
c3cf38b0c9 | |||
7929e7530f | |||
a11e453112 | |||
2e7ad1626e | |||
4182c15500 | |||
4b43726e1d | |||
a4e7082ab8 | |||
f0b8f92791 | |||
da88303a22 | |||
cb5b70a23a | |||
2b5b749dc8 | |||
ef00c0e2df | |||
06f7e306e0 | |||
878744b636 | |||
f84694b809 | |||
473ef018c9 | |||
9a734565b0 | |||
2eda9657ac | |||
203c1852d4 | |||
708112c486 | |||
5b321fcc78 | |||
59231e513f | |||
bf6dfab121 | |||
f5f1be9f7d | |||
c0148bb770 | |||
d938c555b7 | |||
52efc8b752 | |||
822e3f91c4 | |||
d0a1aec1c0 | |||
e8305184af | |||
e9727ac2c5 | |||
d9a6bb0fd2 | |||
13a807ba4f | |||
32c5eee0b5 | |||
06f761bf56 | |||
4b16a69275 | |||
a309b041bf | |||
8c40a5a9e8 | |||
3b11dd216f | |||
8db5649cd5 | |||
f2f6eb81f7 | |||
f6831ec02b | |||
7f64654800 | |||
8e570027a1 | |||
df9fb3c527 | |||
2080fdc955 | |||
70f8748364 | |||
0343e2e310 | |||
80645a089c | |||
37442bcb48 | |||
a99072dd7c | |||
6b57ec8b97 | |||
d84d402271 | |||
f004c82302 | |||
126e8c8858 | |||
dbc89509d7 | |||
0ba38e4a3a | |||
361ce456cf | |||
c1cfde9d49 | |||
daa38772b4 | |||
dc83172aea | |||
b909a633a6 | |||
1f95a6cb8e | |||
468af3f9a6 | |||
038e4b2e4e | |||
de53e0dcd6 | |||
1cf7434918 | |||
fc7e7f502b | |||
38a2ebd32b | |||
3b965b92f2 | |||
421cb7ba03 | |||
8319935a3d | |||
91ef386a41 | |||
c8eec17180 | |||
c94e60d49b | |||
b00170c3f9 | |||
b37e5fffbf | |||
8c27a9368f | |||
735659dee6 | |||
bf02b185ed | |||
4ccf5d21a4 | |||
9ac1c43511 | |||
76b9496fe6 | |||
ae8191ca0e | |||
a9a9bfebeb | |||
2d8e2f0824 | |||
6f18d46037 | |||
6261318df1 | |||
bff7585fa9 | |||
4dbc4ebeb2 | |||
fc391cc18c | |||
35b390d3c1 | |||
b21703f6d9 | |||
d003098146 | |||
db7c183d06 | |||
7d3c82f4b7 | |||
13ec3366d3 | |||
f9a41fd4f3 | |||
2157126332 | |||
e87dcfe48e | |||
566c3d474d | |||
ca03cf3b08 | |||
f0e530722f | |||
dcd1b4ad94 | |||
3394c2126c | |||
85765928b4 | |||
f13874ee01 | |||
bac272a2db | |||
48bd957276 | |||
d4d42e7856 | |||
671a8e0cb3 | |||
822c2e0fa2 | |||
ee651ae96a | |||
9fc4aa8a40 | |||
8f6a012538 | |||
91d6430815 | |||
eac5a4c9a6 | |||
7449688bfe | |||
63612b2fb0 | |||
8e010c7fa5 | |||
3181c47fde | |||
a133955489 | |||
7551c79715 | |||
5a4e387026 | |||
00945a0028 | |||
2b9d384f8f | |||
90efee3f20 | |||
574d1f9134 | |||
25e82d828f | |||
2eb9e63724 | |||
d85f74f365 | |||
f775527d63 | |||
a6d64282c0 | |||
24fb0e0e7b | |||
b6a5a60066 | |||
f68d1009e5 | |||
99b74559da | |||
346888db41 | |||
7b218bfd75 | |||
098c4254d4 | |||
bbdb7fe41f | |||
3c13d2083b | |||
ad55851090 | |||
a37423a119 | |||
02daf88db3 | |||
ce3b8ba4b3 | |||
891fd3826b | |||
e0eba95b48 | |||
2febb37a8e | |||
a20e8b2f48 | |||
b3d5ed8505 | |||
4401503b85 | |||
6c5909c800 | |||
af5109f86c | |||
b782e66a45 | |||
a1ffb23f0d | |||
ea5afeeb88 | |||
49334766ef | |||
3bba4edb45 | |||
bda8fdb1b9 | |||
f361517a92 | |||
a12afb8dc2 | |||
de1a97d357 | |||
c17cf460d7 | |||
8ff20bf7aa | |||
205056f636 | |||
40197e04cf | |||
2249e5a315 | |||
bff1ea8b9d | |||
b614226871 | |||
f51f3c8a94 | |||
074a296a68 | |||
2874e4bfd3 | |||
74a157d26c | |||
3d3fc3f515 | |||
6c371f868f | |||
06855420da | |||
0d7cc69947 | |||
cfc69627e5 | |||
160f48679b | |||
4931665b45 | |||
849882287f | |||
436159bd46 | |||
2224dbebb8 | |||
9882250a9b | |||
bb22a6bf9e | |||
15c83f8332 | |||
5ec35b6009 | |||
22fe1e8ab1 | |||
813d0433d6 | |||
cd9d64410f | |||
2b66f98832 | |||
6ebcc162e6 | |||
8b7c78e3b1 | |||
ab8ccbb408 | |||
f89d2c1cca | |||
30449a2875 | |||
afed157f29 | |||
6b4223a9d6 | |||
0746e12737 | |||
350e331eb2 | |||
bb3f353dbc | |||
6bd2eacb88 | |||
29b594207c | |||
e5363b2e21 | |||
d04259b253 | |||
f50c219f95 | |||
b2fe2fdf9a | |||
850a0e90ce | |||
391ea1b46a | |||
247bb31c56 | |||
5471d810c8 | |||
ad0a9ecafe | |||
ee630cf9df | |||
c786c022b8 | |||
33649cc5c0 | |||
71a10f8514 | |||
a864f4e344 | |||
007d5d6791 | |||
f176a6c8eb | |||
104981f3d3 | |||
2ba6b64485 | |||
81ac3708a3 | |||
8e9e0fa346 | |||
b6f32ca6be | |||
e042754be1 | |||
38ac5858a9 | |||
0c0180264e | |||
3d9477f0c9 | |||
6f51f321f6 | |||
ab17a688cf | |||
18bc6ce61e | |||
765b5e1a7c | |||
a3e64703ab | |||
d74be9d81d | |||
6ca5bb0c74 | |||
76550d8fb8 | |||
daf3741c9a | |||
b2977540e0 | |||
bcc70e9f8c | |||
2252b6d09e | |||
8deb502140 | |||
2582907919 | |||
266868376d | |||
71fa3910a1 | |||
75f290ae8f | |||
073a1afbde | |||
aaa031f212 | |||
762d298c06 | |||
2a892fa6ec | |||
cb82826fcf | |||
6e5498430f | |||
57fb921573 | |||
d1b5126288 | |||
9d2324b587 | |||
60921cb95f | |||
9e76879ce6 | |||
1992a4c60b | |||
f833bc3a6f | |||
4731801893 | |||
4293b51c31 | |||
ecadb83c6d | |||
205bdffebd | |||
ae7ca9c91c | |||
841119949b | |||
b63f663947 | |||
00a23b525f | |||
ea85b11945 | |||
d8c7eb5cf5 | |||
8bc185ecf9 | |||
1832e64ad7 | |||
87bc1f5f75 | |||
6e2f6bb8e9 | |||
74d8adfffe | |||
99127b617b | |||
65ea72c07f | |||
04ca932a01 | |||
4ea2dff8f1 | |||
9f0176350c | |||
dac1e1fe3f | |||
afed69e43e | |||
b2096f22c3 | |||
14c456df22 | |||
3f34357692 | |||
429dcefa88 | |||
d1a35620c9 | |||
ce741d6e1f | |||
5a82851fe9 | |||
92ff900bc0 | |||
2a1deb8d7d | |||
38eea44a8b | |||
2d45fbbd91 | |||
32382c4783 | |||
521c46c0be | |||
c114749519 | |||
825424cfba | |||
985eb24e88 | |||
7cadcf1e86 | |||
a314521b96 | |||
ab3bad0e16 | |||
ec75906bc1 | |||
137a537f68 | |||
91123fd24a | |||
597dd56032 | |||
37847a2f9f | |||
471d3459a6 | |||
512eec09a8 | |||
af8a9faaeb | |||
20c4c4bb2f | |||
76268e7a14 | |||
29596180a1 | |||
ebfd8b3efd | |||
509acbde19 | |||
474064669d | |||
1940368c43 | |||
49c9c69b5a | |||
ff29f2768b | |||
942df433b3 | |||
5e2b551045 | |||
2e64500c35 | |||
7b7c05ff68 | |||
aec5c0b787 | |||
d8901b38f5 | |||
9d7c876e3c | |||
455273f322 | |||
16347b2ad0 | |||
0e1cbce10d | |||
8bd6f53f01 | |||
fe32356bce | |||
1f337613be | |||
3f4a62f5f9 | |||
b506704716 | |||
6a3dcca9ee | |||
edd1e55cbb | |||
f1facea929 | |||
d638ea054b | |||
e11784904b | |||
9f1d3804d9 | |||
333295367a | |||
e9d14c6cbf | |||
8fc915d6a0 | |||
2b4898329f | |||
5a9513bb30 | |||
e45459e556 | |||
8b546daeaa | |||
125f91257a | |||
507d9c23e7 | |||
2ee34acbad | |||
6eee97759e | |||
f88bf552af | |||
d2c7664073 | |||
e91249a876 | |||
1eab964c0b | |||
2933ac491b | |||
2958d2b1ac | |||
3262fe002b | |||
521e5ad5fc | |||
2b651b0bc4 | |||
99b3532e64 | |||
2ea8e9cf1e | |||
e8b7446117 | |||
a47b9c0c75 | |||
a75862b5cc | |||
0738683ee3 | |||
155f4036f9 | |||
8181090763 | |||
6328627a97 | |||
c6043d60ee | |||
dd6813c058 | |||
2229b332e0 | |||
63ed3b6e10 | |||
ccd1672e72 | |||
addcc2dacc | |||
a49e9f2c1f | |||
b1421767dd | |||
8ee916411e | |||
9d845bf6c1 | |||
9a2c24942a | |||
cca2a03b2f | |||
1a64bfcef8 | |||
907810d98a | |||
23a4999196 | |||
3e0feba273 | |||
468a559127 | |||
c03fc86300 | |||
a33be0b556 | |||
6aee926f00 | |||
13640be91d | |||
5123cf20c3 | |||
bf739b9f41 | |||
4211806b5f | |||
88aada8d35 | |||
5623cedab3 | |||
ccfc8331fb | |||
10803408cd | |||
fb7a7db6e8 | |||
78cd1313fe | |||
db1bbf7148 | |||
5f19adf2d0 | |||
6f006adbc1 | |||
39bff06897 | |||
68682ee291 | |||
5029b26b40 | |||
907cf08400 | |||
e85d194e5f | |||
cfeb87d2ba | |||
e4f3735c9f | |||
baa9dfe0f1 | |||
5e73439e7b | |||
4b2776ee81 | |||
566df3e285 | |||
0653d695d9 | |||
4811747790 | |||
ed2519848c | |||
b1374b12a3 | |||
c5a25eecf1 | |||
a4dbf3ddbb | |||
be3a61ebc7 | |||
ababa4b428 | |||
d75c2558ca | |||
ac0dedfd3d | |||
37563b6afd | |||
937afc0dfd | |||
94c34e03dd | |||
1ad556f9cf | |||
019f7d6d6a | |||
b4384d11f5 | |||
2ed8d22899 | |||
cce6413e2b | |||
8fb0fb66e3 | |||
abe2bbdfd4 | |||
1d9efc7fb5 | |||
b17b7b7a24 | |||
18d7917756 | |||
cc401fce8c | |||
a5fc35d0b1 | |||
acd48a6db4 | |||
b45d3fb80a | |||
3ea1ad5622 | |||
5898da3234 | |||
9dd966f639 | |||
48662ef1f3 | |||
854d48e54e | |||
d4c560d7fc | |||
91b7ce3008 | |||
4dca231a06 | |||
b81c83a250 | |||
f9e619d9e7 | |||
ae7962ae50 | |||
5027660b52 | |||
358d81b5cf | |||
79b9108a8f | |||
5ab22e742b | |||
4f655bb80a | |||
e4f1309e2d | |||
bb40894778 | |||
24b3fa1e3f | |||
16cd045588 | |||
15a7cd5f65 | |||
e676075d5b | |||
967bff063b | |||
3cba0bce34 | |||
60b182ac18 | |||
619878ac85 | |||
169f1a0191 | |||
fa31c28e92 | |||
f815d4e2e4 | |||
a3e5b29cfc | |||
46cecde014 | |||
86143c5887 | |||
0a1dc423d4 | |||
1cb0f1ae56 | |||
9f86158bb7 | |||
231b0ea830 | |||
4dc108f782 | |||
795146cde4 | |||
975be17d13 | |||
32be76ebee | |||
d13b517128 | |||
e0d97cd2a8 | |||
8b718ce50b | |||
ce708e2d16 | |||
01467574d0 | |||
97a2278634 | |||
4b2a263889 | |||
1f37a5e7eb | |||
77c9fac3ce | |||
a13d5d5a82 | |||
23e4541eb7 | |||
d4b9f71fd3 | |||
a9edeaf5b9 | |||
1f6074e539 | |||
df7b62e14b | |||
cacc8a51cc | |||
89ca0629b3 | |||
360db07ef2 | |||
f55a870964 | |||
5ee140cdab | |||
ff4dff1147 | |||
ba1eed7a85 | |||
0c9f6e02bd | |||
565d17970f | |||
dc3c2d027c | |||
ba2c34fdd6 | |||
3691c3f483 | |||
9c103103e8 | |||
382d8ef2c8 | |||
2891f47cb3 | |||
3c80ec8b43 | |||
478ba3db28 | |||
f96cd1b5e2 | |||
7f4ab57a1d | |||
8caf93bf0a | |||
9c4b68b09e | |||
b49e8d0279 | |||
71a57e9859 | |||
081ef16e5e | |||
b3ec259ce9 | |||
4f48514d1a | |||
f96acd33f2 | |||
cde061c77a | |||
a79b3cfd70 | |||
9a35f96c75 | |||
60767c6a7e | |||
57668886b2 | |||
ffb5c76f7c | |||
00e8dd6345 | |||
7904462920 | |||
13d649bace | |||
bebe563e8f | |||
4be2258882 | |||
40ff8d0a2a | |||
0dcb7e71c4 | |||
08878f2fb9 | |||
3ea7e1057b | |||
fc8fcb76fd | |||
eac2a9b19f | |||
0ce57d1308 | |||
97dec0f9d2 | |||
b64c748b73 | |||
77ab2c3753 | |||
b90262bfd0 | |||
581f4b24bd | |||
5f3d9da9f8 | |||
41775e5d19 | |||
044d34d20f | |||
f1b1732e5c | |||
1da2b17a76 | |||
e49725e06d | |||
669404d6f8 | |||
2e21742264 | |||
7763d08816 | |||
726be85223 | |||
19bf6cbf18 | |||
df07fa85d5 | |||
e3e55de55b | |||
54857a3bf3 | |||
b28f616e85 | |||
97c7104dbc | |||
6501343f24 | |||
fabe339215 | |||
e1886509d3 | |||
8ad48784d9 | |||
75e9c9f986 | |||
a17afe247c | |||
81abcfcf7b | |||
7e5d8675c2 | |||
cde3109203 | |||
fcf95ba8c1 | |||
f71804f094 | |||
83ca7f1321 | |||
16a1e4008b | |||
518a8eba0a | |||
8d56a6450e | |||
8896bfbc59 | |||
4ca57f8c76 | |||
c9fa11cc3b | |||
0247c50650 | |||
eca06cb14a | |||
c07e2cfdd8 | |||
db7615d26f | |||
2f0acad866 | |||
a2b3fc0628 | |||
e005b70071 | |||
b515664db3 | |||
948eff1f7e | |||
f1a39c2faa | |||
ab8e498cee | |||
c6da754875 | |||
97d5b955a0 | |||
80f9800fd6 | |||
0485400c1f | |||
811aac35d7 | |||
a77b090435 | |||
21874b0966 | |||
08c63a2f84 | |||
97f00e9d6f | |||
a97a7e0aea | |||
cf870916c9 | |||
7297566060 | |||
4f28fec62a | |||
c01bc4d840 | |||
ea6698a2d8 | |||
1e950b5ccb | |||
3e5a3c81b5 | |||
a5506aeab6 | |||
23b76a7276 | |||
d8f503351b | |||
d5887f1f02 | |||
e04cdd16d6 | |||
c256fb4cbd | |||
21299c8eb8 | |||
527706154a | |||
07c86b6949 | |||
92cf938e99 | |||
f23d3dfa3f | |||
23f9e200dc | |||
366834e2e4 | |||
d409d26478 | |||
76fc73de95 | |||
40800f964d | |||
9f7d16a70e | |||
c2cb0a0c5a | |||
272f35417b | |||
848c3dd950 | |||
dfeb39b31f | |||
bab5226f2a | |||
88cfbfb1f3 | |||
49f1d6339f | |||
3e7cb443fa | |||
b5c8a38b9b | |||
ab19922530 | |||
45c844b065 | |||
47b838a386 | |||
276691efbf | |||
0a8d50cc27 | |||
11e81acbc1 | |||
fb2c9b341c | |||
810ae71832 | |||
001a73af3c | |||
c8375b742a | |||
9feef054fc | |||
bf87ae7a7d | |||
f8de6f9e10 | |||
ab47fa776e | |||
7178473f34 | |||
c8319d8af2 | |||
9ff1452c68 | |||
ce534c4a05 | |||
0fddf94292 | |||
8276e99d27 | |||
a5ad8e43b1 | |||
ce7ce3ac92 | |||
99a1c76cb1 | |||
603e989879 | |||
dd82283341 | |||
af2d9e7eb8 | |||
06ad46e639 | |||
71f97d41c4 | |||
df131f32c6 | |||
77dece36d0 | |||
1a767ff910 | |||
220c8050b1 | |||
d4fa9c96e8 | |||
22b5d62ba1 | |||
b9bdd29986 | |||
f848bbf7c4 | |||
0fe9edfdbc | |||
6d2830cf78 | |||
7294ff6e1a | |||
3fd62552b3 | |||
fa5abc27f7 | |||
ccc47e204d | |||
bf3f735062 | |||
de0198946e | |||
072a77b58e | |||
eb7fe22863 | |||
f1511039ef | |||
5c479e3bf0 | |||
0413f326a0 | |||
9d1c3f1410 | |||
802a0ac9ba | |||
9da986e3b8 | |||
e6a5b899be | |||
60bf3b2e33 | |||
b465838b71 | |||
21bd716844 | |||
523fb91b21 | |||
d8bf770902 | |||
10aa32d9cc | |||
7474969969 | |||
319b5458fc | |||
f7304a011c | |||
94dc5d3177 | |||
6d692c2730 | |||
d0f8691560 | |||
9a43ab5a13 | |||
01124b76a3 | |||
7600954f4b | |||
5a5c67e445 | |||
68c3affacf | |||
e40f4faa8e | |||
b56c6c37ec | |||
999118798c | |||
84cf755332 | |||
5bd7c0ad2b | |||
7fe06d42ce | |||
20986ba3f0 | |||
97a95c435e | |||
b9555cf7dd | |||
590b9f0bcc | |||
ca2ceaea56 | |||
96d8a79d42 | |||
11233f7d25 | |||
a991e0f429 | |||
bfdce07d81 | |||
f5953655c5 | |||
6bc4993d81 | |||
68646c4b4d | |||
38b0d57118 | |||
b38c24b347 | |||
a6d51cee3c | |||
7bdbd9f71a | |||
b47876dc3d | |||
4644475bc7 | |||
16ba292afa | |||
c7f3bac330 | |||
abb8352c92 | |||
59d866aa23 | |||
ba032412eb | |||
5de0c034f4 | |||
b1d83f2746 | |||
658c08010d | |||
6a5753fac8 | |||
8da89986df | |||
c7e39cb041 | |||
b755607895 | |||
508eef8c07 | |||
a18dfc38af | |||
95f9fad673 | |||
4857b507b1 | |||
bca7bd3586 | |||
9978e392a2 | |||
cc33cf18f2 | |||
c5921bc4cb | |||
91450ced7c | |||
5afd9e83eb | |||
d05275020f | |||
c420c236d9 | |||
d5433e9b91 | |||
cbbe9ec11f | |||
0e06d47687 | |||
c907b7257a | |||
10239d14c9 | |||
2344275ff9 | |||
e0ffa1d9c5 | |||
77a6654ff2 | |||
43aee0ec67 | |||
d95ba82e5b | |||
b6d8232951 | |||
bb9cef55ea | |||
67718d8fe4 | |||
71a2029752 | |||
6bb1f3b7dc | |||
2469d285bc | |||
5f410213e2 | |||
bb3e1b44b1 | |||
868df25417 | |||
2801f65e67 | |||
cccde29e6c | |||
aa0629d202 | |||
ba209fa4d2 | |||
d224f47b8c | |||
ffb0ceba20 | |||
22022f5ef6 | |||
1ac72bc363 | |||
dcc8f38f3d | |||
8cf217d2ba | |||
7d66117fab | |||
9c0c1f87f8 | |||
7a2d8e78eb | |||
c15a5fc90f | |||
212ce69ffd | |||
7470b053c6 | |||
d1b4b39e86 | |||
b43f0d5bd9 | |||
035034430e | |||
a703b7cc0a | |||
e78bec8409 | |||
412e4a4dc5 | |||
81e10326d3 | |||
20f88ef161 | |||
bce0f8ef18 | |||
d661870401 | |||
afa1a733f4 | |||
1b186725ce | |||
164a8e26c4 | |||
cadcc1a92a | |||
bcb3c24027 | |||
fd6a4ba41c | |||
3ab82b2dbb | |||
1ed218d5e3 | |||
0fee770411 | |||
5b116c0d4e | |||
b7a4f7e30f | |||
ba1300b1b7 | |||
817ef0c2cc | |||
18ee621489 | |||
ddf5094acf | |||
133921848d | |||
46db70d58b | |||
21958eb77f | |||
b30f149dc9 | |||
9b83566482 | |||
b688631937 | |||
4d654358d7 | |||
24e90de672 | |||
780e8b09b7 | |||
2196663d94 | |||
7085ac01cb | |||
81671d73c7 | |||
a38c89a17f | |||
253fb8d27d | |||
a682c8f5cc | |||
d18a4b3c42 | |||
426b31d46c | |||
5c09b1910f | |||
fe72d8faec | |||
b560bcd8dc | |||
85ced7ff5f | |||
5ac76ef9c4 | |||
123a512d3c | |||
d141ed7d03 | |||
95e120afd6 | |||
ca8a214cf6 | |||
7161861d36 | |||
c6c8f63e39 | |||
e9962997a6 | |||
f2ab1778c5 | |||
0f71d61b88 | |||
80c4fcce82 | |||
8f8d50efbd | |||
43b4976ed7 | |||
ff3681627b | |||
35d21fb725 | |||
bbfb3b0a7a | |||
8b78a5e7ad | |||
66c17006d1 | |||
8a911f238b | |||
77c44c323f | |||
c2d1fe45d8 | |||
24591cee05 | |||
50dd785ef8 | |||
af2e95ea39 | |||
4fa1bd7268 | |||
ea07e6aef6 | |||
5e7a1e5974 | |||
9b3cc61dcb | |||
0c37b99a68 | |||
f96d1d780c | |||
5a5364ad3b | |||
5b70c713b2 | |||
efb96eddf3 | |||
5cb25c8c1f | |||
700cc2c67c | |||
a9e0bffe5f | |||
512e0e9053 | |||
b842389449 | |||
cc10a13785 | |||
f9c3ad5921 | |||
0960699699 | |||
c6e06fe9f3 | |||
10f6a68065 | |||
037b717e60 | |||
9fa352d4f8 | |||
73345bb927 | |||
f5385b0a1d | |||
46fbbdc99a | |||
6ef8c92d09 | |||
08b7cf013b | |||
f702df2f15 | |||
92efee6f46 | |||
facf039f97 | |||
d7f35cd1e4 | |||
332637e0d9 | |||
6d6fd3d49d | |||
b4675a97c7 | |||
02e3417c27 | |||
f5ac2616ad | |||
01bb37b0f6 | |||
a4d43889ce | |||
4991da1622 | |||
f106cc78bb | |||
2617d22819 | |||
dbdf1d39bd | |||
54ff3893a6 | |||
0168c05259 | |||
65e75afa8b | |||
90809811c1 | |||
0f6e9c97cc | |||
98516e3802 | |||
68b03838a2 | |||
1f0025b101 | |||
b46f007f64 | |||
ecab33bdce | |||
cc0da2ec54 | |||
a2868739c2 | |||
2f75510889 | |||
46332cd1b9 | |||
21e9ca990d | |||
1a02319894 | |||
4a95ccccdb | |||
d3187ce2c4 | |||
ed0643c4ad | |||
1e2947ceba | |||
ddcb13dd28 | |||
c71bf3ba23 | |||
3e5c441b24 | |||
0b6c16b0a6 | |||
5f566724bb | |||
4a89ae3cfe | |||
56a0518c80 | |||
bf8a294676 | |||
c069712c22 | |||
d04957ba41 | |||
8cc08cf4c0 | |||
1b917f6bed | |||
514e569bd5 | |||
a22059a1a1 | |||
2cfefc9432 | |||
2f7c7bae5e | |||
3f04d74dd6 | |||
4dd8c1d692 | |||
eb9a5aeb42 | |||
7465abe0a9 | |||
20dab7c77a | |||
4e105e0fbc | |||
d2f1d78aa2 | |||
360f52d0cf | |||
8c888906c9 | |||
d611aeb035 | |||
0e888d35eb | |||
98bb230817 | |||
3d6d9b2a91 | |||
bc9a700383 | |||
62c7a30bbc | |||
abf6ff8115 | |||
a718721537 | |||
4f99d3c6e1 | |||
a2fc1652d1 | |||
77007dcea0 | |||
dc818524b2 | |||
d1ba1105b5 | |||
89a9bfba47 | |||
2798a199aa | |||
3d0402c1e0 | |||
af0c9c92b6 | |||
0a7709526f | |||
9ec821f6b3 | |||
5c4474dc87 | |||
829ecf06da | |||
cb2bb215d3 | |||
916c6fba0d | |||
8473f32781 | |||
240ccf23a4 | |||
e49859e5ea | |||
c6d158a8a3 | |||
7e90fe2401 | |||
cab78a4aa4 | |||
7da139be4d | |||
2444783edf | |||
727615a818 | |||
6e3089f025 | |||
e09b0ff4e3 | |||
830eea5e95 | |||
705fbbe343 | |||
12bcf52764 | |||
f31c909517 | |||
781c37fbae | |||
930ec7ccff | |||
de93d6e171 | |||
80c79ded3b | |||
126b0ae90a | |||
d6a847bfcc | |||
9b33059089 | |||
804fdb439d | |||
6ba5f70615 | |||
54c01be7ff | |||
6e964ff601 | |||
73d33ae730 | |||
434d975767 | |||
41a31c23b7 | |||
02461ad46c | |||
072e68e97b | |||
6879acbe02 | |||
ace503ad3d | |||
e12a82b476 | |||
51cb7c3edf | |||
2198e2bf3e | |||
6138fc7748 | |||
dc1eb3d6f0 | |||
fa1482a152 | |||
e65ed3e773 | |||
eca7f31e82 | |||
2b22180191 | |||
654b5d9c59 | |||
777d1f378c | |||
3b132ab4dc | |||
d1083116e0 | |||
7b79cec0ed | |||
50cbbb86fc | |||
5a914ea5a3 | |||
ca5ac8b826 | |||
2b50609e5c | |||
57cb0614a9 | |||
eccb1043db | |||
9768097488 | |||
f5e9f71586 | |||
9f8b14d180 | |||
10a3cbbe9c | |||
b917120f17 | |||
30ef9cc6d0 | |||
948c792e5d | |||
2df703ab71 | |||
1ec85ca095 | |||
5a26739b78 | |||
36a78f1a3c | |||
1c0291b1dd | |||
e7d9e3780e | |||
83d4af2303 | |||
7c5076d01a | |||
e61823b78f | |||
4d52ac4d34 | |||
aced0a63c9 | |||
1e54235ff5 | |||
e6e5554edf | |||
9026f487ec | |||
c0097ba752 | |||
f109253bba | |||
1fda4248ec | |||
7781c5252b | |||
7f4bf52050 | |||
ba0d179de5 | |||
71b6f1bdf0 | |||
09ec4a920c | |||
7edf0fdb93 | |||
99e06441f0 | |||
85e1e131f6 | |||
1d79918a94 | |||
340d13b1fa | |||
cf1000a4df | |||
b781b56efd | |||
10a8a85bfc | |||
6d8a014cc7 | |||
60c88ded5e | |||
1e7a6af0bf | |||
f8b79ef34f | |||
4cf56685b5 | |||
fdcd2aa540 | |||
667d30a710 | |||
b0f23e46ba | |||
9b30b48016 | |||
bd49683e13 | |||
c22945b1e7 | |||
0a16a2e261 | |||
b95819cada | |||
dc1ea1bed9 | |||
5f9fe505d5 | |||
5b8e97287e | |||
49572c1fec | |||
ebb0770198 | |||
27e05cc72d | |||
4ca48a5f50 | |||
230bd50661 | |||
4f2f8d517f | |||
130da9d4cc | |||
472b9aa5e2 | |||
3413dff8f9 | |||
66e8fce488 | |||
aa2d333f4a | |||
c8a45d8eef | |||
40f5be28f6 | |||
7c9287543c | |||
2a05b6d326 | |||
2499d25432 | |||
9417872790 | |||
c02a1bbf74 | |||
0a894b219a | |||
22803668d2 | |||
2f6d1cb069 | |||
8889261b6b | |||
91f1a5195c | |||
1a5b958b1a | |||
d667f6362c | |||
ef1db466b9 | |||
0566f0ddfa | |||
f54d4d757f | |||
fbc5d6eed9 | |||
2c4d2ce551 | |||
bbe260bc9e | |||
2fe19a5abe | |||
feacf576d7 | |||
ceb58f1d92 | |||
806591f5b7 | |||
18ce21c2c6 | |||
47fb0ea868 | |||
ffe6450b26 | |||
b51c1c03cb | |||
e745d78d67 | |||
4c9d5e8465 | |||
9ec7177bfa | |||
421881d461 | |||
c78f152670 | |||
dabcae0905 | |||
e7e141bd1e | |||
8386e9d3c6 | |||
21e4828a72 | |||
9ab95dfc43 | |||
c34ce758dd | |||
2c9f00d19f | |||
f7127b84d8 | |||
fdb21cd1fb | |||
9f0c1eece8 | |||
e18a09f4ac | |||
005001b081 | |||
90f17693f1 | |||
698b045f86 | |||
654f84363a | |||
4dd510f3af | |||
1c36dfcc5f | |||
b0bd27db31 | |||
daa1a9eef7 | |||
c737354ed3 | |||
8ea15d3bab | |||
13a4221fce | |||
a896573a5e | |||
edd89450aa | |||
5f5ef8fcea | |||
a3b59c990b | |||
1e7bfac13c | |||
6e92633793 | |||
e4ff632dcb | |||
b0ebef2cfd | |||
bbb8707cb7 | |||
6a927e4092 | |||
13cdb5d8c7 | |||
9f0883d0cb | |||
eba2e17479 | |||
5d1c95621b | |||
02ba45fa34 | |||
9d5c004ec4 | |||
37e90229c2 | |||
73aceda97f | |||
669d55500a | |||
f44d127110 | |||
bcc023a127 | |||
122cce3bc7 | |||
949162bcab | |||
4ed862120c | |||
f9411d706b | |||
8f61b0b9a6 | |||
cdffda5593 | |||
d1c45a87e6 | |||
2761c05a01 | |||
e7800249af | |||
2e88b266d9 | |||
0b008489f7 | |||
de67327f6d | |||
04a6fe807e | |||
6dee0957ea | |||
c12d2db258 | |||
27b39b79e6 | |||
d7aa3f1617 | |||
69c2faf0e1 | |||
678ed4959b | |||
0bdcda1b23 | |||
74a30d27e8 | |||
f0e2bb8db6 | |||
3fdeb51353 | |||
a7b2a7df71 | |||
41403c84f9 | |||
e67f6b2ad8 | |||
4ac3292183 | |||
d3c13ee1e6 | |||
1b44117891 | |||
c7b708e62b | |||
56b51f944d | |||
30297c2390 | |||
522c9b2b03 | |||
67a029180e | |||
dfad8740eb | |||
b45dc19811 | |||
80c0d08ec6 | |||
2b5ab90cd8 | |||
0303c9af9d | |||
1e59f663e5 | |||
72217cde51 | |||
4bccbe254b | |||
9e15a84006 | |||
c19b7ec2c6 | |||
59c00b01dc | |||
75d26e613b | |||
904ff4eecf | |||
0249207dcc | |||
366378f267 | |||
80cca7673a | |||
fc888b168c | |||
348c306858 | |||
0a11d2de47 | |||
4ac76ab672 | |||
eb4e6e32f7 | |||
89b35fab6d | |||
d638ff513b | |||
93828830a9 | |||
39b244384b | |||
80b3585b71 | |||
5d9f4b8ea8 | |||
16b02edf87 | |||
b8f169d0cd | |||
62a9535394 | |||
8c4ef3caa6 | |||
e763d48bf3 | |||
f841854c5f | |||
1c871a12a1 | |||
8a528936b8 | |||
744329dca2 | |||
45ac40b125 | |||
2426989161 | |||
1439c8b162 | |||
5125cc3397 | |||
9b949af390 | |||
3ff9fdabdb | |||
a805da9faa | |||
e0acb0f04a | |||
5414f2329c | |||
08045dd1e9 | |||
288f855e2f | |||
7883b04618 | |||
0687c040a0 | |||
58c6d508ec | |||
ae272582ac | |||
1a4517c43a | |||
2cfc0cf28a | |||
cf63384dce | |||
733d50b642 | |||
0e60e74a8a | |||
fd0054addf | |||
576e4aa90d | |||
ea3de4cdda | |||
83c7609df5 | |||
809584cc54 | |||
9b85090884 | |||
6965a4c374 | |||
b6c0c02028 | |||
42f9d19ee9 | |||
b80a61cc95 | |||
0d972d987c | |||
3e33c8e6f9 | |||
3822d536c8 | |||
5906c374ba | |||
ee90b20f7f |
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
|
Dist.xcconfig
|
||||||
|
Tusker.xcconfig
|
||||||
.DS_Store
|
.DS_Store
|
||||||
MyPlayground.playground/
|
MyPlayground.playground/
|
||||||
|
|
||||||
|
6
.gitmodules
vendored
@ -1,9 +1,3 @@
|
|||||||
[submodule "Cache"]
|
|
||||||
path = Cache
|
|
||||||
url = git@github.com:hyperoslo/Cache.git
|
|
||||||
[submodule "Gifu"]
|
|
||||||
path = Gifu
|
|
||||||
url = git://github.com/kaishin/Gifu.git
|
|
||||||
[submodule "Embassy"]
|
[submodule "Embassy"]
|
||||||
path = Embassy
|
path = Embassy
|
||||||
url = https://github.com/envoy/Embassy.git
|
url = https://github.com/envoy/Embassy.git
|
||||||
|
157
Artwork/Tusker no shadow.svg
Normal file
@ -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 |
153
Artwork/Tusker transparent.svg
Normal file
@ -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 |
162
Artwork/Tusker.svg
Normal file
@ -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 |
278
CHANGELOG-release.md
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
## 2024.5
|
||||||
|
Features/Improvements:
|
||||||
|
- Improve gallery animations
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Handle right-to-left text in display names
|
||||||
|
- Fix crash during gifv playback
|
||||||
|
- iPadOS: Fix app becoming unresponsive when switching accounts
|
||||||
|
- iPadOS/macOS: Fix Cmd+R shortcuts not working
|
||||||
|
|
||||||
|
## 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
|
1334
CHANGELOG.md
1
Cache
@ -1 +0,0 @@
|
|||||||
Subproject commit 8c42c575cf28b2ff0e780c9728721e9a8891c92e
|
|
7
Documentation/Haptic Feedback.md
Normal file
@ -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 +0,0 @@
|
|||||||
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007
|
|
22
NotificationExtension/Info.plist
Normal file
@ -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>
|
14
NotificationExtension/NotificationExtension.entitlements
Normal file
@ -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>
|
399
NotificationExtension/NotificationService.swift
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
content.threadIdentifier = conversationIdentifier ?? ""
|
||||||
|
|
||||||
|
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: nil,
|
||||||
|
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 #available(iOS 16.0, macOS 13.0, *),
|
||||||
|
let url = try? URL.ParseStrategy().parse(string) {
|
||||||
|
url
|
||||||
|
} else if let web = WebURL(string),
|
||||||
|
let url = URL(web) {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
URL(string: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||||
|
guard name == "span" else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
let clazz = attributes.attributeValue(for: "class")
|
||||||
|
if clazz == "invisible" {
|
||||||
|
return .skip
|
||||||
|
} else if clazz == "ellipsis" {
|
||||||
|
return .append("…")
|
||||||
|
} else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
NotificationExtension/PrivacyInfo.xcprivacy
Normal file
@ -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>
|
29
OpenInTusker/Action.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// Action.js
|
||||||
|
// OpenInTusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/22/21.
|
||||||
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
var Action = function() {};
|
||||||
|
|
||||||
|
Action.prototype = {
|
||||||
|
|
||||||
|
run: function(arguments) {
|
||||||
|
const results = {
|
||||||
|
url: window.location.href,
|
||||||
|
};
|
||||||
|
const el = document.querySelector('link[rel=alternate][type="application/activity+json"]');
|
||||||
|
if (el) {
|
||||||
|
results.activityPubURL = el.href;
|
||||||
|
}
|
||||||
|
arguments.completionFunction(results);
|
||||||
|
},
|
||||||
|
|
||||||
|
finalize: function(arguments) {
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
var ExtensionPreprocessingJS = new Action();
|
105
OpenInTusker/ActionViewController.swift
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
//
|
||||||
|
// ActionViewController.swift
|
||||||
|
// OpenInTusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/23/21.
|
||||||
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import MobileCoreServices
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
class ActionViewController: UIViewController {
|
||||||
|
|
||||||
|
@IBOutlet weak var imageView: UIImageView!
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
findURLFromWebPage { (components) in
|
||||||
|
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 @Sendable (URLComponents?) -> Void) {
|
||||||
|
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||||
|
for provider in item.attachments! {
|
||||||
|
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
let components = URLComponents(string: urlString) else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(components)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findURLItem(completion: @escaping @Sendable (URLComponents?) -> Void) {
|
||||||
|
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||||
|
for provider in item.attachments! {
|
||||||
|
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(components)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchForURLInApp(_ components: URLComponents) {
|
||||||
|
var components = components
|
||||||
|
components.scheme = "tusker"
|
||||||
|
self.openURL(components.url!)
|
||||||
|
self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openURL(_ url: URL) {
|
||||||
|
var responder: UIResponder = self
|
||||||
|
while let parent = responder.next {
|
||||||
|
if let application = parent as? UIApplication {
|
||||||
|
application.perform(#selector(openURL(_:)), with: url)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
responder = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBAction func done() {
|
||||||
|
extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
65
OpenInTusker/Base.lproj/MainInterface.storyboard
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="ObA-dk-sSI">
|
||||||
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Image-->
|
||||||
|
<scene sceneID="7MM-of-jgj">
|
||||||
|
<objects>
|
||||||
|
<viewController title="Image" id="ObA-dk-sSI" customClass="ActionViewController" customModule="OpenInTusker" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="zMn-AG-sqS">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="320" height="528"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<navigationBar contentMode="scaleToFill" horizontalCompressionResistancePriority="751" verticalCompressionResistancePriority="751" translatesAutoresizingMaskIntoConstraints="NO" id="NOA-Dm-cuz">
|
||||||
|
<rect key="frame" x="0.0" y="44" width="320" height="44"/>
|
||||||
|
<items>
|
||||||
|
<navigationItem id="3HJ-uW-3hn">
|
||||||
|
<barButtonItem key="leftBarButtonItem" title="Done" style="done" id="WYi-yp-eM6">
|
||||||
|
<connections>
|
||||||
|
<action selector="done" destination="ObA-dk-sSI" id="Qdu-qn-U6V"/>
|
||||||
|
</connections>
|
||||||
|
</barButtonItem>
|
||||||
|
</navigationItem>
|
||||||
|
</items>
|
||||||
|
</navigationBar>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Unable to find Mastodon link on this page." textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yho-gp-VyR">
|
||||||
|
<rect key="frame" x="0.0" y="254" width="320" height="20.5"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
<viewLayoutGuide key="safeArea" id="VVe-Uw-JpX"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="VVe-Uw-JpX" firstAttribute="trailing" secondItem="NOA-Dm-cuz" secondAttribute="trailing" id="A05-Pj-hrr"/>
|
||||||
|
<constraint firstItem="NOA-Dm-cuz" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="HxO-8t-aoh"/>
|
||||||
|
<constraint firstItem="Yho-gp-VyR" firstAttribute="centerY" secondItem="zMn-AG-sqS" secondAttribute="centerY" id="R7q-OB-hhA"/>
|
||||||
|
<constraint firstItem="Yho-gp-VyR" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="TEy-zi-dP7"/>
|
||||||
|
<constraint firstItem="Yho-gp-VyR" firstAttribute="trailing" secondItem="VVe-Uw-JpX" secondAttribute="trailing" id="Uvn-0x-Y6N"/>
|
||||||
|
<constraint firstItem="NOA-Dm-cuz" firstAttribute="top" secondItem="VVe-Uw-JpX" secondAttribute="top" id="we0-1t-bgp"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||||
|
<size key="freeformSize" width="320" height="528"/>
|
||||||
|
<connections>
|
||||||
|
<outlet property="view" destination="zMn-AG-sqS" id="Qma-de-2ek"/>
|
||||||
|
</connections>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="X47-rx-isc" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="-61" y="-57"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<systemColor name="systemBackgroundColor">
|
||||||
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</systemColor>
|
||||||
|
</resources>
|
||||||
|
</document>
|
57
OpenInTusker/Info.plist
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Open in Tusker</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionServiceRoleType</key>
|
||||||
|
<string>NSExtensionServiceRoleTypeViewer</string>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||||
|
<string>Action</string>
|
||||||
|
<key>NSExtensionServiceAllowsFinderPreviewItem</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExtensionServiceAllowsTouchBarItem</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExtensionServiceFinderPreviewIconName</key>
|
||||||
|
<string>NSActionTemplate</string>
|
||||||
|
<key>NSExtensionServiceTouchBarBezelColorName</key>
|
||||||
|
<string>TouchBarBezel</string>
|
||||||
|
<key>NSExtensionServiceTouchBarIconName</key>
|
||||||
|
<string>NSActionTemplate</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionMainStoryboard</key>
|
||||||
|
<string>MainInterface</string>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.ui-services</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
BIN
OpenInTusker/Media.xcassets/AppIcon.appiconset/1024x1024@1x.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
OpenInTusker/Media.xcassets/AppIcon.appiconset/60x60@2x.png
Normal file
After Width: | Height: | Size: 900 B |
BIN
OpenInTusker/Media.xcassets/AppIcon.appiconset/60x60@3x.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
OpenInTusker/Media.xcassets/AppIcon.appiconset/76x76@2x.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
OpenInTusker/Media.xcassets/AppIcon.appiconset/83.5x83.5@2x.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
103
OpenInTusker/Media.xcassets/AppIcon.appiconset/Contents.json
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "60x60@2x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "60x60@3x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "76x76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "76x76@2x.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "76x76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "83.5x83.5@2x.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "83.5x83.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "1024x1024@1x.png",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
6
OpenInTusker/Media.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
},
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"color" : {
|
||||||
|
"reference" : "systemPurpleColor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -2,7 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>IDEDidComputeMac32BitWarning</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
@ -1,355 +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")
|
|
||||||
decoder.dateDecodingStrategy = .formatted(formatter)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) {
|
|
||||||
guard let request = createURLRequest(request: request) else {
|
|
||||||
completion(.failure(Error.invalidRequest))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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.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, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
|
|
||||||
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
|
||||||
"q" => query,
|
|
||||||
"resolve" => resolve,
|
|
||||||
"limit" => limit
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) -> 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
|
|
||||||
] + "media_ids" => media?.map { $0.id }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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,65 +0,0 @@
|
|||||||
//
|
|
||||||
// Card.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Card: Decodable {
|
|
||||||
public let url: URL
|
|
||||||
public let title: String
|
|
||||||
public let description: String
|
|
||||||
public let image: URL?
|
|
||||||
public let kind: Kind
|
|
||||||
public let authorName: String?
|
|
||||||
public let authorURL: URL?
|
|
||||||
public let providerName: String?
|
|
||||||
public let providerURL: URL?
|
|
||||||
public let html: String?
|
|
||||||
public let width: Int?
|
|
||||||
public let height: Int?
|
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
self.url = try container.decode(URL.self, forKey: .url)
|
|
||||||
self.title = try container.decode(String.self, forKey: .title)
|
|
||||||
self.description = try container.decode(String.self, forKey: .description)
|
|
||||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
|
||||||
self.image = try? container.decode(URL.self, forKey: .image)
|
|
||||||
self.authorName = try? container.decode(String.self, forKey: .authorName)
|
|
||||||
self.authorURL = try? container.decode(URL.self, forKey: .authorURL)
|
|
||||||
self.providerName = try? container.decode(String.self, forKey: .providerName)
|
|
||||||
self.providerURL = try? container.decode(URL.self, forKey: .providerURL)
|
|
||||||
self.html = try? container.decode(String.self, forKey: .html)
|
|
||||||
self.width = try? container.decode(Int.self, forKey: .width)
|
|
||||||
self.height = try? container.decode(Int.self, forKey: .height)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case url
|
|
||||||
case title
|
|
||||||
case description
|
|
||||||
case image
|
|
||||||
case kind = "type"
|
|
||||||
case authorName = "author_name"
|
|
||||||
case authorURL = "author_url"
|
|
||||||
case providerName = "provider_name"
|
|
||||||
case providerURL = "provider_url"
|
|
||||||
case html
|
|
||||||
case width
|
|
||||||
case height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Card {
|
|
||||||
public enum Kind: String, Decodable {
|
|
||||||
case link
|
|
||||||
case photo
|
|
||||||
case video
|
|
||||||
case rich
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
//
|
|
||||||
// Emoji.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/8/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Emoji: Codable {
|
|
||||||
public let shortcode: String
|
|
||||||
public let url: URL
|
|
||||||
public let staticURL: URL
|
|
||||||
public let visibleInPicker: Bool
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case shortcode
|
|
||||||
case url
|
|
||||||
case staticURL = "static_url"
|
|
||||||
case visibleInPicker = "visible_in_picker"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Emoji: CustomDebugStringConvertible {
|
|
||||||
public var debugDescription: String {
|
|
||||||
return ":\(shortcode):"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,84 +0,0 @@
|
|||||||
//
|
|
||||||
// Hashtag.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Hashtag: Codable {
|
|
||||||
public let name: String
|
|
||||||
public let url: URL
|
|
||||||
public let history: [History]?
|
|
||||||
|
|
||||||
public init(name: String, url: URL) {
|
|
||||||
self.name = name
|
|
||||||
self.url = url
|
|
||||||
self.history = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case name
|
|
||||||
case url
|
|
||||||
case history
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Hashtag {
|
|
||||||
public class History: Codable {
|
|
||||||
public let day: Date
|
|
||||||
public let uses: Int
|
|
||||||
public let accounts: Int
|
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
if let day = try? container.decode(Date.self, forKey: .day) {
|
|
||||||
self.day = day
|
|
||||||
} else if let unixTimestamp = try? container.decode(Double.self, forKey: .day) {
|
|
||||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
|
||||||
} else if let str = try? container.decode(String.self, forKey: .day),
|
|
||||||
let unixTimestamp = Double(str) {
|
|
||||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .day, in: container, debugDescription: "day must be either date or UNIX timestamp")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let uses = try? container.decode(Int.self, forKey: .uses) {
|
|
||||||
self.uses = uses
|
|
||||||
} else if let str = try? container.decode(String.self, forKey: .uses),
|
|
||||||
let uses = Int(str) {
|
|
||||||
self.uses = uses
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .uses, in: container, debugDescription: "uses must either be int or string containing int")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let accounts = try? container.decode(Int.self, forKey: .accounts) {
|
|
||||||
self.accounts = accounts
|
|
||||||
} else if let str = try? container.decode(String.self, forKey: .accounts),
|
|
||||||
let accounts = Int(str) {
|
|
||||||
self.accounts = accounts
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .accounts, in: container, debugDescription: "accounts must either be int or string containing int")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case day
|
|
||||||
case uses
|
|
||||||
case accounts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Hashtag: Equatable, Hashable {
|
|
||||||
public static func ==(lhs: Hashtag, rhs: Hashtag) -> Bool {
|
|
||||||
return lhs.name == rhs.name
|
|
||||||
}
|
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(url)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,87 +0,0 @@
|
|||||||
//
|
|
||||||
// Instance.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class Instance: Decodable {
|
|
||||||
public let uri: String
|
|
||||||
public let title: String
|
|
||||||
public let description: String
|
|
||||||
public let email: String?
|
|
||||||
public let version: String
|
|
||||||
public let urls: [String: URL]
|
|
||||||
public let thumbnail: URL?
|
|
||||||
public let languages: [String]?
|
|
||||||
public let stats: Stats?
|
|
||||||
|
|
||||||
// pleroma doesn't currently implement these
|
|
||||||
public let contactAccount: Account?
|
|
||||||
|
|
||||||
// MARK: Unofficial additions to the Mastodon API.
|
|
||||||
public let maxStatusCharacters: Int?
|
|
||||||
|
|
||||||
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
self.uri = try container.decode(String.self, forKey: .uri)
|
|
||||||
self.title = try container.decode(String.self, forKey: .title)
|
|
||||||
self.description = try container.decode(String.self, forKey: .description)
|
|
||||||
self.email = try container.decodeIfPresent(String.self, forKey: .email)
|
|
||||||
self.version = try container.decode(String.self, forKey: .version)
|
|
||||||
if let urls = try? container.decodeIfPresent([String: URL].self, forKey: .urls) {
|
|
||||||
self.urls = urls
|
|
||||||
} else {
|
|
||||||
self.urls = [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
self.languages = try? container.decodeIfPresent([String].self, forKey: .languages)
|
|
||||||
self.contactAccount = try? container.decodeIfPresent(Account.self, forKey: .contactAccount)
|
|
||||||
|
|
||||||
self.stats = try? container.decodeIfPresent(Stats.self, forKey: .stats)
|
|
||||||
self.thumbnail = try? container.decodeIfPresent(URL.self, forKey: .thumbnail)
|
|
||||||
if let maxStatusCharacters = try? container.decodeIfPresent(Int.self, forKey: .maxStatusCharacters) {
|
|
||||||
self.maxStatusCharacters = maxStatusCharacters
|
|
||||||
} else if let str = try? container.decodeIfPresent(String.self, forKey: .maxStatusCharacters),
|
|
||||||
let maxStatusCharacters = Int(str, radix: 10) {
|
|
||||||
self.maxStatusCharacters = maxStatusCharacters
|
|
||||||
} else {
|
|
||||||
self.maxStatusCharacters = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case uri
|
|
||||||
case title
|
|
||||||
case description
|
|
||||||
case email
|
|
||||||
case version
|
|
||||||
case urls
|
|
||||||
case thumbnail
|
|
||||||
case languages
|
|
||||||
case stats
|
|
||||||
|
|
||||||
case contactAccount = "contact_account"
|
|
||||||
|
|
||||||
case maxStatusCharacters = "max_toot_chars"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Instance {
|
|
||||||
public class Stats: Decodable {
|
|
||||||
public let domainCount: Int?
|
|
||||||
public let statusCount: Int?
|
|
||||||
public let userCount: Int?
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case domainCount = "domain_count"
|
|
||||||
case statusCount = "status_count"
|
|
||||||
case userCount = "user_count"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,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,21 +0,0 @@
|
|||||||
//
|
|
||||||
// RegisteredApplication.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/9/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class RegisteredApplication: Decodable {
|
|
||||||
public let id: String
|
|
||||||
public let clientID: String
|
|
||||||
public let clientSecret: String
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case id
|
|
||||||
case clientID = "client_id"
|
|
||||||
case clientSecret = "client_secret"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,33 +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
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,147 +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 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
9
Packages/ComposeUI/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
23
Packages/ComposeUI/Package.resolved
Normal file
@ -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
|
||||||
|
}
|
49
Packages/ComposeUI/Package.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// 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(.v15),
|
||||||
|
],
|
||||||
|
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: "../TuskerPreferences"),
|
||||||
|
.package(path: "../UserAccounts"),
|
||||||
|
.package(path: "../GalleryVC"),
|
||||||
|
],
|
||||||
|
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",
|
||||||
|
"TuskerPreferences",
|
||||||
|
"UserAccounts",
|
||||||
|
"GalleryVC",
|
||||||
|
],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
|
.testTarget(
|
||||||
|
name: "ComposeUITests",
|
||||||
|
dependencies: ["ComposeUI"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
)
|
3
Packages/ComposeUI/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# ComposeUI
|
||||||
|
|
||||||
|
A description of this package.
|
218
Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
//
|
||||||
|
// PostService.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/27/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PostService: ObservableObject {
|
||||||
|
private let mastodonController: any ComposeMastodonContext
|
||||||
|
private let contentType: StatusContentType
|
||||||
|
private let draft: Draft
|
||||||
|
|
||||||
|
@Published var currentStep = 1
|
||||||
|
@Published private(set) var totalSteps = 2
|
||||||
|
|
||||||
|
init(mastodonController: any ComposeMastodonContext, contentType: StatusContentType, draft: Draft) {
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.contentType = contentType
|
||||||
|
self.draft = draft
|
||||||
|
}
|
||||||
|
|
||||||
|
func post() async throws(Error) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollParams: EditPollParameters?
|
||||||
|
if draft.pollEnabled,
|
||||||
|
let poll = draft.poll {
|
||||||
|
pollParams = EditPollParameters(options: poll.pollOptions.map(\.text), expiresIn: Int(poll.duration), multiple: poll.multiple)
|
||||||
|
} else {
|
||||||
|
pollParams = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
request = Client.editStatus(
|
||||||
|
id: editedStatusID,
|
||||||
|
text: textForPosting(),
|
||||||
|
contentType: 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: pollParams
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let pollOptions: [String]?
|
||||||
|
let pollExpiresIn: Int?
|
||||||
|
let pollMultiple: Bool?
|
||||||
|
if draft.pollEnabled,
|
||||||
|
let poll = draft.poll {
|
||||||
|
pollOptions = poll.pollOptions.map(\.text)
|
||||||
|
pollExpiresIn = Int(poll.duration)
|
||||||
|
pollMultiple = poll.multiple
|
||||||
|
} else {
|
||||||
|
pollOptions = nil
|
||||||
|
pollExpiresIn = nil
|
||||||
|
pollMultiple = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
request = Client.createStatus(
|
||||||
|
text: textForPosting(),
|
||||||
|
contentType: 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: pollOptions,
|
||||||
|
pollExpiresIn: pollExpiresIn,
|
||||||
|
pollMultiple: pollMultiple,
|
||||||
|
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 {
|
||||||
|
throw Error.posting(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadAttachments() async throws(Error) -> [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 {
|
||||||
|
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(DraftAttachment.ExportError) -> (Data, UTType) {
|
||||||
|
let result = await withCheckedContinuation { continuation in
|
||||||
|
attachment.getData(features: mastodonController.instanceFeatures) { result in
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case .success(let result):
|
||||||
|
return result
|
||||||
|
case .failure(let error):
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws(Error) -> 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 {
|
||||||
|
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
|
// CharacterCounter.swift
|
||||||
// Pachyderm
|
// ComposeUI
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/29/18.
|
// Created by Shadowfacts on 9/29/18.
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
public struct CharacterCounter {
|
public struct CharacterCounter {
|
||||||
|
|
||||||
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
||||||
|
|
||||||
public static func count(text: String) -> Int {
|
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
|
||||||
let mentionsRemoved = removeMentions(in: text)
|
let mentionsRemoved = removeMentions(in: text)
|
||||||
var count = mentionsRemoved.count
|
var count = mentionsRemoved.count
|
||||||
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
||||||
count -= match.range.length
|
count -= match.range.length
|
||||||
count += 23 // Mastodon link length
|
count += instanceFeatures.charsReservedPerURL
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
72
Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
//
|
||||||
|
// ComposeInput.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/5/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FocusedComposeInput: FocusedValueKey {
|
||||||
|
typealias Value = (any ComposeInput)?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FocusedValues {
|
||||||
|
// double optional is necessary pre-iOS 16
|
||||||
|
var composeInput: (any ComposeInput)?? {
|
||||||
|
get { self[FocusedComposeInput.self] }
|
||||||
|
set { self[FocusedComposeInput.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@propertyWrapper
|
||||||
|
final class MutableObservableBox<Value>: ObservableObject {
|
||||||
|
@Published var wrappedValue: Value
|
||||||
|
|
||||||
|
init(wrappedValue: Value) {
|
||||||
|
self.wrappedValue = wrappedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FocusedComposeInputBox: EnvironmentKey {
|
||||||
|
static let defaultValue: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var composeInputBox: MutableObservableBox<(any ComposeInput)?> {
|
||||||
|
get { self[FocusedComposeInputBox.self] }
|
||||||
|
set { self[FocusedComposeInputBox.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FocusedInputModifier: ViewModifier {
|
||||||
|
@StateObject var box: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.environment(\.composeInputBox, box)
|
||||||
|
.focusedValue(\.composeInput, box.wrappedValue)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// ComposeMastodonContext.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/5/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
import InstanceFeatures
|
||||||
|
import UserAccounts
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public protocol ComposeMastodonContext {
|
||||||
|
var accountInfo: UserAccountInfo? { get }
|
||||||
|
var instanceFeatures: InstanceFeatures { get }
|
||||||
|
|
||||||
|
func run<Result: Sendable>(_ request: Request<Result>) async throws(Client.Error) -> (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)
|
||||||
|
|
||||||
|
func fetchStatus(id: String) -> (any StatusProtocol)?
|
||||||
|
}
|
51
Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
//
|
||||||
|
// ComposeUIConfig.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import PhotosUI
|
||||||
|
import PencilKit
|
||||||
|
import TuskerComponents
|
||||||
|
import GalleryVC
|
||||||
|
|
||||||
|
// Configuration/data injected from outside the compose UI.
|
||||||
|
public struct ComposeUIConfig {
|
||||||
|
// Config
|
||||||
|
public var allowSwitchingDrafts = true
|
||||||
|
public var textSelectionStartsAtBeginning = false
|
||||||
|
public var showToolbar = true
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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 var fetchAvatar: AvatarImageView.FetchAvatar = { _ in nil }
|
||||||
|
public var displayNameLabel: (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView = { _, _, _ in AnyView(EmptyView()) }
|
||||||
|
public var replyContentView: (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView = { _, _ in AnyView(EmptyView()) }
|
||||||
|
public var fetchImageAndGIFData: (URL) async -> (UIImage, Data)? = { _ in nil }
|
||||||
|
public var makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)? = { _ in nil }
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
|
||||||
|
static let defaultValue = ComposeUIConfig()
|
||||||
|
}
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var composeUIConfig: ComposeUIConfig {
|
||||||
|
get { self[ComposeUIConfigEnvironmentKey.self] }
|
||||||
|
set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
88
Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// 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 private var pollEnabledInternal: NSNumber?
|
||||||
|
@NSManaged public var text: String
|
||||||
|
@NSManaged private var visibilityStr: String
|
||||||
|
|
||||||
|
@NSManaged internal var attachments: NSMutableOrderedSet
|
||||||
|
@NSManaged public var poll: Poll?
|
||||||
|
|
||||||
|
public var pollEnabled: Bool {
|
||||||
|
get { pollEnabledInternal.map(\.boolValue) ?? (poll != nil) }
|
||||||
|
set { pollEnabledInternal = NSNumber(booleanLiteral: newValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func addAttachment(_ attachment: DraftAttachment) {
|
||||||
|
attachments.add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Draft {
|
||||||
|
var hasText: Bool {
|
||||||
|
!text.isEmpty && text != initialText
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasContentWarning: Bool {
|
||||||
|
contentWarningEnabled && contentWarning != initialContentWarning
|
||||||
|
}
|
||||||
|
|
||||||
|
public var hasContent: Bool {
|
||||||
|
hasText || hasContentWarning || attachments.count > 0 || (pollEnabled && poll!.hasContent)
|
||||||
|
}
|
||||||
|
}
|
@ -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 public 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 {
|
||||||
|
public 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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,43 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24A335" 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="pollEnabledInternal" optional="YES" attributeType="Boolean" 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,226 @@
|
|||||||
|
//
|
||||||
|
// 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 final 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 = PollDuration.allCases.max(by: {
|
||||||
|
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
|
||||||
|
})!.timeInterval
|
||||||
|
} else {
|
||||||
|
poll.duration = PollDuration.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 orphanedAttachmentsReq: NSFetchRequest<any NSFetchRequestResult> = DraftAttachment.fetchRequest()
|
||||||
|
orphanedAttachmentsReq.predicate = NSPredicate(format: "draft == nil")
|
||||||
|
let deleteReq = NSBatchDeleteRequest(fetchRequest: orphanedAttachmentsReq)
|
||||||
|
do {
|
||||||
|
try context.execute(deleteReq)
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to remove orphaned attachments: \(String(describing: error), privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let allAttachmentsReq = DraftAttachment.fetchRequest()
|
||||||
|
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
|
||||||
|
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let orphanedFiles = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
||||||
|
for url in orphanedFiles {
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to remove orphaned attachment files: \(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
46
Packages/ComposeUI/Sources/ComposeUI/CoreData/Poll.swift
Normal file
@ -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.contains { !$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
|
||||||
|
|
||||||
|
}
|
255
Packages/ComposeUI/Sources/ComposeUI/DraftsMigrator.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
49
Packages/ComposeUI/Sources/ComposeUI/Environment.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// Environment.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/10/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
//@propertyWrapper
|
||||||
|
//struct RequiredEnvironment<Value>: DynamicProperty {
|
||||||
|
// private let keyPath: KeyPath<EnvironmentValues, Value?>
|
||||||
|
// @Environment private var value: Value?
|
||||||
|
//
|
||||||
|
// init(_ keyPath: KeyPath<EnvironmentValues, Value?>) {
|
||||||
|
// self.keyPath = keyPath
|
||||||
|
// self._value = Environment(keyPath)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// var wrappedValue: Value {
|
||||||
|
// guard let value else {
|
||||||
|
// preconditionFailure("Missing required environment value for \(keyPath)")
|
||||||
|
// }
|
||||||
|
// return value
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
private struct ComposeMastodonContextKey: EnvironmentKey {
|
||||||
|
static let defaultValue: (any ComposeMastodonContext)? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var mastodonController: (any ComposeMastodonContext)? {
|
||||||
|
get { self[ComposeMastodonContextKey.self] }
|
||||||
|
set { self[ComposeMastodonContextKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CurrentAccountKey: EnvironmentKey {
|
||||||
|
static let defaultValue: (any AccountProtocol)? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var currentAccount: (any AccountProtocol)? {
|
||||||
|
get { self[CurrentAccountKey.self] }
|
||||||
|
set { self[CurrentAccountKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
40
Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// KeyboardReader.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/7/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
class KeyboardReader: ObservableObject {
|
||||||
|
@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
|
||||||
|
keyboardHeight = endFrame.height
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func willHide() {
|
||||||
|
// sometimes willHide is called during a SwiftUI view update
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.keyboardHeight = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
12
Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
//
|
||||||
|
// DismissMode.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/7/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum DismissMode {
|
||||||
|
case cancel, post
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// PollDuration.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 2/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PollDuration: 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) -> PollDuration? {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,23 @@
|
|||||||
//
|
//
|
||||||
// StatusFormat.swift
|
// StatusFormat.swift
|
||||||
// Tusker
|
// ComposeUI
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 1/12/19.
|
// Created by Shadowfacts on 1/12/19.
|
||||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
enum StatusFormat: CaseIterable {
|
enum StatusFormat: Int, CaseIterable, Identifiable {
|
||||||
case italics, bold, strikethrough, code
|
case bold, italics, strikethrough, code
|
||||||
|
|
||||||
var insertionResult: FormatInsertionResult? {
|
var id: some Hashable {
|
||||||
switch Preferences.shared.statusContentType {
|
rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
|
||||||
|
switch contentType {
|
||||||
case .plain:
|
case .plain:
|
||||||
return nil
|
return nil
|
||||||
case .markdown:
|
case .markdown:
|
||||||
@ -23,26 +27,16 @@ enum StatusFormat: CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var image: UIImage? {
|
var imageName: String {
|
||||||
let name: String
|
|
||||||
switch self {
|
switch self {
|
||||||
case .italics:
|
case .italics:
|
||||||
name = "italic"
|
return "italic"
|
||||||
case .bold:
|
case .bold:
|
||||||
name = "bold"
|
return "bold"
|
||||||
case .strikethrough:
|
case .strikethrough:
|
||||||
name = "strikethrough"
|
return "strikethrough"
|
||||||
default:
|
case .code:
|
||||||
return nil
|
return "chevron.left.forwardslash.chevron.right"
|
||||||
}
|
|
||||||
return UIImage(systemName: name)
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: (String, [NSAttributedString.Key: Any])? {
|
|
||||||
if self == .code {
|
|
||||||
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +56,7 @@ enum StatusFormat: CaseIterable {
|
|||||||
|
|
||||||
typealias FormatInsertionResult = (prefix: String, suffix: String, insertionPoint: Int)
|
typealias FormatInsertionResult = (prefix: String, suffix: String, insertionPoint: Int)
|
||||||
|
|
||||||
protocol FormatType {
|
fileprivate protocol FormatType {
|
||||||
static func format(_ format: StatusFormat) -> FormatInsertionResult
|
static func format(_ format: StatusFormat) -> FormatInsertionResult
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
.sink { [unowned self] _ in
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@StateObject private var republisher = Republisher()
|
||||||
|
var wrappedValue: T?
|
||||||
|
|
||||||
|
init(wrappedValue: T?) {
|
||||||
|
self.wrappedValue = wrappedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func update() {
|
||||||
|
republisher.wrapped = wrappedValue
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// PKDrawing+Render.swift
|
// PKDrawing+Render.swift
|
||||||
// Tusker
|
// ComposeUI
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 5/9/20.
|
// Created by Shadowfacts on 5/9/20.
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
@ -11,7 +11,7 @@ import PencilKit
|
|||||||
|
|
||||||
extension PKDrawing {
|
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)
|
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
|
||||||
var drawingImage: UIImage!
|
var drawingImage: UIImage!
|
||||||
lightTraitCollection.performAsCurrent {
|
lightTraitCollection.performAsCurrent {
|
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// PlaceholderController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PlaceholderController: PlaceholderViewProvider {
|
||||||
|
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?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exists to provide access to the type alias since the @State property needs it to be explicit
|
||||||
|
protocol PlaceholderViewProvider {
|
||||||
|
associatedtype PlaceholderView: View
|
||||||
|
@ViewBuilder
|
||||||
|
static func makePlaceholderView() -> PlaceholderView
|
||||||
|
}
|
11
Packages/ComposeUI/Sources/ComposeUI/Preferences.swift
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
//
|
||||||
|
// Preferences.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/10/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
typealias Preferences = TuskerPreferences.Preferences
|
@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// TextViewCaretScrolling.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol TextViewCaretScrolling: AnyObject {
|
||||||
|
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TextViewCaretScrolling {
|
||||||
|
func ensureCursorVisible(textView: UITextView) {
|
||||||
|
guard textView.isFirstResponder,
|
||||||
|
let range = textView.selectedTextRange,
|
||||||
|
let scrollView = findParentScrollView(of: textView) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We use a UIViewProperty animator to change the scroll view position so that we can store the currently
|
||||||
|
// running one on the Coordinator. This allows us to cancel the running one, preventing multiple animations
|
||||||
|
// from attempting to change the scroll view offset simultaneously, causing it to jitter around. This can
|
||||||
|
// happen if the user is pressing return and quickly creating many new lines.
|
||||||
|
|
||||||
|
if let existing = caretScrollPositionAnimator {
|
||||||
|
existing.stopAnimation(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursorRect = textView.caretRect(for: range.start)
|
||||||
|
var rectToMakeVisible = textView.convert(cursorRect, to: scrollView)
|
||||||
|
|
||||||
|
// expand the rect to be three times the cursor height centered on the cursor so that there's
|
||||||
|
// some space between the bottom of the line of text being edited and the top of the keyboard
|
||||||
|
rectToMakeVisible.origin.y -= cursorRect.height
|
||||||
|
rectToMakeVisible.size.height *= 3
|
||||||
|
|
||||||
|
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
||||||
|
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
||||||
|
scrollView.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
self.caretScrollPositionAnimator = animator
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findParentScrollView(of view: UIView) -> UIScrollView? {
|
||||||
|
var current: UIView = view
|
||||||
|
while let superview = current.superview {
|
||||||
|
if let scrollView = superview as? UIScrollView,
|
||||||
|
scrollView.isScrollEnabled {
|
||||||
|
return scrollView
|
||||||
|
} else {
|
||||||
|
current = superview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
@ -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,55 @@
|
|||||||
|
//
|
||||||
|
// View+ForwardsCompat.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
#if os(visionOS)
|
||||||
|
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
|
||||||
|
self.scrollDisabled(disabled)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.scrollDisabled(disabled)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
self.scrollDismissesKeyboard(.interactively)
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.scrollDismissesKeyboard(.interactively)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
|
@ViewBuilder
|
||||||
|
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
self.contextMenu(menuItems: menuItems, preview: preview)
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.contextMenu(menuItems: menuItems, preview: preview)
|
||||||
|
} else {
|
||||||
|
self.contextMenu(menuItems: menuItems)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
30
Packages/ComposeUI/Sources/ComposeUI/ViewController.swift
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// ViewController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
public protocol ViewController: ObservableObject {
|
||||||
|
associatedtype ContentView: View
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@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,175 @@
|
|||||||
|
//
|
||||||
|
// AttachmentThumbnailView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 10/14/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import TuskerComponents
|
||||||
|
import AVFoundation
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
struct AttachmentThumbnailView: View {
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
var contentMode: ContentMode = .fit
|
||||||
|
var thumbnailSize: CGSize?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
AttachmentThumbnailViewContent(
|
||||||
|
attachment: attachment,
|
||||||
|
contentMode: contentMode,
|
||||||
|
thumbnailSize: thumbnailSize
|
||||||
|
)
|
||||||
|
.id(attachment.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AttachmentThumbnailViewContent: View {
|
||||||
|
var attachment: DraftAttachment
|
||||||
|
var contentMode: ContentMode = .fit
|
||||||
|
var thumbnailSize: CGSize?
|
||||||
|
@State private var mode: Mode = .empty
|
||||||
|
@Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch mode {
|
||||||
|
case .empty:
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.imageScale(.large)
|
||||||
|
.foregroundStyle(.gray)
|
||||||
|
.task {
|
||||||
|
await loadThumbnail()
|
||||||
|
}
|
||||||
|
case .image(let image):
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: contentMode)
|
||||||
|
.task(id: attachment.drawingData) {
|
||||||
|
await loadThumbnail()
|
||||||
|
}
|
||||||
|
case .gifController(let controller):
|
||||||
|
GIFViewWrapper(controller: controller)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadThumbnail() async {
|
||||||
|
switch attachment.data {
|
||||||
|
case .editing(_, let kind, let url):
|
||||||
|
switch kind {
|
||||||
|
case .image:
|
||||||
|
if let (image, _) = await fetchImageAndGIFData(url) {
|
||||||
|
self.mode = .image(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .video, .gifv:
|
||||||
|
await loadVideoThumbnail(url: url)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
$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.mode = .gifController(GIFController(gifData: data))
|
||||||
|
} else if let image = UIImage(data: data) {
|
||||||
|
self.mode = .image(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let size = thumbnailSize ?? CGSize(width: 80, height: 80)
|
||||||
|
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in
|
||||||
|
if let image {
|
||||||
|
self.mode = .image(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .drawing(let drawing):
|
||||||
|
self.mode = .image(drawing.imageInLightMode(from: drawing.bounds))
|
||||||
|
|
||||||
|
case .file(let url, let type):
|
||||||
|
if type.conforms(to: .movie) {
|
||||||
|
await loadVideoThumbnail(url: url)
|
||||||
|
} else if let data = try? Data(contentsOf: url) {
|
||||||
|
if type == .gif {
|
||||||
|
self.mode = .gifController(GIFController(gifData: data))
|
||||||
|
} else if type.conforms(to: .image) {
|
||||||
|
if let image = UIImage(data: data),
|
||||||
|
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
|
||||||
|
// crashing share extension. see FB12186346
|
||||||
|
let prepared = await thumbnailImage(image) {
|
||||||
|
self.mode = .image(prepared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func thumbnailImage(_ image: UIImage) async -> UIImage? {
|
||||||
|
if let thumbnailSize {
|
||||||
|
await image.byPreparingThumbnail(ofSize: thumbnailSize)
|
||||||
|
} else {
|
||||||
|
await image.byPreparingForDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadVideoThumbnail(url: URL) async {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
imageGenerator.appliesPreferredTrackTransform = true
|
||||||
|
#if os(visionOS)
|
||||||
|
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
|
||||||
|
self.mode = .image(UIImage(cgImage: cgImage))
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
|
||||||
|
self.mode = .image(UIImage(cgImage: cgImage))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||||
|
self.mode = .image(UIImage(cgImage: cgImage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
case empty
|
||||||
|
case image(UIImage)
|
||||||
|
case gifController(GIFController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,282 @@
|
|||||||
|
//
|
||||||
|
// AttachmentCollectionViewCell.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/20/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
import InstanceFeatures
|
||||||
|
import Vision
|
||||||
|
|
||||||
|
struct AttachmentCollectionViewCellView: View {
|
||||||
|
let attachment: DraftAttachment?
|
||||||
|
@State private var recognizingText = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let attachment {
|
||||||
|
AttachmentThumbnailView(attachment: attachment, contentMode: .fill)
|
||||||
|
.squareFrame()
|
||||||
|
.background {
|
||||||
|
RoundedSquare(cornerRadius: 5)
|
||||||
|
.fill(.quaternary)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
AttachmentDescriptionLabel(attachment: attachment, recognizingText: recognizingText)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
AttachmentOptionsMenu(attachment: attachment, recognizingText: $recognizingText)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
AttachmentRemoveButton(attachment: attachment)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedSquare(cornerRadius: 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AttachmentOptionsMenu: View {
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
@Binding var recognizingText: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if attachment.drawingData != nil || attachment.type == .image {
|
||||||
|
Menu {
|
||||||
|
if attachment.drawingData != nil {
|
||||||
|
EditDrawingButton(attachment: attachment)
|
||||||
|
} else if attachment.type == .image {
|
||||||
|
RecognizeTextButton(attachment: attachment, recognizingText: $recognizingText)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Options", systemImage: "ellipsis.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(AttachmentOverlayButtonStyle())
|
||||||
|
.padding([.top, .leading], 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RecognizeTextButton: View {
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
@Binding var recognizingText: Bool
|
||||||
|
@State private var error: (any Error)?
|
||||||
|
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: self.recognizeText) {
|
||||||
|
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
||||||
|
}
|
||||||
|
.alertWithData("Text Recognition Failed", data: $error) { _ in
|
||||||
|
Button("OK") {}
|
||||||
|
} message: { error in
|
||||||
|
Text(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recognizeText() {
|
||||||
|
recognizingText = true
|
||||||
|
attachment.getData(features: instanceFeatures) { result in
|
||||||
|
switch result {
|
||||||
|
case .failure(let error):
|
||||||
|
self.recognizingText = false
|
||||||
|
self.error = error
|
||||||
|
case .success(let (data, _)):
|
||||||
|
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.recognizingText = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.recognizingText = false
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EditDrawingButton: View {
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: self.editDrawing) {
|
||||||
|
Label("Edit Drawing", systemImage: "hand.draw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editDrawing() {
|
||||||
|
guard let drawing = attachment.drawing else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
presentDrawing?(drawing) { drawing in
|
||||||
|
self.attachment.drawing = drawing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AttachmentRemoveButton: View {
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button("Remove", systemImage: "xmark.circle.fill") {
|
||||||
|
let draft = attachment.draft
|
||||||
|
let attachments = draft.attachments.mutableCopy() as! NSMutableOrderedSet
|
||||||
|
attachments.remove(attachment)
|
||||||
|
draft.attachments = attachments
|
||||||
|
DraftsPersistentContainer.shared.viewContext.delete(attachment)
|
||||||
|
}
|
||||||
|
.buttonStyle(AttachmentOverlayButtonStyle())
|
||||||
|
.padding([.top, .trailing], 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AttachmentOverlayButtonStyle: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.imageScale(.large)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AttachmentDescriptionLabel: View {
|
||||||
|
@ObservedObject var attachment: DraftAttachment
|
||||||
|
let recognizingText: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [.init(color: .clear, location: 0.6), .init(color: .black.opacity(0.15), location: 0.7), .init(color: .black.opacity(0.5), location: 1)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
labelOrProgress
|
||||||
|
.padding([.horizontal, .bottom], 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var labelOrProgress: some View {
|
||||||
|
if recognizingText {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.shadow(color: .black.opacity(0.75), radius: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var label: some View {
|
||||||
|
if attachment.attachmentDescription.isEmpty {
|
||||||
|
Label("Add alt", systemImage: "pencil")
|
||||||
|
.labelStyle(NarrowSpacingLabelStyle())
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
Text(attachment.attachmentDescription)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NarrowSpacingLabelStyle: LabelStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
configuration.icon
|
||||||
|
configuration.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RoundedSquare: Shape {
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
|
||||||
|
nonisolated func path(in rect: CGRect) -> Path {
|
||||||
|
let minDimension = min(rect.width, rect.height)
|
||||||
|
let square = CGRect(x: rect.minX - (rect.width - minDimension) / 2, y: rect.minY - (rect.height - minDimension), width: minDimension, height: minDimension)
|
||||||
|
return RoundedRectangle(cornerRadius: cornerRadius).path(in: square)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
private struct SquareFrame: Layout {
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
precondition(subviews.count == 1)
|
||||||
|
let size = proposal.replacingUnspecifiedDimensions(by: subviews[0].sizeThatFits(proposal))
|
||||||
|
let minDimension = min(size.width, size.height)
|
||||||
|
return CGSize(width: minDimension, height: minDimension)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
precondition(subviews.count == 1)
|
||||||
|
let subviewSize = subviews[0].sizeThatFits(proposal)
|
||||||
|
let minDimension = min(bounds.width, bounds.height)
|
||||||
|
let origin = CGPoint(x: bounds.minX - (subviewSize.width - minDimension) / 2, y: bounds.minY - (subviewSize.height - minDimension) / 2)
|
||||||
|
subviews[0].place(at: origin, proposal: ProposedViewSize(subviewSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private struct LegacySquareFrame<Content: View>: View {
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let minDimension = min(proxy.size.width, proxy.size.height)
|
||||||
|
content
|
||||||
|
.frame(width: minDimension, height: minDimension, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func squareFrame() -> some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
SquareFrame {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
SquareFrame {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LegacySquareFrame {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,217 @@
|
|||||||
|
//
|
||||||
|
// AttachmentWrapperGalleryContentViewController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/22/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GalleryVC
|
||||||
|
|
||||||
|
class AttachmentWrapperGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||||
|
let draftAttachment: DraftAttachment
|
||||||
|
let wrapped: any GalleryContentViewController
|
||||||
|
|
||||||
|
var container: (any GalleryContentViewControllerContainer)?
|
||||||
|
|
||||||
|
var contentSize: CGSize {
|
||||||
|
wrapped.contentSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var activityItemsForSharing: [Any] {
|
||||||
|
wrapped.activityItemsForSharing
|
||||||
|
}
|
||||||
|
|
||||||
|
var caption: String? {
|
||||||
|
wrapped.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var editDescriptionViewController: EditAttachmentDescriptionViewController = EditAttachmentDescriptionViewController(draftAttachment: draftAttachment, wrapped: wrapped.bottomControlsAccessoryViewController)
|
||||||
|
|
||||||
|
var bottomControlsAccessoryViewController: UIViewController? {
|
||||||
|
editDescriptionViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||||
|
wrapped.presentationAnimation
|
||||||
|
}
|
||||||
|
|
||||||
|
var hideControlsOnZoom: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
var showBelowSafeArea: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
init(draftAttachment: DraftAttachment, wrapped: any GalleryContentViewController) {
|
||||||
|
self.draftAttachment = draftAttachment
|
||||||
|
self.wrapped = wrapped
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
wrapped.container = container
|
||||||
|
addChild(wrapped)
|
||||||
|
wrapped.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(wrapped.view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
wrapped.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
wrapped.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
wrapped.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
wrapped.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
wrapped.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||||
|
wrapped.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
|
||||||
|
if !visible {
|
||||||
|
editDescriptionViewController.textView?.resignFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInsetForBottomControls(_ inset: CGFloat) {
|
||||||
|
wrapped.setInsetForBottomControls(inset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func galleryContentDidAppear() {
|
||||||
|
wrapped.galleryContentDidAppear()
|
||||||
|
}
|
||||||
|
|
||||||
|
func galleryContentWillDisappear() {
|
||||||
|
wrapped.galleryContentWillDisappear()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldHideControls() -> Bool {
|
||||||
|
if editDescriptionViewController.textView.isFirstResponder {
|
||||||
|
editDescriptionViewController.textView.resignFirstResponder()
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func galleryShouldBeginInteractiveDismiss() -> Bool {
|
||||||
|
if editDescriptionViewController.textView.isFirstResponder {
|
||||||
|
editDescriptionViewController.textView.resignFirstResponder()
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class EditAttachmentDescriptionViewController: UIViewController {
|
||||||
|
private let draftAttachment: DraftAttachment
|
||||||
|
private let wrapped: UIViewController?
|
||||||
|
|
||||||
|
private(set) var textView: UITextView!
|
||||||
|
private var isShowingPlaceholder = false
|
||||||
|
|
||||||
|
private var descriptionObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
init(draftAttachment: DraftAttachment, wrapped: UIViewController?) {
|
||||||
|
self.draftAttachment = draftAttachment
|
||||||
|
self.wrapped = wrapped
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.overrideUserInterfaceStyle = .dark
|
||||||
|
view.backgroundColor = .secondarySystemFill
|
||||||
|
|
||||||
|
let stack = UIStackView()
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.distribution = .fill
|
||||||
|
stack.spacing = 0
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
if let wrapped {
|
||||||
|
addChild(wrapped)
|
||||||
|
stack.addArrangedSubview(wrapped.view)
|
||||||
|
wrapped.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
textView = UITextView()
|
||||||
|
textView.backgroundColor = nil
|
||||||
|
textView.font = .preferredFont(forTextStyle: .body)
|
||||||
|
textView.adjustsFontForContentSizeCategory = true
|
||||||
|
if draftAttachment.attachmentDescription.isEmpty {
|
||||||
|
showPlaceholder()
|
||||||
|
} else {
|
||||||
|
removePlaceholder()
|
||||||
|
textView.text = draftAttachment.attachmentDescription
|
||||||
|
}
|
||||||
|
textView.delegate = self
|
||||||
|
stack.addArrangedSubview(textView)
|
||||||
|
textView.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
||||||
|
|
||||||
|
descriptionObservation = draftAttachment.observe(\.attachmentDescription) { [unowned self] _, _ in
|
||||||
|
let desc = self.draftAttachment.attachmentDescription
|
||||||
|
if desc.isEmpty {
|
||||||
|
if !isShowingPlaceholder {
|
||||||
|
showPlaceholder()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if isShowingPlaceholder {
|
||||||
|
removePlaceholder()
|
||||||
|
}
|
||||||
|
self.textView.text = desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func showPlaceholder() {
|
||||||
|
isShowingPlaceholder = true
|
||||||
|
textView.text = "Describe for the visually impaired"
|
||||||
|
textView.textColor = .secondaryLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func removePlaceholder() {
|
||||||
|
isShowingPlaceholder = false
|
||||||
|
textView.text = ""
|
||||||
|
textView.textColor = .label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditAttachmentDescriptionViewController: UITextViewDelegate {
|
||||||
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
if isShowingPlaceholder {
|
||||||
|
removePlaceholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
|
draftAttachment.attachmentDescription = textView.text
|
||||||
|
|
||||||
|
if textView.text.isEmpty {
|
||||||
|
showPlaceholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
//
|
||||||
|
// AttachmentsGalleryDataSource.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/21/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GalleryVC
|
||||||
|
import TuskerComponents
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
struct AttachmentsGalleryDataSource: GalleryDataSource {
|
||||||
|
let collectionView: UICollectionView
|
||||||
|
let fetchImageAndGIFData: (URL) async -> (UIImage, Data)?
|
||||||
|
let makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)?
|
||||||
|
let attachmentAtIndex: (Int) -> DraftAttachment?
|
||||||
|
|
||||||
|
func galleryItemsCount() -> Int {
|
||||||
|
collectionView.numberOfItems(inSection: 0) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func galleryContentViewController(forItemAt index: Int) -> any GalleryVC.GalleryContentViewController {
|
||||||
|
let attachment = attachmentAtIndex(index)!
|
||||||
|
|
||||||
|
let content: any GalleryContentViewController
|
||||||
|
switch attachment.data {
|
||||||
|
case .editing(_, let kind, let url):
|
||||||
|
switch kind {
|
||||||
|
case .image:
|
||||||
|
content = LoadingGalleryContentViewController(caption: nil) {
|
||||||
|
if let (image, data) = await fetchImageAndGIFData(url) {
|
||||||
|
let gifController: GIFController? = if url.pathExtension == "gif" {
|
||||||
|
GIFController(gifData: data)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
return ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .video, .audio:
|
||||||
|
content = VideoGalleryContentViewController(url: url, caption: nil)
|
||||||
|
case .gifv:
|
||||||
|
content = LoadingGalleryContentViewController(caption: nil) { makeGifvGalleryContentVC(url) }
|
||||||
|
case .unknown:
|
||||||
|
content = LoadingGalleryContentViewController(caption: nil) { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
case .asset(let id):
|
||||||
|
content = LoadingGalleryContentViewController(caption: nil) {
|
||||||
|
if let (image, gifData) = await fetchAssetImageAndGIFData(assetID: id) {
|
||||||
|
let gifController = gifData.map(GIFController.init)
|
||||||
|
return ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .drawing(let drawing):
|
||||||
|
let image = drawing.imageInLightMode(from: drawing.bounds)
|
||||||
|
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: nil)
|
||||||
|
|
||||||
|
case .file(let url, let type):
|
||||||
|
if type.conforms(to: .movie) {
|
||||||
|
content = VideoGalleryContentViewController(url: url, caption: nil)
|
||||||
|
} else if type.conforms(to: .image),
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
let gifController = type == .gif ? GIFController(gifData: data) : nil
|
||||||
|
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
|
||||||
|
} else {
|
||||||
|
return LoadingGalleryContentViewController(caption: nil) {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .none:
|
||||||
|
return LoadingGalleryContentViewController(caption: nil) {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttachmentWrapperGalleryContentViewController(draftAttachment: attachment, wrapped: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
|
||||||
|
if let cell = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? HostingCollectionViewCell {
|
||||||
|
// Use the hostView, because otherwise, the animation's changes to the source view opacity get clobbered by SwiftUI
|
||||||
|
cell.hostView
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAssetImageAndGIFData(assetID id: String) async -> (UIImage, Data?)? {
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let (type, data) = await withCheckedContinuation { continuation in
|
||||||
|
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
||||||
|
continuation.resume(returning: (typeIdentifier, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let data,
|
||||||
|
let image = UIImage(data: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if type == UTType.gif.identifier {
|
||||||
|
return (image, data)
|
||||||
|
} else {
|
||||||
|
return (image, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,554 @@
|
|||||||
|
//
|
||||||
|
// AttachmentsSection.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/17/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import PencilKit
|
||||||
|
import GalleryVC
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
|
struct AttachmentsSection: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
private let spacing: CGFloat = 8
|
||||||
|
private let minItemSize: CGFloat = 100
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
collectionView
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
collectionView
|
||||||
|
} else {
|
||||||
|
LegacyCollectionViewSizingView {
|
||||||
|
collectionView
|
||||||
|
} computeHeight: { width in
|
||||||
|
WrappedCollectionView.totalHeight(width: width, minItemSize: minItemSize, spacing: spacing, items: draft.attachments.count + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var collectionView: some View {
|
||||||
|
WrappedCollectionView(
|
||||||
|
draft: draft,
|
||||||
|
spacing: spacing,
|
||||||
|
minItemSize: minItemSize
|
||||||
|
)
|
||||||
|
// Impose a minimum height, because otherwise it defaults to zero which prevents the collection
|
||||||
|
// view from laying out, and leaving the intrinsic content size at zero too.
|
||||||
|
// Add 4 to the minItemSize because otherwise drag-and-drop while reordering can alter the contentOffset by that much.
|
||||||
|
.frame(minHeight: minItemSize + 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func insertAttachments(in draft: Draft, at index: 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 {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||||
|
attachment.draft = draft
|
||||||
|
draft.attachments.add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private struct LegacyCollectionViewSizingView<Content: View>: View {
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
let computeHeight: (CGFloat) -> CGFloat
|
||||||
|
@State private var width: CGFloat = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let height = computeHeight(width)
|
||||||
|
|
||||||
|
content
|
||||||
|
.frame(height: max(height, 10))
|
||||||
|
.overlay {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: WidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(WidthPrefKey.self) {
|
||||||
|
width = $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WidthPrefKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat { 0 }
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
let next = nextValue()
|
||||||
|
if next != 0 {
|
||||||
|
value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Use a UIViewControllerRepresentable so we have something from which to present the gallery VC.
|
||||||
|
private struct WrappedCollectionView: UIViewControllerRepresentable {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
let spacing: CGFloat
|
||||||
|
let minItemSize: CGFloat
|
||||||
|
@Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData
|
||||||
|
@Environment(\.composeUIConfig.makeGifvGalleryContentVC) private var makeGifvGalleryContentVC
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> WrappedCollectionViewController {
|
||||||
|
WrappedCollectionViewController(
|
||||||
|
spacing: spacing,
|
||||||
|
minItemSize: minItemSize,
|
||||||
|
fetchImageAndGIFData: fetchImageAndGIFData,
|
||||||
|
makeGifvGalleryContentVC: makeGifvGalleryContentVC
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
|
||||||
|
uiViewController.draft = draft
|
||||||
|
uiViewController.addAttachment = {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.insert($0)
|
||||||
|
$0.draft = draft
|
||||||
|
draft.attachments.add($0)
|
||||||
|
}
|
||||||
|
uiViewController.updateAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: WrappedCollectionViewController, context: Context) -> CGSize? {
|
||||||
|
guard let width = proposal.width,
|
||||||
|
width.isFinite else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let count = draft.attachments.count + 1
|
||||||
|
return CGSize(
|
||||||
|
width: width,
|
||||||
|
height: Self.totalHeight(width: width, minItemSize: minItemSize, spacing: spacing, items: count)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static func itemSize(width: CGFloat, minItemSize: CGFloat, spacing: CGFloat) -> (CGFloat, Int) {
|
||||||
|
// The maximum item size is 2*minItemSize + spacing - 1,
|
||||||
|
// in the case where one item fits in the row but we are one pt short of
|
||||||
|
// adding a second item.
|
||||||
|
var itemSize = minItemSize
|
||||||
|
var fittingCount = floor((width + spacing) / (itemSize + spacing))
|
||||||
|
var usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
|
||||||
|
var remainingSpace = width - usedSpaceForFittingCount
|
||||||
|
if fittingCount == 0 {
|
||||||
|
return (0, 0)
|
||||||
|
} else if fittingCount == 1 && remainingSpace > minItemSize / 2 {
|
||||||
|
// If there's only one item that would fit at min size, and giving
|
||||||
|
// it the rest of the space would increase it by at least 50%,
|
||||||
|
// add a second item anywyas.
|
||||||
|
itemSize = (width - spacing) / 2
|
||||||
|
fittingCount = 2
|
||||||
|
usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
|
||||||
|
remainingSpace = width - usedSpaceForFittingCount
|
||||||
|
}
|
||||||
|
itemSize = itemSize + remainingSpace / fittingCount
|
||||||
|
return (itemSize, Int(fittingCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static func totalHeight(width: CGFloat, minItemSize: CGFloat, spacing: CGFloat, items: Int) -> CGFloat {
|
||||||
|
let (size, itemsPerRow) = itemSize(width: width, minItemSize: minItemSize, spacing: spacing)
|
||||||
|
guard itemsPerRow != 0 else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
let rows = ceil(Double(items) / Double(itemsPerRow))
|
||||||
|
return size * rows + spacing * (rows - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class WrappedCollectionViewController: UIViewController {
|
||||||
|
let spacing: CGFloat
|
||||||
|
let minItemSize: CGFloat
|
||||||
|
var draft: Draft!
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
fileprivate var currentInteractiveMoveStartOffsetInCell: CGPoint?
|
||||||
|
fileprivate var currentInteractiveMoveCell: HostingCollectionViewCell?
|
||||||
|
fileprivate var addAttachment: ((DraftAttachment) -> Void)? = nil
|
||||||
|
fileprivate var fetchImageAndGIFData: (URL) async -> (UIImage, Data)?
|
||||||
|
fileprivate var makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)?
|
||||||
|
|
||||||
|
var collectionView: UICollectionView {
|
||||||
|
view as! UICollectionView
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
spacing: CGFloat,
|
||||||
|
minItemSize: CGFloat,
|
||||||
|
fetchImageAndGIFData: @escaping (URL) async -> (UIImage, Data)?,
|
||||||
|
makeGifvGalleryContentVC: @escaping (URL) -> (any GalleryContentViewController)?
|
||||||
|
) {
|
||||||
|
self.spacing = spacing
|
||||||
|
self.minItemSize = minItemSize
|
||||||
|
self.fetchImageAndGIFData = fetchImageAndGIFData
|
||||||
|
self.makeGifvGalleryContentVC = makeGifvGalleryContentVC
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
let layout = UICollectionViewCompositionalLayout { [unowned self] section, environment in
|
||||||
|
let (itemSize, itemsPerRow) = WrappedCollectionView.itemSize(width: environment.container.contentSize.width, minItemSize: minItemSize, spacing: spacing)
|
||||||
|
|
||||||
|
let items = Array(repeating: NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(itemSize), heightDimension: .absolute(itemSize))), count: itemsPerRow)
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemSize)), subitems: items)
|
||||||
|
group.interItemSpacing = .fixed(spacing)
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.interGroupSpacing = spacing
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
let attachmentCell = UICollectionView.CellRegistration<HostingCollectionViewCell, DraftAttachment> { [unowned self] cell, indexPath, attachment in
|
||||||
|
#if !os(visionOS)
|
||||||
|
cell.containingViewController = self
|
||||||
|
#endif
|
||||||
|
cell.setView(AttachmentCollectionViewCellView(attachment: attachment))
|
||||||
|
}
|
||||||
|
let addButtonCell = UICollectionView.CellRegistration<HostingCollectionViewCell, Void> { [unowned self] cell, indexPath, item in
|
||||||
|
#if !os(visionOS)
|
||||||
|
cell.containingViewController = self
|
||||||
|
#endif
|
||||||
|
cell.setView(AddAttachmentButton(viewController: self))
|
||||||
|
}
|
||||||
|
let collectionView = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
self.view = collectionView
|
||||||
|
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||||
|
switch itemIdentifier {
|
||||||
|
case .attachment(let attachment):
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
|
||||||
|
case .addButton:
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.reorderingHandlers.canReorderItem = { item in
|
||||||
|
switch item {
|
||||||
|
case .attachment(_):
|
||||||
|
true
|
||||||
|
case .addButton:
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.reorderingHandlers.didReorder = { [unowned self] transaction in
|
||||||
|
let attachmentChanges = transaction.difference.map {
|
||||||
|
switch $0 {
|
||||||
|
case .insert(let offset, let element, let associatedWith):
|
||||||
|
guard case .attachment(let attachment) = element else { fatalError() }
|
||||||
|
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
|
||||||
|
case .remove(let offset, let element, let associatedWith):
|
||||||
|
guard case .attachment(let attachment) = element else { fatalError() }
|
||||||
|
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let attachmentsDiff = CollectionDifference(attachmentChanges)!
|
||||||
|
let array = draft.draftAttachments.applying(attachmentsDiff)!
|
||||||
|
draft.attachments = NSMutableOrderedSet(array: array)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionView.isScrollEnabled = false
|
||||||
|
collectionView.clipsToBounds = false
|
||||||
|
collectionView.delegate = self
|
||||||
|
|
||||||
|
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(reorderingLongPressRecognized))
|
||||||
|
longPressRecognizer.delegate = self
|
||||||
|
collectionView.addGestureRecognizer(longPressRecognizer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAttachments() {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.all])
|
||||||
|
snapshot.appendItems(draft.draftAttachments.map { .attachment($0) })
|
||||||
|
snapshot.appendItems([.addButton])
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
|
||||||
|
let collectionView = recognizer.view as! UICollectionView
|
||||||
|
switch recognizer.state {
|
||||||
|
case .began:
|
||||||
|
break
|
||||||
|
case .changed:
|
||||||
|
var pos = recognizer.location(in: collectionView)
|
||||||
|
if let currentInteractiveMoveStartOffsetInCell {
|
||||||
|
pos.x -= currentInteractiveMoveStartOffsetInCell.x
|
||||||
|
pos.y -= currentInteractiveMoveStartOffsetInCell.y
|
||||||
|
}
|
||||||
|
collectionView.updateInteractiveMovementTargetPosition(pos)
|
||||||
|
case .ended:
|
||||||
|
collectionView.endInteractiveMovement()
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
self.currentInteractiveMoveCell?.hostView?.transform = .identity
|
||||||
|
}
|
||||||
|
currentInteractiveMoveCell = nil
|
||||||
|
currentInteractiveMoveStartOffsetInCell = nil
|
||||||
|
case .cancelled:
|
||||||
|
collectionView.cancelInteractiveMovement()
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
self.currentInteractiveMoveCell?.hostView?.transform = .identity
|
||||||
|
}
|
||||||
|
currentInteractiveMoveCell = nil
|
||||||
|
currentInteractiveMoveStartOffsetInCell = nil
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Section {
|
||||||
|
case all
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Item: Hashable {
|
||||||
|
case attachment(DraftAttachment)
|
||||||
|
case addButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WrappedCollectionViewController: UIGestureRecognizerDelegate {
|
||||||
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
let collectionView = gestureRecognizer.view as! UICollectionView
|
||||||
|
let location = gestureRecognizer.location(in: collectionView)
|
||||||
|
guard let indexPath = collectionView.indexPathForItem(at: location),
|
||||||
|
let cell = collectionView.cellForItem(at: indexPath) as? HostingCollectionViewCell else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard collectionView.beginInteractiveMovementForItem(at: indexPath) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
cell.hostView?.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
||||||
|
}
|
||||||
|
currentInteractiveMoveCell = cell
|
||||||
|
currentInteractiveMoveStartOffsetInCell = gestureRecognizer.location(in: cell)
|
||||||
|
currentInteractiveMoveStartOffsetInCell!.x -= cell.bounds.midX
|
||||||
|
currentInteractiveMoveStartOffsetInCell!.y -= cell.bounds.midY
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WrappedCollectionViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
|
||||||
|
let snapshot = dataSource.snapshot()
|
||||||
|
let items = snapshot.itemIdentifiers(inSection: .all).count
|
||||||
|
if proposedIndexPath.row == items - 1 {
|
||||||
|
return IndexPath(item: items - 2, section: proposedIndexPath.section)
|
||||||
|
} else {
|
||||||
|
return proposedIndexPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard case .attachment(_) = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let dataSource = AttachmentsGalleryDataSource(
|
||||||
|
collectionView: collectionView,
|
||||||
|
fetchImageAndGIFData: self.fetchImageAndGIFData,
|
||||||
|
makeGifvGalleryContentVC: self.makeGifvGalleryContentVC
|
||||||
|
) { [dataSource] in
|
||||||
|
let item = dataSource?.itemIdentifier(for: IndexPath(item: $0, section: 0))
|
||||||
|
switch item {
|
||||||
|
case .attachment(let attachment):
|
||||||
|
return attachment
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let galleryVC = GalleryViewController(dataSource: dataSource, initialItemIndex: indexPath.item)
|
||||||
|
galleryVC.showShareButton = false
|
||||||
|
present(galleryVC, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class IntrinsicContentSizeCollectionView: UICollectionView {
|
||||||
|
private var _intrinsicContentSize = CGSize.zero
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
_intrinsicContentSize
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
if contentSize != _intrinsicContentSize {
|
||||||
|
_intrinsicContentSize = contentSize
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(visionOS)
|
||||||
|
final class HostingCollectionViewCell: UICollectionViewCell {
|
||||||
|
private(set) var hostView: UIView?
|
||||||
|
|
||||||
|
func setView<V: View>(_ view: V) {
|
||||||
|
let config = UIHostingConfiguration(content: {
|
||||||
|
view
|
||||||
|
}).margins(.all, 0)
|
||||||
|
|
||||||
|
if let hostView = hostView as? UIContentView {
|
||||||
|
hostView.configuration = config
|
||||||
|
} else {
|
||||||
|
hostView = config.makeContentView()
|
||||||
|
hostView!.frame = contentView.bounds
|
||||||
|
contentView.addSubview(hostView!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
final class HostingCollectionViewCell: UICollectionViewCell {
|
||||||
|
weak var containingViewController: UIViewController?
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private var hostController: UIHostingController<AnyView>?
|
||||||
|
private(set) var hostView: UIView?
|
||||||
|
|
||||||
|
func setView<V: View>(_ view: V) {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
let config = UIHostingConfiguration(content: {
|
||||||
|
view
|
||||||
|
}).margins(.all, 0)
|
||||||
|
|
||||||
|
// We don't just use the cell's contentConfiguration property because we need to animate
|
||||||
|
// the size of the host view, and when the host view is the contentView, that doesn't work.
|
||||||
|
if let hostView = hostView as? UIContentView {
|
||||||
|
hostView.configuration = config
|
||||||
|
} else {
|
||||||
|
hostView = config.makeContentView()
|
||||||
|
hostView!.frame = contentView.bounds
|
||||||
|
contentView.addSubview(hostView!)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let hostController {
|
||||||
|
hostController.rootView = AnyView(view)
|
||||||
|
} else {
|
||||||
|
let host = UIHostingController(rootView: AnyView(view))
|
||||||
|
containingViewController!.addChild(host)
|
||||||
|
host.view.frame = contentView.bounds
|
||||||
|
contentView.addSubview(host.view)
|
||||||
|
host.didMove(toParent: containingViewController!)
|
||||||
|
hostController = host
|
||||||
|
hostView = host.view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private struct AddAttachmentButton: View {
|
||||||
|
unowned let viewController: WrappedCollectionViewController
|
||||||
|
@Environment(\.canAddAttachment) private var enabled
|
||||||
|
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
|
||||||
|
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
if let presentAssetPicker {
|
||||||
|
Button("Add photo or video", systemImage: "photo") {
|
||||||
|
presentAssetPicker {
|
||||||
|
let draft = viewController.draft!
|
||||||
|
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let presentDrawing {
|
||||||
|
Button("Draw something", systemImage: "hand.draw") {
|
||||||
|
presentDrawing(PKDrawing()) { drawing in
|
||||||
|
let draft = viewController.draft!
|
||||||
|
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
||||||
|
attachment.id = UUID()
|
||||||
|
attachment.drawing = drawing
|
||||||
|
attachment.draft = draft
|
||||||
|
draft.attachments.add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.imageScale(.large)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 5)
|
||||||
|
.foregroundStyle(.tint.opacity(0.1))
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 5)
|
||||||
|
.stroke(.tint, style: StrokeStyle(lineWidth: 2, dash: [5]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!enabled)
|
||||||
|
.animation(.linear(duration: 0.2), value: enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
"photo.badge.plus"
|
||||||
|
} else {
|
||||||
|
"photo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AddAttachmentConditionsModifier: ViewModifier {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||||
|
|
||||||
|
private var canAddAttachment: Bool {
|
||||||
|
if instanceFeatures.mastodonAttachmentRestrictions {
|
||||||
|
return draft.attachments.count < 4
|
||||||
|
&& draft.draftAttachments.allSatisfy { $0.type == .image }
|
||||||
|
&& !draft.pollEnabled
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.environment(\.canAddAttachment, canAddAttachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CanAddAttachmentKey: EnvironmentKey {
|
||||||
|
static let defaultValue = false
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var canAddAttachment: Bool {
|
||||||
|
get { self[CanAddAttachmentKey.self] }
|
||||||
|
set { self[CanAddAttachmentKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DropAttachmentModifier: ViewModifier {
|
||||||
|
let draft: Draft
|
||||||
|
@Environment(\.canAddAttachment) private var canAddAttachment
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, delegate: AttachmentDropDelegate(draft: draft, canAddAttachment: canAddAttachment))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AttachmentDropDelegate: DropDelegate {
|
||||||
|
let draft: Draft
|
||||||
|
let canAddAttachment: Bool
|
||||||
|
|
||||||
|
func validateDrop(info: DropInfo) -> Bool {
|
||||||
|
canAddAttachment
|
||||||
|
}
|
||||||
|
|
||||||
|
func performDrop(info: DropInfo) -> Bool {
|
||||||
|
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,242 @@
|
|||||||
|
//
|
||||||
|
// ComposeNavigationBarActions.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/30/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import InstanceFeatures
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
struct ComposeNavigationBarActions: ToolbarContent {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@Binding var isShowingDrafts: Bool
|
||||||
|
let isPosting: Bool
|
||||||
|
let cancel: (_ deleteDraft: Bool) -> Void
|
||||||
|
let postStatus: () async -> Void
|
||||||
|
|
||||||
|
var body: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
ToolbarCancelButton(draft: draft, isPosting: isPosting, cancel: cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
DraftsButton(isShowingDrafts: $isShowingDrafts)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
PostButton(draft: draft, isPosting: isPosting, postStatus: postStatus)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
PostOrDraftsButton(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: isPosting, postStatus: postStatus)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ToolbarCancelButton: View {
|
||||||
|
let draft: Draft
|
||||||
|
let isPosting: Bool
|
||||||
|
let cancel: (_ deleteDraft: Bool) -> Void
|
||||||
|
@State private var isShowingSaveDraftSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(role: .cancel, action: self.showConfirmationOrCancel) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
.disabled(isPosting)
|
||||||
|
.confirmationDialog("Are you sure?", isPresented: $isShowingSaveDraftSheet) {
|
||||||
|
// edit drafts can't be saved
|
||||||
|
if draft.editedStatusID == nil {
|
||||||
|
Button(action: { cancel(false) }) {
|
||||||
|
Text("Save Draft")
|
||||||
|
}
|
||||||
|
Button(role: .destructive, action: { cancel(true) }) {
|
||||||
|
Text("Delete Draft")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(role: .destructive, action: { cancel(true) }) {
|
||||||
|
Text("Cancel Edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showConfirmationOrCancel() {
|
||||||
|
if draft.hasContent {
|
||||||
|
isShowingSaveDraftSheet = true
|
||||||
|
} else {
|
||||||
|
cancel(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
private struct PostOrDraftsButton: View {
|
||||||
|
@DraftObserving var draft: Draft
|
||||||
|
@Binding var isShowingDrafts: Bool
|
||||||
|
let isPosting: Bool
|
||||||
|
let postStatus: () async -> Void
|
||||||
|
@Environment(\.composeUIConfig.allowSwitchingDrafts) private var allowSwitchingDrafts
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !draftIsEmpty || draft.editedStatusID != nil || !allowSwitchingDrafts {
|
||||||
|
PostButton(draft: draft, isPosting: isPosting, postStatus: postStatus)
|
||||||
|
} else {
|
||||||
|
DraftsButton(isShowingDrafts: $isShowingDrafts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var draftIsEmpty: Bool {
|
||||||
|
draft.text == draft.initialText && (!draft.contentWarningEnabled || draft.contentWarning == draft.initialContentWarning) && draft.attachments.count == 0 && (!draft.pollEnabled || !draft.poll!.hasContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private struct PostButton: View {
|
||||||
|
@DraftObserving var draft: Draft
|
||||||
|
let isPosting: Bool
|
||||||
|
let postStatus: () async -> Void
|
||||||
|
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||||
|
@PreferenceObserving(\.$requireAttachmentDescriptions) private var requireAttachmentDescriptions
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await postStatus()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
|
.disabled(!draftValid)
|
||||||
|
.disabled(isPosting)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasCharactersRemaining: Bool {
|
||||||
|
let limit = instanceFeatures.maxStatusChars
|
||||||
|
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||||
|
let bodyCount = CharacterCounter.count(text: draft.text, for: instanceFeatures)
|
||||||
|
let remaining = limit - (cwCount + bodyCount)
|
||||||
|
return remaining >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var attachmentsCombinationValid: Bool {
|
||||||
|
if !instanceFeatures.mastodonAttachmentRestrictions {
|
||||||
|
true
|
||||||
|
} else if draft.attachments.count > 1,
|
||||||
|
draft.draftAttachments.contains(where: { $0.type == .video }) {
|
||||||
|
false
|
||||||
|
} else if draft.attachments.count > 4 {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var attachmentsValid: Bool {
|
||||||
|
(!requireAttachmentDescriptions || draft.draftAttachments.allSatisfy { !$0.attachmentDescription.isEmpty })
|
||||||
|
&& attachmentsCombinationValid
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pollValid: Bool {
|
||||||
|
!draft.pollEnabled || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var draftValid: Bool {
|
||||||
|
draft.editedStatusID != nil ||
|
||||||
|
((draft.hasText || draft.attachments.count > 0)
|
||||||
|
&& hasCharactersRemaining
|
||||||
|
&& attachmentsValid
|
||||||
|
&& pollValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DraftsButton: View {
|
||||||
|
@Binding var isShowingDrafts: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
isShowingDrafts = true
|
||||||
|
} label: {
|
||||||
|
Text("Drafts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This property wrapper lets a View observe all of the following:
|
||||||
|
// 1. The Draft itself
|
||||||
|
// 2. The Draft's Poll (if it has one)
|
||||||
|
// 3. Each of the Poll's PollOptions (if there is a Poll)
|
||||||
|
// 4. Each of the Draft's DraftAttachments
|
||||||
|
@propertyWrapper
|
||||||
|
private struct DraftObserving: DynamicProperty {
|
||||||
|
let wrappedValue: Draft
|
||||||
|
@StateObject private var observer = Observer()
|
||||||
|
|
||||||
|
init(wrappedValue: Draft) {
|
||||||
|
self.wrappedValue = wrappedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func update() {
|
||||||
|
observer.update(draft: wrappedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Observer: ObservableObject {
|
||||||
|
private var draft: Draft?
|
||||||
|
|
||||||
|
private var cancellable: AnyCancellable?
|
||||||
|
private var draftPollObservation: NSKeyValueObservation?
|
||||||
|
private var pollOptionsObservation: NSKeyValueObservation?
|
||||||
|
private var pollOptionsCancellables: [AnyCancellable] = []
|
||||||
|
private var draftAttachmentsObservation: NSKeyValueObservation?
|
||||||
|
private var draftAttachmentsCancellables: [AnyCancellable] = []
|
||||||
|
|
||||||
|
func update(draft: Draft) {
|
||||||
|
guard draft !== self.draft else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.draft = draft
|
||||||
|
cancellable = draft.objectWillChange
|
||||||
|
.sink { [unowned self] _ in self.objectWillChange.send() }
|
||||||
|
draftPollObservation = draft.observe(\.poll) { [unowned self] _, _ in
|
||||||
|
objectWillChange.send()
|
||||||
|
self.pollChanged()
|
||||||
|
}
|
||||||
|
pollChanged()
|
||||||
|
draftAttachmentsObservation = draft.observe(\.attachments) { [unowned self] _, _ in
|
||||||
|
objectWillChange.send()
|
||||||
|
self.draftAttachmentsChanged()
|
||||||
|
}
|
||||||
|
draftAttachmentsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pollChanged() {
|
||||||
|
pollOptionsObservation = (draft?.poll).map {
|
||||||
|
$0.observe(\.options) { [unowned self] _, _ in
|
||||||
|
objectWillChange.send()
|
||||||
|
self.pollOptionsChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pollOptionsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pollOptionsChanged() {
|
||||||
|
pollOptionsCancellables = draft?.poll?.pollOptions.map {
|
||||||
|
$0.objectWillChange
|
||||||
|
.sink { [unowned self] _ in self.objectWillChange.send() }
|
||||||
|
} ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func draftAttachmentsChanged() {
|
||||||
|
draftAttachmentsCancellables = draft?.draftAttachments.map {
|
||||||
|
$0.objectWillChange
|
||||||
|
.sink { [unowned self] _ in self.objectWillChange.send() }
|
||||||
|
} ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,242 @@
|
|||||||
|
//
|
||||||
|
// ComposeToolbarView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/10/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import TuskerComponents
|
||||||
|
import InstanceFeatures
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
struct ComposeToolbarView: View {
|
||||||
|
static let height: CGFloat = 44
|
||||||
|
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
let mastodonController: any ComposeMastodonContext
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
buttons
|
||||||
|
#else
|
||||||
|
ToolbarScrollView {
|
||||||
|
buttons
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
.frame(height: Self.height)
|
||||||
|
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Divider()
|
||||||
|
.ignoresSafeArea(edges: [.leading, .trailing])
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttons: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ContentWarningButton(enabled: $draft.contentWarningEnabled, focusedField: $focusedField)
|
||||||
|
|
||||||
|
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||||
|
|
||||||
|
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController)
|
||||||
|
|
||||||
|
InsertEmojiButton()
|
||||||
|
|
||||||
|
FormatButtons()
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
private struct ToolbarScrollView<Content: View>: View {
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
@State private var minWidth: CGFloat?
|
||||||
|
@State private var realWidth: CGFloat?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
content
|
||||||
|
.frame(minWidth: minWidth)
|
||||||
|
.background {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(ToolbarWidthPrefKey.self) {
|
||||||
|
realWidth = $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(ToolbarWidthPrefKey.self) {
|
||||||
|
minWidth = $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private struct ToolbarWidthPrefKey: SwiftUI.PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat? = nil
|
||||||
|
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
||||||
|
value = value ?? nextValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContentWarningButton: View {
|
||||||
|
@Binding var enabled: Bool
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button("CW", action: toggleContentWarning)
|
||||||
|
.accessibilityLabel(enabled ? "Remove content warning" : "Add content warning")
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleContentWarning() {
|
||||||
|
enabled.toggle()
|
||||||
|
if focusedField != nil {
|
||||||
|
if enabled {
|
||||||
|
focusedField = .contentWarning
|
||||||
|
} else if focusedField == .contentWarning {
|
||||||
|
focusedField = .body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct VisibilityButton: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@ObservedObject var instanceFeatures: InstanceFeatures
|
||||||
|
|
||||||
|
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,
|
||||||
|
instanceFeatures.localOnlyPostsVisibility {
|
||||||
|
return .constant(.public)
|
||||||
|
} else {
|
||||||
|
return $draft.visibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
|
||||||
|
let visibilities: [Pachyderm.Visibility]
|
||||||
|
if !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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
||||||
|
.disabled(draft.editedStatusID != nil)
|
||||||
|
.disabled(instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LocalOnlyButton: View {
|
||||||
|
@Binding var enabled: Bool
|
||||||
|
var mastodonController: any ComposeMastodonContext
|
||||||
|
@ObservedObject private var instanceFeatures: InstanceFeatures
|
||||||
|
|
||||||
|
init(enabled: Binding<Bool>, mastodonController: any ComposeMastodonContext) {
|
||||||
|
self._enabled = enabled
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.instanceFeatures = mastodonController.instanceFeatures
|
||||||
|
}
|
||||||
|
|
||||||
|
private var options: [MenuPicker<Bool>.Option] {
|
||||||
|
let domain = mastodonController.accountInfo!.instanceURL.host!
|
||||||
|
return [
|
||||||
|
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
|
||||||
|
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if mastodonController.instanceFeatures.localOnlyPosts {
|
||||||
|
MenuPicker(selection: $enabled, options: options, buttonStyle: .iconOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InsertEmojiButton: View {
|
||||||
|
@FocusedValue(\.composeInput) private var input
|
||||||
|
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if input??.toolbarElements.contains(.emojiPicker) == true {
|
||||||
|
Button(action: beginAutocompletingEmoji) {
|
||||||
|
Label("Insert custom emoji", systemImage: "face.smiling")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.system(size: imageSize))
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginAutocompletingEmoji() {
|
||||||
|
input??.beginAutocompletingEmoji()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FormatButtons: View {
|
||||||
|
@FocusedValue(\.composeInput) private var input
|
||||||
|
@PreferenceObserving(\.$statusContentType) private var contentType
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let input = input.flatMap(\.self),
|
||||||
|
input.toolbarElements.contains(.formattingButtons),
|
||||||
|
contentType != .plain {
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
ForEach(StatusFormat.allCases) { format in
|
||||||
|
FormatButton(format: format, input: input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FormatButton: View {
|
||||||
|
let format: StatusFormat
|
||||||
|
let input: any ComposeInput
|
||||||
|
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: applyFormat) {
|
||||||
|
Image(systemName: format.imageName)
|
||||||
|
.font(.system(size: imageSize))
|
||||||
|
}
|
||||||
|
.accessibilityLabel(format.accessibilityLabel)
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyFormat() {
|
||||||
|
input.applyFormat(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// ComposeToolbarView()
|
||||||
|
//}
|
296
Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
//
|
||||||
|
// ComposeView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/10/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CoreData
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
// State owned by the compose UI but that needs to be accessible from outside.
|
||||||
|
public final class ComposeViewState: ObservableObject {
|
||||||
|
@Published var poster: PostService?
|
||||||
|
@Published public internal(set) var draft: Draft
|
||||||
|
@Published public internal(set) var didPostSuccessfully = false
|
||||||
|
|
||||||
|
public var isPosting: Bool {
|
||||||
|
poster != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(draft: Draft) {
|
||||||
|
self.draft = draft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ComposeView: View {
|
||||||
|
@ObservedObject var state: ComposeViewState
|
||||||
|
let mastodonController: any ComposeMastodonContext
|
||||||
|
let currentAccount: (any AccountProtocol)?
|
||||||
|
let config: ComposeUIConfig
|
||||||
|
|
||||||
|
public init(
|
||||||
|
state: ComposeViewState,
|
||||||
|
mastodonController: any ComposeMastodonContext,
|
||||||
|
currentAccount: (any AccountProtocol)?,
|
||||||
|
config: ComposeUIConfig
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.currentAccount = currentAccount
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ComposeViewBody(
|
||||||
|
draft: state.draft,
|
||||||
|
mastodonController: mastodonController,
|
||||||
|
state: state,
|
||||||
|
setDraft: self.setDraft
|
||||||
|
)
|
||||||
|
.environment(\.composeUIConfig, config)
|
||||||
|
.environment(\.currentAccount, currentAccount)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setDraft(_ draft: Draft) {
|
||||||
|
let oldDraft = state.draft
|
||||||
|
state.draft = draft
|
||||||
|
|
||||||
|
if oldDraft.hasContent {
|
||||||
|
oldDraft.lastModified = Date()
|
||||||
|
} else {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
|
||||||
|
}
|
||||||
|
DraftsPersistentContainer.shared.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
||||||
|
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
|
||||||
|
deleted.contains(where: { $0.objectID == state.draft.objectID }) {
|
||||||
|
config.dismiss(.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: see if this can be broken up further
|
||||||
|
private struct ComposeViewBody: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
let mastodonController: any ComposeMastodonContext
|
||||||
|
@ObservedObject var state: ComposeViewState
|
||||||
|
let setDraft: (Draft) -> Void
|
||||||
|
@State private var postError: PostService.Error?
|
||||||
|
@FocusState private var focusedField: FocusableField?
|
||||||
|
@State private var isShowingDrafts = false
|
||||||
|
@State private var isDismissing = false
|
||||||
|
@State private var userConfirmedDelete = false
|
||||||
|
@Environment(\.composeUIConfig) private var config
|
||||||
|
@PreferenceObserving(\.$statusContentType) private var statusContentType
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
navigation
|
||||||
|
.environmentObject(mastodonController.instanceFeatures)
|
||||||
|
.sheet(isPresented: $isShowingDrafts) {
|
||||||
|
DraftsView(
|
||||||
|
currentDraft: draft,
|
||||||
|
isShowingDrafts: $isShowingDrafts,
|
||||||
|
accountInfo: mastodonController.accountInfo!,
|
||||||
|
selectDraft: {
|
||||||
|
self.setDraft($0)
|
||||||
|
self.isShowingDrafts = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.alertWithData("Error Posting", data: $postError, actions: { _ in
|
||||||
|
Button("OK") {}
|
||||||
|
}, message: { error in
|
||||||
|
Text(error.localizedDescription)
|
||||||
|
})
|
||||||
|
.onDisappear(perform: self.deleteOrSaveDraft)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var navigation: some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
NavigationStack {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
NavigationStack {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavigationView {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var navigationRoot: some View {
|
||||||
|
ZStack {
|
||||||
|
ScrollView {
|
||||||
|
scrollContent
|
||||||
|
}
|
||||||
|
#if !os(visionOS)
|
||||||
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
|
#endif
|
||||||
|
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
||||||
|
.modifier(ToolbarSafeAreaInsetModifier())
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if let poster = state.poster {
|
||||||
|
PostProgressView(poster: poster)
|
||||||
|
.frame(alignment: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if !os(visionOS)
|
||||||
|
.overlay(alignment: .bottom, content: {
|
||||||
|
// This needs to be in an overlay, ignoring the keyboard safe area
|
||||||
|
// doesn't work with the safeAreaInset modifier.
|
||||||
|
if config.showToolbar {
|
||||||
|
toolbarView
|
||||||
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
|
// TODO: use a input accessory view (controller) for the toolbar
|
||||||
|
// .ignoresSafeArea(.keyboard)
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
.animation(.snappy, value: config.showToolbar)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
#endif
|
||||||
|
// Have these after the overlays so they barely work instead of not working at all. FB11790805
|
||||||
|
.modifier(DropAttachmentModifier(draft: draft))
|
||||||
|
.modifier(AddAttachmentConditionsModifier(draft: draft))
|
||||||
|
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ComposeNavigationBarActions(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: state.isPosting, cancel: self.cancel(deleteDraft:), postStatus: self.postStatus)
|
||||||
|
#if os(visionOS)
|
||||||
|
ToolbarItem(placement: .bottomOrnament) {
|
||||||
|
toolbarView
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var toolbarView: some View {
|
||||||
|
ComposeToolbarView(draft: draft, mastodonController: mastodonController, focusedField: $focusedField)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var scrollContent: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
|
||||||
|
|
||||||
|
DraftEditor(draft: draft, focusedField: $focusedField)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addAttachments(_ itemProviders: [NSItemProvider]) {
|
||||||
|
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteOrSaveDraft() {
|
||||||
|
if isDismissing,
|
||||||
|
!draft.hasContent || state.didPostSuccessfully || userConfirmedDelete {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||||
|
} else {
|
||||||
|
draft.lastModified = Date()
|
||||||
|
}
|
||||||
|
DraftsPersistentContainer.shared.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancel(deleteDraft: Bool) {
|
||||||
|
isDismissing = true
|
||||||
|
userConfirmedDelete = deleteDraft
|
||||||
|
config.dismiss(.cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postStatus() async {
|
||||||
|
guard !state.isPosting,
|
||||||
|
draft.editedStatusID != nil || draft.hasContent else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let poster = PostService(mastodonController: mastodonController, contentType: statusContentType, draft: draft)
|
||||||
|
state.poster = poster
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await poster.post()
|
||||||
|
|
||||||
|
isDismissing = true
|
||||||
|
state.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 {
|
||||||
|
self.postError = error
|
||||||
|
state.poster = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ComposeView {
|
||||||
|
public static func navigationTitle(for draft: Draft, mastodonController: any ComposeMastodonContext) -> String {
|
||||||
|
if let id = draft.inReplyToID,
|
||||||
|
let status = mastodonController.fetchStatus(id: id) {
|
||||||
|
return "Reply to @\(status.account.acct)"
|
||||||
|
} else if draft.editedStatusID != nil {
|
||||||
|
return "Edit Post"
|
||||||
|
} else {
|
||||||
|
return "New Post"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NavigationTitleModifier: ViewModifier {
|
||||||
|
let draft: Draft
|
||||||
|
let mastodonController: any ComposeMastodonContext
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
let title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
|
||||||
|
content
|
||||||
|
.navigationTitle(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FocusableField: Hashable {
|
||||||
|
case contentWarning
|
||||||
|
case body
|
||||||
|
case pollOption(NSManagedObjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
||||||
|
private struct ToolbarSafeAreaInsetModifier: ViewModifier {
|
||||||
|
@StateObject private var keyboardReader = KeyboardReader()
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
content
|
||||||
|
.safeAreaPadding(.bottom, keyboardReader.isVisible ? 0 : ComposeToolbarView.height)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
if !keyboardReader.isVisible {
|
||||||
|
Color.clear.frame(height: ComposeToolbarView.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// ComposeView()
|
||||||
|
//}
|
@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// ContentWarningTextField.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/10/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentWarningTextField: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
EmojiTextField(
|
||||||
|
text: $draft.contentWarning,
|
||||||
|
placeholder: "Write your warning here",
|
||||||
|
maxLength: nil,
|
||||||
|
focusNextView: {
|
||||||
|
focusedField = .body
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .contentWarning)
|
||||||
|
.modifier(FocusedInputModifier())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// ContentWarningTextField()
|
||||||
|
//}
|
@ -0,0 +1,137 @@
|
|||||||
|
//
|
||||||
|
// DraftContentEditor.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/16/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
|
struct DraftContentEditor: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
|
||||||
|
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
LanguageButton(draft: draft)
|
||||||
|
TogglePollButton(draft: draft, focusedField: $focusedField)
|
||||||
|
Spacer()
|
||||||
|
CharactersRemaining(draft: draft)
|
||||||
|
.padding(.trailing, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.composePlatterBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addAttachments(_ providers: [NSItemProvider]) {
|
||||||
|
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: providers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CharactersRemaining: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@EnvironmentObject var instanceFeatures: InstanceFeatures
|
||||||
|
|
||||||
|
private var charsRemaining: Int {
|
||||||
|
let limit = instanceFeatures.maxStatusChars
|
||||||
|
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||||
|
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(verbatim: charsRemaining.description)
|
||||||
|
.foregroundStyle(charsRemaining < 0 ? .red : .secondary)
|
||||||
|
.font(.body.monospacedDigit())
|
||||||
|
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LanguageButton: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||||
|
@FocusedValue(\.composeInput) private var input
|
||||||
|
@State private var hasChanged = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if #available(iOS 16.0, *),
|
||||||
|
instanceFeatures.createStatusWithLanguage {
|
||||||
|
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
|
||||||
|
.buttonStyle(LanguageButtonStyle())
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
private func currentInputModeChanged(_ notification: Foundation.Notification) {
|
||||||
|
guard !hasChanged,
|
||||||
|
!draft.hasContent,
|
||||||
|
let mode = input??.textInputMode,
|
||||||
|
let code = LanguagePicker.codeFromInputMode(mode) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.language = code.identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LanguageButtonStyle: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.body.monospaced())
|
||||||
|
.foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1))
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3))
|
||||||
|
.animation(.linear(duration: 0.1), value: configuration.isPressed)
|
||||||
|
.padding(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TogglePollButton: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: togglePoll) {
|
||||||
|
Image(systemName: draft.pollEnabled ? "chart.bar.doc.horizontal.fill" : "chart.bar.doc.horizontal")
|
||||||
|
}
|
||||||
|
.buttonStyle(LanguageButtonStyle())
|
||||||
|
.disabled(disabled)
|
||||||
|
.animation(.linear(duration: 0.2), value: disabled)
|
||||||
|
.animation(.linear(duration: 0.2), value: draft.pollEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var disabled: Bool {
|
||||||
|
instanceFeatures.mastodonAttachmentRestrictions && draft.attachments.count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func togglePoll() {
|
||||||
|
if draft.pollEnabled {
|
||||||
|
draft.pollEnabled = false
|
||||||
|
|
||||||
|
if case .pollOption(_) = focusedField {
|
||||||
|
focusedField = .body
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let poll: Poll
|
||||||
|
if let p = draft.poll {
|
||||||
|
poll = p
|
||||||
|
} else {
|
||||||
|
poll = Poll(context: DraftsPersistentContainer.shared.viewContext)
|
||||||
|
draft.poll = poll
|
||||||
|
}
|
||||||
|
draft.pollEnabled = true
|
||||||
|
|
||||||
|
if focusedField != nil {
|
||||||
|
let optionToFocus = poll.pollOptions.first(where: { $0.text.isEmpty }) ?? poll.pollOptions.last!
|
||||||
|
focusedField = .pollOption(optionToFocus.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
97
Packages/ComposeUI/Sources/ComposeUI/Views/DraftEditor.swift
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// DraftEditor.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/16/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
struct DraftEditor: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
@Environment(\.currentAccount) private var currentAccount
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
// TODO: scroll effect?
|
||||||
|
AvatarView(account: currentAccount)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if let currentAccount {
|
||||||
|
AccountNameView(account: currentAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if draft.contentWarningEnabled {
|
||||||
|
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
DraftContentEditor(draft: draft, focusedField: $focusedField)
|
||||||
|
|
||||||
|
if let poll = draft.poll,
|
||||||
|
draft.pollEnabled {
|
||||||
|
PollEditor(poll: poll, focusedField: $focusedField)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
// So that during the appearance transition, it's behind the text view.
|
||||||
|
.zIndex(-1)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
AttachmentsSection(draft: draft)
|
||||||
|
// We want the padding between the poll/attachments to be part of the poll, so it animates in/out with the transition.
|
||||||
|
// Otherwise, when the poll is added, its bottom edge is aligned with the top edge of the attachments
|
||||||
|
.padding(.top, draft.pollEnabled ? -4 : 0)
|
||||||
|
}
|
||||||
|
// These animations are here, because the height of the VStack and the positions of the lower views needs to animate too.
|
||||||
|
.animation(.snappy, value: draft.pollEnabled)
|
||||||
|
.modifier(PollAnimatingModifier(poll: draft.poll))
|
||||||
|
.animation(.snappy, value: draft.contentWarningEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AvatarView: View {
|
||||||
|
let account: (any AccountProtocol)?
|
||||||
|
@PreferenceObserving(\.$avatarStyle) private var avatarStyle
|
||||||
|
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
AvatarImageView(
|
||||||
|
url: account?.avatar,
|
||||||
|
size: 50,
|
||||||
|
style: avatarStyle == .circle ? .circle : .roundRect,
|
||||||
|
fetchAvatar: fetchAvatar
|
||||||
|
)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AccountNameView: View {
|
||||||
|
let account: any AccountProtocol
|
||||||
|
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
displayNameLabel(account, .body, 16)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(verbatim: "@\(account.acct)")
|
||||||
|
.font(.body.weight(.light))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate modifier because we need to observe the Poll itself, not the draft
|
||||||
|
private struct PollAnimatingModifier: ViewModifier {
|
||||||
|
@OptionalObservedObject var poll: Poll?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.animation(.snappy, value: poll?.pollOptions.count)
|
||||||
|
}
|
||||||
|
}
|
163
Packages/ComposeUI/Sources/ComposeUI/Views/DraftsView.swift
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
//
|
||||||
|
// DraftsView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/27/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UserAccounts
|
||||||
|
|
||||||
|
struct DraftsView: View {
|
||||||
|
let currentDraft: Draft
|
||||||
|
@Binding var isShowingDrafts: Bool
|
||||||
|
let accountInfo: UserAccountInfo
|
||||||
|
let selectDraft: (Draft) -> Void
|
||||||
|
@State private var draftForDifferentReply: Draft? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
navigationView
|
||||||
|
.alertWithData("Different Reply", data: $draftForDifferentReply) { draft in
|
||||||
|
Button(role: .cancel) {
|
||||||
|
draftForDifferentReply = nil
|
||||||
|
} label: {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
selectDraft(draft)
|
||||||
|
} label: {
|
||||||
|
Text("Restore Draft")
|
||||||
|
}
|
||||||
|
} message: { _ in
|
||||||
|
Text("The selected draft is a reply to a different post, do you wish to use it?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var navigationView: some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
NavigationStack {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavigationView {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var navigationRoot: some View {
|
||||||
|
DraftsListView(currentDraft: currentDraft, isShowingDrafts: $isShowingDrafts, accountInfo: accountInfo, selectDraft: selectDraft)
|
||||||
|
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DraftsListView: View {
|
||||||
|
let currentDraft: Draft
|
||||||
|
@Binding var isShowingDrafts: Bool
|
||||||
|
let accountInfo: UserAccountInfo
|
||||||
|
let selectDraft: (Draft) -> Void
|
||||||
|
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
|
||||||
|
@Environment(\.composeUIConfig.userActivityForDraft) private var userActivityForDraft
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(drafts) { draft in
|
||||||
|
Button {
|
||||||
|
self.selectDraft(draft)
|
||||||
|
} label: {
|
||||||
|
DraftRowView(draft: draft)
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteDraft(draft)
|
||||||
|
} label: {
|
||||||
|
Label("Delete Draft", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDrag {
|
||||||
|
userActivityForDraft(draft) ?? NSItemProvider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indices in
|
||||||
|
indices.map { drafts[$0] }.forEach(deleteDraft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Drafts")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
cancelButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
drafts.nsPredicate = NSPredicate(
|
||||||
|
format: "accountID == %@ AND id != %@ AND lastModified != nil",
|
||||||
|
accountInfo.id,
|
||||||
|
currentDraft.id as NSUUID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cancelButton: some View {
|
||||||
|
Button {
|
||||||
|
isShowingDrafts = false
|
||||||
|
} label: {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteDraft(_ draft: Draft) {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DraftRowView: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
|
||||||
|
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())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if draft.contentWarningEnabled && !draft.contentWarning.isEmpty {
|
||||||
|
Text(draft.contentWarning)
|
||||||
|
.font(.body.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(draft.text)
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
if draft.pollEnabled {
|
||||||
|
Text("Poll")
|
||||||
|
.font(.body.bold())
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(draft.draftAttachments) { attachment in
|
||||||
|
AttachmentThumbnailView(attachment: attachment, thumbnailSize: CGSize(width: 50, height: 50))
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
|
.frame(height: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let lastModified = draft.lastModified {
|
||||||
|
Text(lastModified.formatted(.abbreviatedTimeAgo))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
140
Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
//
|
||||||
|
// EmojiTextField.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/5/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EmojiTextField: UIViewRepresentable {
|
||||||
|
typealias UIViewType = UITextField
|
||||||
|
|
||||||
|
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.composeInputBox) private var inputBox
|
||||||
|
|
||||||
|
@Binding var text: String
|
||||||
|
let placeholder: String
|
||||||
|
let maxLength: Int?
|
||||||
|
let focusNextView: (() -> Void)?
|
||||||
|
|
||||||
|
init(text: Binding<String>, placeholder: String, maxLength: Int?, focusNextView: (() -> Void)? = nil) {
|
||||||
|
self._text = text
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.maxLength = maxLength
|
||||||
|
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(fillColor) : .secondarySystemBackground
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
let coordinator = Coordinator(text: $text, focusNextView: focusNextView)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
inputBox.wrappedValue = coordinator
|
||||||
|
}
|
||||||
|
return coordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
||||||
|
var text: Binding<String>
|
||||||
|
var focusNextView: (() -> Void)?
|
||||||
|
var maxLength: Int?
|
||||||
|
|
||||||
|
@Published var autocompleteState: AutocompleteState?
|
||||||
|
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
|
||||||
|
|
||||||
|
weak var textField: UITextField?
|
||||||
|
|
||||||
|
init(text: Binding<String>, focusNextView: (() -> Void)?, maxLength: Int? = nil) {
|
||||||
|
self.text = text
|
||||||
|
self.focusNextView = focusNextView
|
||||||
|
self.maxLength = maxLength
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func didChange(_ textField: UITextField) {
|
||||||
|
text.wrappedValue = textField.text ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func returnKeyPressed() {
|
||||||
|
focusNextView?()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
221
Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
361
Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
//
|
||||||
|
// NewMainTextView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/11/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
struct NewMainTextView: View {
|
||||||
|
static var minHeight: CGFloat { 150 }
|
||||||
|
|
||||||
|
@Binding var value: String
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
||||||
|
@State private var becomeFirstResponder = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop)
|
||||||
|
.focused($focusedField, equals: .body)
|
||||||
|
.modifier(FocusedInputModifier())
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if value.isEmpty {
|
||||||
|
PlaceholderView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||||
|
@Binding var value: String
|
||||||
|
@Binding var becomeFirstResponder: Bool
|
||||||
|
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
||||||
|
@Environment(\.composeInputBox) private var inputBox
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||||
|
@PreferenceObserving(\.$useTwitterKeyboard) private var useTwitterKeyboard
|
||||||
|
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
|
||||||
|
@PreferenceObserving(\.$statusContentType) private var statusContentType
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> WrappedTextView {
|
||||||
|
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
|
||||||
|
let view = if #available(iOS 16.0, *) {
|
||||||
|
WrappedTextView(usingTextLayoutManager: true)
|
||||||
|
} else {
|
||||||
|
WrappedTextView()
|
||||||
|
}
|
||||||
|
view.addInteraction(UIDropInteraction(delegate: context.coordinator))
|
||||||
|
view.delegate = context.coordinator
|
||||||
|
view.adjustsFontForContentSizeCategory = true
|
||||||
|
view.textContainer.lineBreakMode = .byWordWrapping
|
||||||
|
view.isScrollEnabled = false
|
||||||
|
view.typingAttributes = [
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
||||||
|
]
|
||||||
|
view.backgroundColor = nil
|
||||||
|
|
||||||
|
// view.layer.cornerRadius = 5
|
||||||
|
// view.layer.cornerCurve = .continuous
|
||||||
|
// view.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
// view.layer.shadowOpacity = 0.15
|
||||||
|
// view.layer.shadowOffset = .zero
|
||||||
|
// view.layer.shadowRadius = 1
|
||||||
|
|
||||||
|
if textSelectionStartsAtBeginning {
|
||||||
|
// Update the text immediately so that the selection isn't invalidated by the text changing.
|
||||||
|
context.coordinator.updateTextViewTextIfNecessary(value, textView: view)
|
||||||
|
view.selectedTextRange = view.textRange(from: view.beginningOfDocument, to: view.beginningOfDocument)
|
||||||
|
}
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||||
|
context.coordinator.value = $value
|
||||||
|
context.coordinator.handleAttachmentDrop = handleAttachmentDrop
|
||||||
|
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
|
||||||
|
|
||||||
|
uiView.isEditable = isEnabled
|
||||||
|
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
|
||||||
|
|
||||||
|
uiView.contentType = statusContentType
|
||||||
|
// #if !os(visionOS)
|
||||||
|
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// Trying to set this with the @FocusState binding in onAppear results in the
|
||||||
|
// keyboard not appearing until after the sheet presentation animation completes :/
|
||||||
|
if becomeFirstResponder {
|
||||||
|
uiView.becomeFirstResponder()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
becomeFirstResponder = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> WrappedTextViewCoordinator {
|
||||||
|
let coordinator = WrappedTextViewCoordinator(value: $value, handleAttachmentDrop: handleAttachmentDrop)
|
||||||
|
// DispatchQueue.main.async {
|
||||||
|
// inputBox.wrappedValue = coordinator
|
||||||
|
// }
|
||||||
|
return coordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIViewType, context: Context) -> CGSize? {
|
||||||
|
let width = proposal.width ?? 10
|
||||||
|
let size = uiView.sizeThatFits(CGSize(width: width, height: 0))
|
||||||
|
return CGSize(width: width, height: max(NewMainTextView.minHeight, size.height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// laxer than the CharacterCounter regex, because we want to find mentions that are being typed but aren't yet complete (e.g., "@a@b")
|
||||||
|
private let mentionRegex = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+)?", options: .caseInsensitive)
|
||||||
|
|
||||||
|
private final class WrappedTextViewCoordinator: NSObject {
|
||||||
|
private static let attachment: NSTextAttachment = {
|
||||||
|
let font = UIFont.systemFont(ofSize: 20)
|
||||||
|
let size = /*1.4 * */font.capHeight
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
UIColor.systemRed.setFill()
|
||||||
|
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
|
||||||
|
}
|
||||||
|
let attachment = NSTextAttachment(image: image)
|
||||||
|
attachment.bounds = CGRect(x: 0, y: -1, width: size + 2, height: size)
|
||||||
|
attachment.lineLayoutPadding = 1
|
||||||
|
return attachment
|
||||||
|
}()
|
||||||
|
|
||||||
|
var value: Binding<String>
|
||||||
|
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
||||||
|
|
||||||
|
init(value: Binding<String>, handleAttachmentDrop: @escaping ([NSItemProvider]) -> Void) {
|
||||||
|
self.value = value
|
||||||
|
self.handleAttachmentDrop = handleAttachmentDrop
|
||||||
|
}
|
||||||
|
|
||||||
|
private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String {
|
||||||
|
attributedText.string.replacingOccurrences(of: "\u{FFFC}", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func attributedTextFromPlain(_ text: String) -> NSAttributedString {
|
||||||
|
let str = NSMutableAttributedString(string: text)
|
||||||
|
str.addAttributes([
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
||||||
|
], range: NSRange(location: 0, length: str.length))
|
||||||
|
if Preferences.shared.hasFeatureFlag(.composeTextAttributes) {
|
||||||
|
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
|
||||||
|
for match in mentionMatches.reversed() {
|
||||||
|
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
|
||||||
|
let range = NSRange(location: match.range.location, length: match.range.length + 1)
|
||||||
|
str.addAttributes([
|
||||||
|
.mention: true,
|
||||||
|
.foregroundColor: UIColor.tintColor,
|
||||||
|
], range: range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func updateTextViewTextIfNecessary(_ text: String, textView: UITextView) {
|
||||||
|
if text != plainTextFromAttributed(textView.attributedText) {
|
||||||
|
textView.attributedText = attributedTextFromPlain(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateAttributes(in textView: UITextView) {
|
||||||
|
guard Preferences.shared.hasFeatureFlag(.composeTextAttributes) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let str = NSMutableAttributedString(attributedString: textView.attributedText!)
|
||||||
|
var changed = false
|
||||||
|
var cursorOffset = 0
|
||||||
|
|
||||||
|
// remove existing mentions that aren't valid
|
||||||
|
str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in
|
||||||
|
var substr = (str.string as NSString).substring(with: range)
|
||||||
|
let hasTextAttachment = substr.unicodeScalars.first == UnicodeScalar(NSTextAttachment.character)
|
||||||
|
if hasTextAttachment {
|
||||||
|
substr = String(substr.dropFirst())
|
||||||
|
}
|
||||||
|
if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
|
||||||
|
changed = true
|
||||||
|
str.removeAttribute(.mention, range: range)
|
||||||
|
str.addAttribute(.foregroundColor, value: UIColor.label, range: range)
|
||||||
|
if hasTextAttachment {
|
||||||
|
str.deleteCharacters(in: NSRange(location: range.location, length: 1))
|
||||||
|
cursorOffset -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add mentions for those missing
|
||||||
|
let mentionMatches = mentionRegex.matches(in: str.string, range: NSRange(location: 0, length: str.length))
|
||||||
|
for match in mentionMatches.reversed() {
|
||||||
|
var attributeRange = NSRange()
|
||||||
|
let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange)
|
||||||
|
// the attribute range should always be one greater than the match range, to account for the text attachment
|
||||||
|
if attribute == nil || attributeRange.length <= match.range.length {
|
||||||
|
changed = true
|
||||||
|
let newAttributeRange: NSRange
|
||||||
|
if attribute == nil {
|
||||||
|
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
|
||||||
|
newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1)
|
||||||
|
cursorOffset += 1
|
||||||
|
} else {
|
||||||
|
newAttributeRange = match.range
|
||||||
|
}
|
||||||
|
str.addAttributes([
|
||||||
|
.mention: true,
|
||||||
|
.foregroundColor: UIColor.tintColor,
|
||||||
|
], range: newAttributeRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
let selection = textView.selectedRange
|
||||||
|
|
||||||
|
textView.attributedText = str
|
||||||
|
|
||||||
|
textView.selectedRange = NSRange(location: selection.location + cursorOffset, length: selection.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WrappedTextViewCoordinator: UITextViewDelegate {
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
if textView.text.isEmpty {
|
||||||
|
textView.typingAttributes = [
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
updateAttributes(in: textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
let plain = plainTextFromAttributed(textView.attributedText)
|
||||||
|
if plain != value.wrappedValue {
|
||||||
|
value.wrappedValue = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||||
|
guard let textView = textView as? WrappedTextView else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var actions = suggestedActions
|
||||||
|
if textView.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)) { _ in
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
})
|
||||||
|
actions[index] = newFormatMenu
|
||||||
|
} else {
|
||||||
|
actions.remove(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//extension WrappedTextViewCoordinator: ComposeInput {
|
||||||
|
//
|
||||||
|
//}
|
||||||
|
|
||||||
|
// Because of FB11790805, we can't handle drops for the entire screen.
|
||||||
|
// The onDrop modifier also doesn't work when applied to the NewMainTextView.
|
||||||
|
// So, manually add the UIInteraction to at least handle that.
|
||||||
|
extension WrappedTextViewCoordinator: UIDropInteractionDelegate {
|
||||||
|
func dropInteraction(_ interaction: UIDropInteraction, canHandle session: any UIDropSession) -> Bool {
|
||||||
|
session.canLoadObjects(ofClass: DraftAttachment.self)
|
||||||
|
}
|
||||||
|
func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession) -> UIDropProposal {
|
||||||
|
UIDropProposal(operation: .copy)
|
||||||
|
}
|
||||||
|
func dropInteraction(_ interaction: UIDropInteraction, performDrop session: any UIDropSession) {
|
||||||
|
handleAttachmentDrop(session.items.map(\.itemProvider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class WrappedTextView: UITextView {
|
||||||
|
var contentType: StatusContentType = .plain
|
||||||
|
|
||||||
|
private static var formattingActions: [Selector] {
|
||||||
|
[#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||||
|
if Self.formattingActions.contains(action) {
|
||||||
|
return contentType != .plain
|
||||||
|
}
|
||||||
|
return super.canPerformAction(action, withSender: sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func toggleBoldface(_ sender: Any?) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override func toggleItalics(_ sender: Any?) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override func validate(_ command: UICommand) {
|
||||||
|
super.validate(command)
|
||||||
|
|
||||||
|
if Self.formattingActions.contains(command.action),
|
||||||
|
contentType != .plain {
|
||||||
|
command.attributes.remove(.disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension NSAttributedString.Key {
|
||||||
|
static let mention = NSAttributedString.Key("Tusker.ComposeUI.mention")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlaceholderView: View {
|
||||||
|
@State private var placeholder: PlaceholderController.PlaceholderView = PlaceholderController.makePlaceholderView()
|
||||||
|
@ScaledMetric private var fontSize = 20
|
||||||
|
|
||||||
|
private var placeholderOffset: CGSize {
|
||||||
|
#if os(visionOS)
|
||||||
|
CGSize(width: 8, height: 8)
|
||||||
|
#else
|
||||||
|
CGSize(width: 4, height: 8)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
placeholder
|
||||||
|
.font(.system(size: fontSize))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.offset(placeholderOffset)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// BackgroundPlatterView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/29/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func composePlatterBackground() -> some View {
|
||||||
|
self.background {
|
||||||
|
PlatterBackgroundView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlatterBackgroundView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 5)
|
||||||
|
// TODO: fillColor is semi-transparent in pure-black dark mode, but it needs to be fully opaque for the poll transition to look right
|
||||||
|
// .fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground))
|
||||||
|
.fill(Color(uiColor: .secondarySystemBackground))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
182
Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
//
|
||||||
|
// PollEditor.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/29/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import InstanceFeatures
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
struct PollEditor: View {
|
||||||
|
@ObservedObject var poll: Poll
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Poll")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(poll.pollOptions) { option in
|
||||||
|
PollOptionEditor(poll: poll, option: option, focusedField: $focusedField)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
AddOptionButton(poll: poll, focusedField: $focusedField)
|
||||||
|
|
||||||
|
Toggle("Multiple choice", isOn: $poll.multiple)
|
||||||
|
.padding(.bottom, -8)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Duration")
|
||||||
|
Spacer()
|
||||||
|
PollDurationPicker(poll: poll)
|
||||||
|
.frame(minHeight: 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.all.subtracting(.bottom), 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.composePlatterBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AddOptionButton: View {
|
||||||
|
@ObservedObject var poll: Poll
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||||
|
|
||||||
|
private var canAddOption: Bool {
|
||||||
|
if let max = instanceFeatures.maxPollOptionsCount {
|
||||||
|
poll.options.count < max
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: addOption) {
|
||||||
|
Label("Add Option", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(!canAddOption)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addOption() {
|
||||||
|
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
|
||||||
|
option.poll = poll
|
||||||
|
poll.options.add(option)
|
||||||
|
focusedField = .pollOption(option.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PollDurationPicker: View {
|
||||||
|
@ObservedObject var poll: Poll
|
||||||
|
@State var duration: PollDuration
|
||||||
|
|
||||||
|
init(poll: Poll) {
|
||||||
|
self.poll = poll
|
||||||
|
self._duration = State(wrappedValue: .fromTimeInterval(poll.duration) ?? .oneDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var options: [MenuPicker<PollDuration>.Option] {
|
||||||
|
PollDuration.allCases.map {
|
||||||
|
.init(value: $0, title: PollDuration.formatter.string(from: $0.timeInterval)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MenuPicker(selection: $duration, options: options)
|
||||||
|
#if os(visionOS)
|
||||||
|
.onChange(of: duration) {
|
||||||
|
poll.duration = duration.timeInterval
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
.onChange(of: duration) { newValue in
|
||||||
|
poll.duration = newValue.timeInterval
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PollOptionEditor: View {
|
||||||
|
@ObservedObject var poll: Poll
|
||||||
|
@ObservedObject var option: PollOption
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||||
|
|
||||||
|
var placeholder: String {
|
||||||
|
let index = poll.options.index(of: option)
|
||||||
|
if index != NSNotFound {
|
||||||
|
return "Option \(index + 1)"
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Button(role: .destructive, action: removeOption) {
|
||||||
|
Label("Remove option", systemImage: "minus")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.buttonStyle(PollOptionButtonStyle())
|
||||||
|
.accessibilityLabel("Remove option")
|
||||||
|
.disabled(poll.options.count == 1)
|
||||||
|
|
||||||
|
EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars, focusNextView: self.focusNextOption)
|
||||||
|
.focused($focusedField, equals: .pollOption(option.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeOption() {
|
||||||
|
let index = poll.options.index(of: option)
|
||||||
|
if index != NSNotFound && poll.options.count > 1 {
|
||||||
|
var array = poll.options.array
|
||||||
|
array.remove(at: index)
|
||||||
|
poll.options = NSMutableOrderedSet(array: array)
|
||||||
|
// TODO: does this leave dangling PollOptions in the managed object context?
|
||||||
|
|
||||||
|
if case .pollOption(let id) = focusedField,
|
||||||
|
id == option.id {
|
||||||
|
let indexToFocus = index > 0 ? index - 1 : 0
|
||||||
|
focusedField = .pollOption(poll.pollOptions[indexToFocus].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func focusNextOption() {
|
||||||
|
let index = poll.options.index(of: option)
|
||||||
|
if index != NSNotFound && index + 1 < poll.options.count {
|
||||||
|
let nextOption = poll.options.object(at: index + 1) as! PollOption
|
||||||
|
focusedField = .pollOption(nextOption.objectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PollOptionButtonStyle: ButtonStyle {
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.font(.body.bold())
|
||||||
|
.padding(4)
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.background {
|
||||||
|
let color = if isEnabled {
|
||||||
|
if configuration.role == .destructive {
|
||||||
|
Color.red
|
||||||
|
} else {
|
||||||
|
Color.green
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Color.gray
|
||||||
|
}
|
||||||
|
let opacity = configuration.isPressed ? 0.8 : 1
|
||||||
|
Circle()
|
||||||
|
.fill(color.opacity(opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// PostProgressView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/10/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PostProgressView: View {
|
||||||
|
@ObservedObject var poster: PostService
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||||
|
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// PostProgressView()
|
||||||
|
//}
|
156
Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
//
|
||||||
|
// ReplyStatusView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
struct ReplyStatusView: View {
|
||||||
|
let status: any StatusProtocol
|
||||||
|
let rowTopInset: CGFloat
|
||||||
|
let globalFrameOutsideList: CGRect
|
||||||
|
|
||||||
|
@PreferenceObserving(\.$avatarStyle) private var avatarStyle
|
||||||
|
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel
|
||||||
|
@Environment(\.composeUIConfig.replyContentView) private var replyContentView
|
||||||
|
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
|
||||||
|
@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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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: avatarStyle == .circle ? .circle : .roundRect,
|
||||||
|
fetchAvatar: fetchAvatar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DisplayNameHeightPrefKey: SwiftUI.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewReplyStatusView: View {
|
||||||
|
let draft: Draft
|
||||||
|
let mastodonController: any ComposeMastodonContext
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let id = draft.inReplyToID,
|
||||||
|
let status = mastodonController.fetchStatus(id: id) {
|
||||||
|
ReplyStatusView(
|
||||||
|
status: status,
|
||||||
|
rowTopInset: 8,
|
||||||
|
globalFrameOutsideList: .zero
|
||||||
|
)
|
||||||
|
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
|
||||||
|
.id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +1,14 @@
|
|||||||
//
|
//
|
||||||
// CharacterCounterTests.swift
|
// CharacterCounterTests.swift
|
||||||
// PachydermTests
|
// ComposeUITests
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/29/18.
|
// Created by Shadowfacts on 9/29/18.
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import Pachyderm
|
@testable import ComposeUI
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
class CharacterCounterTests: XCTestCase {
|
class CharacterCounterTests: XCTestCase {
|
||||||
|
|
||||||
@ -17,31 +18,33 @@ class CharacterCounterTests: XCTestCase {
|
|||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let features = InstanceFeatures()
|
||||||
|
|
||||||
func testCountEmpty() {
|
func testCountEmpty() {
|
||||||
XCTAssertEqual(CharacterCounter.count(text: ""), 0)
|
XCTAssertEqual(CharacterCounter.count(text: "", for: features), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCountPlainText() {
|
func testCountPlainText() {
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message"), 26)
|
XCTAssertEqual(CharacterCounter.count(text: "This is an example message", for: features), 26)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄"), 43)
|
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄", for: features), 43)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄"), 7)
|
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄", for: features), 7)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCountLinks() {
|
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", for: features), 55)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com"), 57)
|
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com", for: features), 57)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com"), 32)
|
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"), 55)
|
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() {
|
func testCountLocalMentions() {
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example"), 14)
|
XCTAssertEqual(CharacterCounter.count(text: "hello @example", for: features), 14)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name"), 22)
|
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name", for: features), 22)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCountRemoteMentions() {
|
func testCountRemoteMentions() {
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social"), 14)
|
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"), 28)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
9
Packages/Duckable/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
34
Packages/Duckable/Package.swift
Normal file
@ -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(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "Duckable",
|
||||||
|
targets: ["Duckable"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
// Dependencies declare other packages that this package depends on.
|
||||||
|
// .package(url: /* package url */, from: "1.0.0"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
|
.target(
|
||||||
|
name: "Duckable",
|
||||||
|
dependencies: [],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
|
// .testTarget(
|
||||||
|
// name: "DuckableTests",
|
||||||
|
// dependencies: ["Duckable"]),
|
||||||
|
]
|
||||||
|
)
|