Compare commits
260 Commits
2024.1-115
...
develop
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 01cf597b5d | |
Shadowfacts | 12bab71b17 | |
Shadowfacts | f4b51c06c1 | |
Shadowfacts | c99c397cf6 | |
Shadowfacts | 814f64b3e2 | |
Shadowfacts | 3a3af77907 | |
Shadowfacts | 93e72e1cb6 | |
Shadowfacts | 522e7830e5 | |
Shadowfacts | 263210ac3c | |
Shadowfacts | 506d2ad8a9 | |
Shadowfacts | f9c0506590 | |
Shadowfacts | 3f4917931b | |
Shadowfacts | b7166771cf | |
Shadowfacts | 40230c5478 | |
Shadowfacts | 68bd9e0bed | |
Shadowfacts | 3e28c012d7 | |
Shadowfacts | 57c023c973 | |
Shadowfacts | cc696e58fc | |
Shadowfacts | 59af29ff64 | |
Shadowfacts | 59fb69525b | |
Shadowfacts | 1bd4d144a3 | |
Shadowfacts | b54d34ebfc | |
Shadowfacts | d1ffab3e42 | |
Shadowfacts | d873b157ee | |
Shadowfacts | d7be2048af | |
Shadowfacts | 3d1f506684 | |
Shadowfacts | cd8f0e7926 | |
Shadowfacts | 960ba84683 | |
Shadowfacts | 2eead1f9de | |
Shadowfacts | b663335c6d | |
Shadowfacts | 9ce6bd566f | |
Shadowfacts | 9547bd2913 | |
Shadowfacts | 9b2e6140a3 | |
Shadowfacts | 6de255681c | |
Shadowfacts | 805e5eddd0 | |
Shadowfacts | 4945a234e7 | |
Shadowfacts | 230696f456 | |
Shadowfacts | c113903980 | |
Shadowfacts | 0e95cd0adf | |
Shadowfacts | 494708a362 | |
Shadowfacts | 3a21983b98 | |
Shadowfacts | 1817247077 | |
Shadowfacts | 0d9eed73dd | |
Shadowfacts | 59d43fd3f6 | |
Shadowfacts | d321c31776 | |
Shadowfacts | ce10c7d6e2 | |
Shadowfacts | 37b9673b12 | |
Shadowfacts | 7c7af945e4 | |
Shadowfacts | cb32c66a59 | |
Shadowfacts | 4249ab30ca | |
Shadowfacts | 67e9c1245e | |
Shadowfacts | 3d9a1086b6 | |
Shadowfacts | fda0c18794 | |
Shadowfacts | dffa5d8f75 | |
Shadowfacts | 9891b601a8 | |
Shadowfacts | a8f6aa6ed7 | |
Shadowfacts | 348dcc558c | |
Shadowfacts | 703f6f695b | |
Shadowfacts | fdbfe49a7c | |
Shadowfacts | 3f0dd599b3 | |
Shadowfacts | 07b6bf33cb | |
Shadowfacts | d0758dc73c | |
Shadowfacts | b85c0eb95d | |
Shadowfacts | eea0ef258c | |
Shadowfacts | 18f6445a7c | |
Shadowfacts | c5f42719a0 | |
Shadowfacts | eb89aec00f | |
Shadowfacts | 61576bce58 | |
Shadowfacts | f7d4737782 | |
Shadowfacts | 3dd0f3a154 | |
Shadowfacts | 145ffbfcf0 | |
Shadowfacts | bcf2a2f026 | |
Shadowfacts | 1358152dec | |
Shadowfacts | 2e2279ba8c | |
Shadowfacts | 60dadf599c | |
Shadowfacts | 90537f9d12 | |
Shadowfacts | 8b0c2f80b6 | |
Shadowfacts | 42423f36db | |
Shadowfacts | 176eb7c011 | |
Shadowfacts | da9ca78a8b | |
Shadowfacts | b470ee6401 | |
Shadowfacts | fccd4e427c | |
Shadowfacts | f25031afd4 | |
Shadowfacts | ca65f84137 | |
Shadowfacts | d4057adf4d | |
Shadowfacts | 007937d2d7 | |
Shadowfacts | 5f040ed390 | |
Shadowfacts | 870d0c8404 | |
Shadowfacts | 47b9ac890a | |
Shadowfacts | 50b84350d9 | |
Shadowfacts | cdc64f1b2c | |
Shadowfacts | 2913098e74 | |
Shadowfacts | ce99352e90 | |
Shadowfacts | 8322d3a36c | |
Shadowfacts | a818457f8c | |
Shadowfacts | 1f6644b703 | |
Shadowfacts | 412c5ee91d | |
Shadowfacts | dcc5f7f716 | |
Shadowfacts | 9fefc9e8f8 | |
Shadowfacts | d1af911241 | |
Shadowfacts | 5abd265195 | |
Shadowfacts | 3cb0f46533 | |
Shadowfacts | c367a2e9f1 | |
Shadowfacts | 3eceffbb6b | |
Shadowfacts | 7c3a00a40d | |
Shadowfacts | 45a90fb4a2 | |
Shadowfacts | 8557e110a8 | |
Shadowfacts | c2232a5e14 | |
Shadowfacts | e6d9a33dbf | |
Shadowfacts | d8fccc8f1b | |
Shadowfacts | 6528070f1c | |
Shadowfacts | 09c6a87e19 | |
Shadowfacts | cd0d8fffcb | |
Shadowfacts | 1b6f0c07fd | |
Shadowfacts | 2f31b50a5b | |
Shadowfacts | cee4e15b06 | |
Shadowfacts | 888f44366c | |
Shadowfacts | c88076eec0 | |
Shadowfacts | afe47437e4 | |
Shadowfacts | 4dc484c3c2 | |
Shadowfacts | 0f2a85b108 | |
Shadowfacts | 5e55ce75c2 | |
Shadowfacts | eec2adbfd9 | |
Shadowfacts | a848f6e425 | |
Shadowfacts | 44896d305e | |
Shadowfacts | 6c70ed4b4e | |
Shadowfacts | e3c480131a | |
Shadowfacts | 575166f5b4 | |
Shadowfacts | c60aa3e3f3 | |
Shadowfacts | 75f0d12c82 | |
Shadowfacts | 5cf2bc4fbf | |
Shadowfacts | 908b499f8f | |
Shadowfacts | 67c7905acf | |
Shadowfacts | eacafe87b3 | |
Shadowfacts | 2a53b24487 | |
Shadowfacts | 9df3c33c6c | |
Shadowfacts | d4e82d6e7a | |
Shadowfacts | 06ba758309 | |
Shadowfacts | 2c56902389 | |
Shadowfacts | cb3fd43dbd | |
Shadowfacts | 3d15759fb9 | |
Shadowfacts | 5620b6ab78 | |
Shadowfacts | 09999175f7 | |
Shadowfacts | f2a9f890ff | |
Shadowfacts | 093994b474 | |
Shadowfacts | 3d0de5af04 | |
Shadowfacts | 966a906436 | |
Shadowfacts | 844d4056e3 | |
Shadowfacts | 00ef131bb6 | |
Shadowfacts | d6be6f14dc | |
Shadowfacts | 2ccf028bc2 | |
Shadowfacts | 3eeffada1f | |
Shadowfacts | 0499255be7 | |
Shadowfacts | f909c1da10 | |
Shadowfacts | 81543965ae | |
Shadowfacts | 96d42756d5 | |
Shadowfacts | f6e57d664f | |
Shadowfacts | c33be1cbf3 | |
Shadowfacts | 6d99156bd9 | |
Shadowfacts | ca764811ed | |
Shadowfacts | a589bb2863 | |
Shadowfacts | 6f35fd2676 | |
Shadowfacts | e83cef1c8c | |
Shadowfacts | b89df3f27b | |
Shadowfacts | 4ecc16a93b | |
Shadowfacts | 8960873ff3 | |
Shadowfacts | 043a708515 | |
Shadowfacts | c6b230414e | |
Shadowfacts | f5e9f66f76 | |
Shadowfacts | ee5f9a62ff | |
Shadowfacts | a92cf8c812 | |
Shadowfacts | 756874949a | |
Shadowfacts | 798e0c0cf1 | |
Shadowfacts | 3f370945e6 | |
Shadowfacts | a759731eba | |
Shadowfacts | 405d5def7c | |
Shadowfacts | 1f9806d02f | |
Shadowfacts | c43c951b92 | |
Shadowfacts | 00c44c612f | |
Shadowfacts | e5c4fceacd | |
Shadowfacts | 70227a7fa1 | |
Shadowfacts | cb5488dcaa | |
Shadowfacts | 910e18fb5e | |
Shadowfacts | 66af946766 | |
Shadowfacts | 6784ed7fdf | |
Shadowfacts | 66f0ba6891 | |
Shadowfacts | ee7bf5138c | |
Shadowfacts | c32181818a | |
Shadowfacts | 4665df228d | |
Shadowfacts | c7a56a9f61 | |
Shadowfacts | 39251b9aa2 | |
Shadowfacts | db534e5993 | |
Shadowfacts | e94bee4fc8 | |
Shadowfacts | 216e58e5ec | |
Shadowfacts | a4d13ad03b | |
Shadowfacts | 05cfecb797 | |
Shadowfacts | 132fcfa099 | |
Shadowfacts | 475b9911b1 | |
Shadowfacts | 7825ccbb3d | |
Shadowfacts | f87da10a29 | |
Shadowfacts | 1eec70449d | |
Shadowfacts | 19ca930ee8 | |
Shadowfacts | 2e31d34e9d | |
Shadowfacts | 8a339ec171 | |
Shadowfacts | c7d79422bd | |
Shadowfacts | baf96a8b06 | |
Shadowfacts | bc516a6326 | |
Shadowfacts | 1cd6af1236 | |
Shadowfacts | 9f6910ba73 | |
Shadowfacts | 9cf4975bfd | |
Shadowfacts | ee992bc0bf | |
Shadowfacts | ff8a83ca2d | |
Shadowfacts | 4c957b86ae | |
Shadowfacts | ff11835333 | |
Shadowfacts | 9353bbb56c | |
Shadowfacts | edc887dd4c | |
Shadowfacts | 68dad77f81 | |
Shadowfacts | 840b83012a | |
Shadowfacts | e150856e91 | |
Shadowfacts | 42a3f6c880 | |
Shadowfacts | 7a47b09b39 | |
Shadowfacts | 241e6f7e3a | |
Shadowfacts | f02afaac26 | |
Shadowfacts | bdd4a4d755 | |
Shadowfacts | 94c1eb2c81 | |
Shadowfacts | b03991ae1d | |
Shadowfacts | f98589b419 | |
Shadowfacts | 9fad2a882a | |
Shadowfacts | ec76754270 | |
Shadowfacts | d0bb197e8c | |
Shadowfacts | efd90bca3e | |
Shadowfacts | 3efa017942 | |
Shadowfacts | c5226f6374 | |
Shadowfacts | 281585cdf0 | |
Shadowfacts | 6d4ab4d54b | |
Shadowfacts | 9e429463b2 | |
Shadowfacts | 51db0066ac | |
Shadowfacts | 9763edef47 | |
Shadowfacts | 442f57bfc4 | |
Shadowfacts | ae7101bb30 | |
Shadowfacts | 490d48c635 | |
Shadowfacts | 69ee3bb4f0 | |
Shadowfacts | 46b455c3d1 | |
Shadowfacts | e522e30ce5 | |
Shadowfacts | c73784aa81 | |
Shadowfacts | 7affa09e5e | |
Shadowfacts | 7435d02f6e | |
Shadowfacts | 2467297f04 | |
Shadowfacts | cf317e15e9 | |
Shadowfacts | bcae60316b | |
Shadowfacts | 1a2fa10708 | |
Shadowfacts | f79c2feea6 | |
Shadowfacts | 7ec87d7853 | |
Shadowfacts | f5704e561b | |
Shadowfacts | d6faf3a37b | |
Shadowfacts | b0a6952643 | |
Shadowfacts | 06b58cfb9c | |
Shadowfacts | afcec24f86 | |
Shadowfacts | 3f90a0df04 | |
Shadowfacts | 395ce6523d |
|
@ -0,0 +1,157 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
sodipodi:docname="Tusker.svg"
|
||||||
|
inkscape:version="1.0beta2 (2b71d25, 2019-12-03)"
|
||||||
|
inkscape:export-ydpi="11.52"
|
||||||
|
inkscape:export-xdpi="11.52"
|
||||||
|
inkscape:export-filename="/Users/shadowfacts/Desktop/60x60@2x.png"
|
||||||
|
id="svg8"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 264.58333 264.58333"
|
||||||
|
height="1000"
|
||||||
|
width="1000">
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<inkscape:path-effect
|
||||||
|
bendpath1-nodetypes="cc"
|
||||||
|
bendpath4="M 27.271345,85.808468 V 178.94843"
|
||||||
|
bendpath3="M 27.271345,178.94843 H 242.39013"
|
||||||
|
bendpath2="M 242.39013,85.808468 V 178.94843"
|
||||||
|
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
|
||||||
|
xx="true"
|
||||||
|
yy="true"
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect1345"
|
||||||
|
effect="envelope" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
allow_transforms="true"
|
||||||
|
css_properties=""
|
||||||
|
attributes=""
|
||||||
|
method="d"
|
||||||
|
linkeditem=""
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect38"
|
||||||
|
effect="clone_original" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
scale_y_rel="false"
|
||||||
|
prop_scale="1"
|
||||||
|
strokepath="M0,0 L1,0"
|
||||||
|
endpoint_spacing_variation="0;1"
|
||||||
|
endpoint_edge_variation="0;1"
|
||||||
|
startpoint_spacing_variation="0;1"
|
||||||
|
startpoint_edge_variation="0;1"
|
||||||
|
count="5"
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect32"
|
||||||
|
effect="curvestitching" />
|
||||||
|
<filter
|
||||||
|
height="1.3500000000000001"
|
||||||
|
width="1.2"
|
||||||
|
id="filter1277"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
style="color-interpolation-filters:sRGB;">
|
||||||
|
<feFlood
|
||||||
|
id="feFlood1267"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
flood-opacity="0.321569" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1269"
|
||||||
|
result="composite1"
|
||||||
|
operator="in"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="flood" />
|
||||||
|
<feGaussianBlur
|
||||||
|
id="feGaussianBlur1271"
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="5"
|
||||||
|
in="composite1" />
|
||||||
|
<feOffset
|
||||||
|
id="feOffset1273"
|
||||||
|
result="offset"
|
||||||
|
dy="5"
|
||||||
|
dx="-2.5" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1275"
|
||||||
|
result="composite2"
|
||||||
|
operator="over"
|
||||||
|
in2="offset"
|
||||||
|
in="SourceGraphic" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:window-y="23"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-height="1395"
|
||||||
|
inkscape:window-width="1902"
|
||||||
|
units="px"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:document-rotation="0"
|
||||||
|
inkscape:current-layer="layer2"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:cy="496.39379"
|
||||||
|
inkscape:cx="442.66632"
|
||||||
|
inkscape:zoom="1.4142136"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
borderopacity="1.0"
|
||||||
|
bordercolor="#666666"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
id="base" />
|
||||||
|
<metadata
|
||||||
|
id="metadata5">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 2"
|
||||||
|
id="layer2"
|
||||||
|
inkscape:groupmode="layer">
|
||||||
|
<rect
|
||||||
|
y="-0.14500916"
|
||||||
|
x="-0.14500916"
|
||||||
|
height="264.87335"
|
||||||
|
width="264.87335"
|
||||||
|
id="rect865"
|
||||||
|
style="fill:#75e04e;fill-opacity:1;stroke:#75e04e;stroke-width:0.239149;stroke-opacity:1" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="display:none"
|
||||||
|
id="layer1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
inkscape:label="Layer 1">
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
|
||||||
|
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||||
|
id="path28" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1 copy"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="g1343">
|
||||||
|
<path
|
||||||
|
id="path1341"
|
||||||
|
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.56578600000000001px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 8.1 KiB |
|
@ -0,0 +1,153 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
sodipodi:docname="Tusker transparent.svg"
|
||||||
|
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||||
|
inkscape:export-ydpi="98.304001"
|
||||||
|
inkscape:export-xdpi="98.304001"
|
||||||
|
inkscape:export-filename="../Desktop/1024x1024-dark@1x.png"
|
||||||
|
id="svg8"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 264.58333 264.58333"
|
||||||
|
height="1000"
|
||||||
|
width="1000"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<inkscape:path-effect
|
||||||
|
bendpath1-nodetypes="cc"
|
||||||
|
bendpath4="M 27.271345,85.808468 V 178.94843"
|
||||||
|
bendpath3="M 27.271345,178.94843 H 242.39013"
|
||||||
|
bendpath2="M 242.39013,85.808468 V 178.94843"
|
||||||
|
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
|
||||||
|
xx="true"
|
||||||
|
yy="true"
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect1345"
|
||||||
|
effect="envelope" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
allow_transforms="true"
|
||||||
|
css_properties=""
|
||||||
|
attributes=""
|
||||||
|
method="d"
|
||||||
|
linkeditem=""
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect38"
|
||||||
|
effect="clone_original" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
scale_y_rel="false"
|
||||||
|
prop_scale="1"
|
||||||
|
strokepath="M0,0 L1,0"
|
||||||
|
endpoint_spacing_variation="0;1"
|
||||||
|
endpoint_edge_variation="0;1"
|
||||||
|
startpoint_spacing_variation="0;1"
|
||||||
|
startpoint_edge_variation="0;1"
|
||||||
|
count="5"
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect32"
|
||||||
|
effect="curvestitching" />
|
||||||
|
<filter
|
||||||
|
height="1.317445"
|
||||||
|
width="1.1258237"
|
||||||
|
id="filter1277"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
x="-0.068723437"
|
||||||
|
y="-0.1318855">
|
||||||
|
<feFlood
|
||||||
|
id="feFlood1267"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
flood-opacity="0.321569" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1269"
|
||||||
|
result="composite1"
|
||||||
|
operator="in"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="flood" />
|
||||||
|
<feGaussianBlur
|
||||||
|
id="feGaussianBlur1271"
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="5"
|
||||||
|
in="composite1" />
|
||||||
|
<feOffset
|
||||||
|
id="feOffset1273"
|
||||||
|
result="offset"
|
||||||
|
dy="5"
|
||||||
|
dx="-2.5" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1275"
|
||||||
|
result="composite2"
|
||||||
|
operator="over"
|
||||||
|
in2="offset"
|
||||||
|
in="SourceGraphic" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-x="1280"
|
||||||
|
inkscape:window-height="1387"
|
||||||
|
inkscape:window-width="1280"
|
||||||
|
units="px"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:document-rotation="0"
|
||||||
|
inkscape:current-layer="layer2"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:cy="404.46507"
|
||||||
|
inkscape:cx="442.29528"
|
||||||
|
inkscape:zoom="1.4142136"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
borderopacity="1.0"
|
||||||
|
bordercolor="#666666"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
id="base"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1" />
|
||||||
|
<metadata
|
||||||
|
id="metadata5">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 2"
|
||||||
|
id="layer2"
|
||||||
|
inkscape:groupmode="layer" />
|
||||||
|
<g
|
||||||
|
style="display:none"
|
||||||
|
id="layer1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
inkscape:label="Layer 1">
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
|
||||||
|
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||||
|
id="path28" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1 copy"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="g1343">
|
||||||
|
<path
|
||||||
|
id="path1341"
|
||||||
|
style="fill:#75e04e;fill-opacity:1;stroke:#74e04d;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 7.9 KiB |
|
@ -0,0 +1,162 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
sodipodi:docname="Tusker.svg"
|
||||||
|
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||||
|
inkscape:export-ydpi="98.304001"
|
||||||
|
inkscape:export-xdpi="98.304001"
|
||||||
|
inkscape:export-filename="../Desktop/1024x1024@1x.png"
|
||||||
|
id="svg8"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 264.58333 264.58333"
|
||||||
|
height="1000"
|
||||||
|
width="1000"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<inkscape:path-effect
|
||||||
|
bendpath1-nodetypes="cc"
|
||||||
|
bendpath4="M 27.271345,85.808468 V 178.94843"
|
||||||
|
bendpath3="M 27.271345,178.94843 H 242.39013"
|
||||||
|
bendpath2="M 242.39013,85.808468 V 178.94843"
|
||||||
|
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
|
||||||
|
xx="true"
|
||||||
|
yy="true"
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect1345"
|
||||||
|
effect="envelope" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
allow_transforms="true"
|
||||||
|
css_properties=""
|
||||||
|
attributes=""
|
||||||
|
method="d"
|
||||||
|
linkeditem=""
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect38"
|
||||||
|
effect="clone_original" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
scale_y_rel="false"
|
||||||
|
prop_scale="1"
|
||||||
|
strokepath="M0,0 L1,0"
|
||||||
|
endpoint_spacing_variation="0;1"
|
||||||
|
endpoint_edge_variation="0;1"
|
||||||
|
startpoint_spacing_variation="0;1"
|
||||||
|
startpoint_edge_variation="0;1"
|
||||||
|
count="5"
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect32"
|
||||||
|
effect="curvestitching" />
|
||||||
|
<filter
|
||||||
|
height="1.317445"
|
||||||
|
width="1.1258237"
|
||||||
|
id="filter1277"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
x="-0.068723437"
|
||||||
|
y="-0.1318855">
|
||||||
|
<feFlood
|
||||||
|
id="feFlood1267"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
flood-opacity="0.321569" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1269"
|
||||||
|
result="composite1"
|
||||||
|
operator="in"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="flood" />
|
||||||
|
<feGaussianBlur
|
||||||
|
id="feGaussianBlur1271"
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="5"
|
||||||
|
in="composite1" />
|
||||||
|
<feOffset
|
||||||
|
id="feOffset1273"
|
||||||
|
result="offset"
|
||||||
|
dy="5"
|
||||||
|
dx="-2.5" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1275"
|
||||||
|
result="composite2"
|
||||||
|
operator="over"
|
||||||
|
in2="offset"
|
||||||
|
in="SourceGraphic" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-x="1280"
|
||||||
|
inkscape:window-height="1387"
|
||||||
|
inkscape:window-width="1280"
|
||||||
|
units="px"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:document-rotation="0"
|
||||||
|
inkscape:current-layer="layer2"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:cy="496.38895"
|
||||||
|
inkscape:cx="442.29528"
|
||||||
|
inkscape:zoom="1.4142136"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
borderopacity="1.0"
|
||||||
|
bordercolor="#666666"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
id="base"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1" />
|
||||||
|
<metadata
|
||||||
|
id="metadata5">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 2"
|
||||||
|
id="layer2"
|
||||||
|
inkscape:groupmode="layer">
|
||||||
|
<rect
|
||||||
|
y="-0.14500916"
|
||||||
|
x="-0.14500916"
|
||||||
|
height="264.87335"
|
||||||
|
width="264.87335"
|
||||||
|
id="rect865"
|
||||||
|
style="fill:#75e04e;fill-opacity:1;stroke:#75e04e;stroke-width:0.239149;stroke-opacity:1" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="display:none"
|
||||||
|
id="layer1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
inkscape:label="Layer 1">
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
|
||||||
|
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||||
|
id="path28" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1 copy"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="g1343">
|
||||||
|
<path
|
||||||
|
id="path1341"
|
||||||
|
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.56578600000000001px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||||
|
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 8.2 KiB |
|
@ -1,3 +1,100 @@
|
||||||
|
## 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
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|
176
CHANGELOG.md
176
CHANGELOG.md
|
@ -1,5 +1,181 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2024.4 (136)
|
||||||
|
Features/Improvements:
|
||||||
|
- Import image description when adding attachments from Photos if possible
|
||||||
|
- Reorganize toolbar buttons when adding saved hashtag
|
||||||
|
- Show errors when loading video in attachment gallery fails
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when viewing profiles in certain circumstances
|
||||||
|
- Fix profile tab switching animation getting stuck
|
||||||
|
- Fix video controls in attachment gallery not auto-hiding
|
||||||
|
- Pleroma: Fix error when loading polls in some circumstances
|
||||||
|
- iPadOS 18: Fix incorrect two-column layout when closing sidebar
|
||||||
|
- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
|
||||||
|
- macOS: Fix reselecting current item not navigating back
|
||||||
|
|
||||||
|
## 2024.4 (135)
|
||||||
|
Features/Improvements:
|
||||||
|
- iOS 18: New floating sidebar/tab bar
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when hashtag search results include duplicates
|
||||||
|
- Fix "no content" text not being removed from list timeline after refreshing
|
||||||
|
|
||||||
|
## 2024.3 (133)
|
||||||
|
- Add additional info to Tip Jar
|
||||||
|
|
||||||
|
## 2024.3 (132)
|
||||||
|
- Add ToS nag before signing in
|
||||||
|
|
||||||
|
## 2024.3 (131)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix Cmd+3 not correctly switching to Explore tab
|
||||||
|
|
||||||
|
## 2024.3 (130)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
|
||||||
|
- Fix crash when dragging between buttons in reblog confirmation alert
|
||||||
|
- Fix potential crash when displaying search results
|
||||||
|
- Mac: Fix Post button not displaying on Compose screen
|
||||||
|
|
||||||
|
## 2024.3 (129)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix excessive network traffic on profile pages
|
||||||
|
- Fix attachment gallery controls visibility not being synced between pages
|
||||||
|
- Fix video attachments not restarting when play pressed while at ends
|
||||||
|
- Fix profile field text being misaligned
|
||||||
|
- Fix at sign in timeline statuses usernames sometimes clipping
|
||||||
|
- Fix add hashtag/instance to Pinned Timelines sheets dismissing immediately when opened
|
||||||
|
- Fix for display name being replaced with incorrect user in certain circumstances
|
||||||
|
- Fix profile moved overlay view appearing behind avatar/header
|
||||||
|
- Fix profile moved view accessibility with VoiceOver
|
||||||
|
- Fix mention/status push notifications not showing content warning
|
||||||
|
- Fix sensitive attachment thumbnails being shown in push notifications
|
||||||
|
- Fix Dynamic Type not applying to status content
|
||||||
|
- Fix expand all option in Conversation not transferring when opening ancestors
|
||||||
|
- Fix not being able to resolve remote Mastodon status links in Conversation screen
|
||||||
|
- Fix status indicator icons overlapping thread links when Dynamic Type is enabled
|
||||||
|
|
||||||
|
## 2024.3 (128)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix selecting poll option playing too much haptic feedback
|
||||||
|
- Fix crash when displaying HTML in certain posts
|
||||||
|
- Fix gifv playback pausing audio from other apps
|
||||||
|
- Fix gifv playback not resuming after returning from background
|
||||||
|
- Fix attachment badges not appearing on gifvs
|
||||||
|
- iPadOS: Fix poll options not having pointer hover effects
|
||||||
|
- iPadOS: Fix haptic feedback not working on new Magic Keyboard
|
||||||
|
- iPadOS: Fix scrubbing video with pointer not letting you click to select position
|
||||||
|
- iPadOS: Fix multi-column navigation not animating when replacing multiple columns
|
||||||
|
|
||||||
|
## 2024.3 (127)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen
|
||||||
|
- Fix profile header images being blurry
|
||||||
|
- Fix dismissing gallery when presented from sheet
|
||||||
|
- Fix potential crash in multi-column interface
|
||||||
|
- Fix crash when opening push notification while sheet presented
|
||||||
|
- Fix being able to block your own domain
|
||||||
|
- Fix links in profile fields with other text not being interactable
|
||||||
|
- Fix excessive CPU use immediately after app launch
|
||||||
|
- Fix timeline failing to load when one status is malformed
|
||||||
|
- iPadOS: Fix pointer interactions on conversation main status action buttons
|
||||||
|
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
||||||
|
- iPadOS: Fix Cmd+1/etc. resetting navigation state when returning to previous column
|
||||||
|
- iPadOS: Fix previous sidebar selection losing navigation state in some circumstances
|
||||||
|
- iPadOS: Fix profile followers/following buttons not having pointer effect
|
||||||
|
- iPadOS: Fix search token suggestions not having pointer effect
|
||||||
|
- iPadOS: Fix conversation thread links appearing above avatar during pointer effect
|
||||||
|
- iPadOS: Fix multi-column interface not animating scroll when replacing subsequent columns
|
||||||
|
- iPadOS: Fix not being able to select text on conversation main status by double-clicking with cursor
|
||||||
|
- iPadOS: Fix selecting search result always pushing new column rather than replacing
|
||||||
|
- Pixelfed/Firefish: Fix error loading accounts in some circumstances
|
||||||
|
- Pixelfed: Fix loading relationships and follow/block/etc. actions not working
|
||||||
|
|
||||||
|
## 2024.3 (126)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix an issue displaying post HTML in certain edge cases
|
||||||
|
- Fix crash when video attachment playback ends
|
||||||
|
- Fix excessive CPU usage when scrubbing video attachment
|
||||||
|
- Fix video attachment thubmnails being flipped on Compose screen
|
||||||
|
- Pleroma: Fix editing attachment descriptions not working
|
||||||
|
|
||||||
|
## 2024.2 (124)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add subscription option to Tip Jar
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix attachment captions not displaying while loading in gallery
|
||||||
|
- Fix tapping follow request push notification not working
|
||||||
|
- Pleroma: Handle posts with missing creation dates
|
||||||
|
|
||||||
|
## 2024.2 (122)
|
||||||
|
Features/Improvements:
|
||||||
|
- Show instance announcements in Notifications
|
||||||
|
- Pleroma/Akkoma: Display emoji reactions in Notifications
|
||||||
|
- Pleroma/Akkoma: Add push notifications for emoji reactions
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix issue fetching server info on some instances
|
||||||
|
- Fix Preferences background color not updating after changing Pure Black Dark Mode
|
||||||
|
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
|
||||||
|
|
||||||
|
## 2024.2 (121)
|
||||||
|
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- iPadOS: Enable multi-column navigation
|
||||||
|
- Add post preview to Appearance preferences
|
||||||
|
- Consolidate Media preferences section with Appearance
|
||||||
|
- Add icons to Preferences sections
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix push notifications not working on Pleroma/Akkoma and older Mastodon versions
|
||||||
|
- Fix push notifications not working with certain accounts
|
||||||
|
- Fix links on About screen not being aligned
|
||||||
|
- macOS: Remove non-functional in-app Safari preferences
|
||||||
|
|
||||||
|
## 2024.2 (120)
|
||||||
|
This build adds push notifications, which can be enabled in Preferences -> Notifications.
|
||||||
|
|
||||||
|
## 2024.1 (119)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add Account Settings button to Preferences
|
||||||
|
|
||||||
|
## 2024.1 (118)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix music not pausing/resuming when video playback starts
|
||||||
|
|
||||||
|
## 2024.1 (117)
|
||||||
|
Features/Improvements:
|
||||||
|
- Add See Results button to polls
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix race condition when presenting gallery for 4th of more than 4 attachments
|
||||||
|
- Fix gallery interactive dismissal not working for 4th or later attachments on posts with more than 4 attachments
|
||||||
|
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
|
||||||
|
- macOS: Fix gallery being positioned incorrectly when Reduce Motion is on
|
||||||
|
|
||||||
|
## 2024.1 (116)
|
||||||
|
Features/Improvements:
|
||||||
|
- Display message on empty list timelines
|
||||||
|
- Add preference to display badge for attachments that lack alt text
|
||||||
|
- Mark notifications as read on the Mastodon web frontend once displayed
|
||||||
|
- iPadOS: Support tapping the selected sidebar item to scroll to top
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix playing back GIFVs preventing the device sleeping
|
||||||
|
- Fix incorrect cell separator insets followers/following lists
|
||||||
|
- Fix memory leak in attachments gallery
|
||||||
|
- Fix notifications tab not scrolling to top when tab bar item tapped
|
||||||
|
- Fix Trending Hashtags screen not clearing selection
|
||||||
|
- Fix fast account switcher overlapping sensor housing on landscape iPhones
|
||||||
|
- Fix Edit List screen not updating when accounts are added/removed
|
||||||
|
- Fix changing List reply policy not refreshing list timeline
|
||||||
|
- macOS: Fix certain gallery attachments being incorrectly sized/positioned
|
||||||
|
|
||||||
## 2024.1 (115)
|
## 2024.1 (115)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Rewrite attachment gallery
|
- Rewrite attachment gallery
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>TuskerInfo</key>
|
||||||
|
<dict>
|
||||||
|
<key>PushProxyHost</key>
|
||||||
|
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
|
||||||
|
<key>PushProxyScheme</key>
|
||||||
|
<string>$(TUSKER_PUSH_PROXY_SCHEME)</string>
|
||||||
|
<key>SentryDSN</key>
|
||||||
|
<string>$(SENTRY_DSN)</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.usernotifications.service</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.$(BUNDLE_ID_PREFIX).Tusker</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,397 @@
|
||||||
|
//
|
||||||
|
// NotificationService.swift
|
||||||
|
// NotificationExtension
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/9/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UserNotifications
|
||||||
|
import UserAccounts
|
||||||
|
import PushNotifications
|
||||||
|
import CryptoKit
|
||||||
|
import OSLog
|
||||||
|
import Pachyderm
|
||||||
|
import Intents
|
||||||
|
import HTMLStreamer
|
||||||
|
import WebURL
|
||||||
|
import UIKit
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
|
||||||
|
|
||||||
|
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||||
|
|
||||||
|
class NotificationService: UNNotificationServiceExtension {
|
||||||
|
|
||||||
|
private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self)
|
||||||
|
|
||||||
|
private var pendingRequest: (UNMutableNotificationContent, (UNNotificationContent) -> Void, Task<Void, Never>)?
|
||||||
|
|
||||||
|
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||||
|
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||||
|
logger.error("Couldn't get mutable content")
|
||||||
|
contentHandler(request.content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard request.content.userInfo["v"] as? Int == 1,
|
||||||
|
let accountID = (request.content.userInfo["ctx"] as? String).flatMap(\.removingPercentEncoding),
|
||||||
|
let account = UserAccountsManager.shared.getAccount(id: accountID),
|
||||||
|
let subscription = getSubscription(account: account),
|
||||||
|
let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }),
|
||||||
|
let salt = (request.content.userInfo["salt"] as? String).flatMap(decodeBase64URL(_:)),
|
||||||
|
let serverPublicKeyData = (request.content.userInfo["pk"] as? String).flatMap(decodeBase64URL(_:)) else {
|
||||||
|
logger.error("Missing info from push notification")
|
||||||
|
contentHandler(request.content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let body = decryptNotification(subscription: subscription, serverPublicKeyData: serverPublicKeyData, salt: salt, encryptedBody: encryptedBody) else {
|
||||||
|
contentHandler(request.content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let withoutPadding = body.dropFirst(2)
|
||||||
|
|
||||||
|
let notification: PushNotification
|
||||||
|
do {
|
||||||
|
notification = try JSONDecoder().decode(PushNotification.self, from: withoutPadding)
|
||||||
|
} catch {
|
||||||
|
logger.error("Unable to decode push payload: \(String(describing: error))")
|
||||||
|
contentHandler(request.content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableContent.title = notification.title
|
||||||
|
mutableContent.body = notification.body
|
||||||
|
mutableContent.userInfo["notificationID"] = notification.notificationID
|
||||||
|
mutableContent.userInfo["accountID"] = accountID
|
||||||
|
mutableContent.targetContentIdentifier = accountID
|
||||||
|
|
||||||
|
let task = Task {
|
||||||
|
await updateNotificationContent(mutableContent, account: account, push: notification)
|
||||||
|
if !Task.isCancelled {
|
||||||
|
contentHandler(pendingRequest?.0 ?? mutableContent)
|
||||||
|
pendingRequest = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingRequest = (mutableContent, contentHandler, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func serviceExtensionTimeWillExpire() {
|
||||||
|
if let pendingRequest {
|
||||||
|
logger.debug("Expiring with pending request")
|
||||||
|
pendingRequest.2.cancel()
|
||||||
|
pendingRequest.1(pendingRequest.0)
|
||||||
|
self.pendingRequest = nil
|
||||||
|
} else {
|
||||||
|
logger.debug("Expiring without pending request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNotificationContent(_ content: UNMutableNotificationContent, account: UserAccountInfo, push: PushNotification) async {
|
||||||
|
let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken)
|
||||||
|
let notification: Pachyderm.Notification
|
||||||
|
do {
|
||||||
|
notification = try await client.run(Client.getNotification(id: push.notificationID)).0
|
||||||
|
} catch {
|
||||||
|
logger.error("Error fetching notification: \(String(describing: error))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let kindStr: String?
|
||||||
|
switch notification.kind {
|
||||||
|
case .reblog:
|
||||||
|
kindStr = "🔁 Reblogged"
|
||||||
|
case .favourite:
|
||||||
|
kindStr = "⭐️ Favorited"
|
||||||
|
case .follow:
|
||||||
|
kindStr = "👤 Followed by @\(notification.account.acct)"
|
||||||
|
case .followRequest:
|
||||||
|
kindStr = "👤 Asked to follow by @\(notification.account.acct)"
|
||||||
|
case .poll:
|
||||||
|
kindStr = "📊 Poll finished"
|
||||||
|
case .update:
|
||||||
|
kindStr = "✏️ Edited"
|
||||||
|
case .emojiReaction:
|
||||||
|
if let emoji = notification.emoji {
|
||||||
|
kindStr = "\(emoji) Reacted"
|
||||||
|
} else {
|
||||||
|
kindStr = nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
kindStr = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationContent: String?
|
||||||
|
if let status = notification.status {
|
||||||
|
if notification.kind == .mention || notification.kind == .status,
|
||||||
|
!status.spoilerText.isEmpty {
|
||||||
|
notificationContent = "⚠️ \(status.spoilerText)"
|
||||||
|
} else {
|
||||||
|
notificationContent = NotificationService.textConverter.convert(html: status.content)
|
||||||
|
}
|
||||||
|
} else if notification.kind == .follow || notification.kind == .followRequest {
|
||||||
|
notificationContent = nil
|
||||||
|
} else {
|
||||||
|
notificationContent = push.body
|
||||||
|
}
|
||||||
|
|
||||||
|
content.body = [kindStr, notificationContent].compactMap { $0 }.joined(separator: "\n")
|
||||||
|
|
||||||
|
let attachmentDataTask: Task<URL?, Never>?
|
||||||
|
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
|
||||||
|
// because we risk just fetching the same thing a bunch of times for many senders.
|
||||||
|
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
|
||||||
|
let status = notification.status,
|
||||||
|
!status.sensitive,
|
||||||
|
let attachment = status.attachments.first {
|
||||||
|
let url = attachment.previewURL ?? attachment.url
|
||||||
|
attachmentDataTask = Task {
|
||||||
|
do {
|
||||||
|
let data = try await URLSession.shared.data(from: url).0
|
||||||
|
let localAttachmentURL = FileManager.default.temporaryDirectory.appendingPathComponent("attachment_\(attachment.id)").appendingPathExtension(url.pathExtension)
|
||||||
|
try data.write(to: localAttachmentURL)
|
||||||
|
return localAttachmentURL
|
||||||
|
} catch {
|
||||||
|
logger.error("Error setting notification attachments: \(String(describing: error))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attachmentDataTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let conversationIdentifier: String?
|
||||||
|
if let status = notification.status {
|
||||||
|
if let context = status.pleromaExtras?.context {
|
||||||
|
conversationIdentifier = "context:\(context)"
|
||||||
|
} else if [Notification.Kind.reblog, .favourite, .poll, .update].contains(notification.kind) {
|
||||||
|
conversationIdentifier = "status:\(status.id)"
|
||||||
|
} else {
|
||||||
|
conversationIdentifier = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conversationIdentifier = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let account: Account?
|
||||||
|
switch notification.kind {
|
||||||
|
case .mention, .status:
|
||||||
|
account = notification.status?.account
|
||||||
|
default:
|
||||||
|
account = notification.account
|
||||||
|
}
|
||||||
|
let sender: INPerson?
|
||||||
|
if let account {
|
||||||
|
let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown)
|
||||||
|
let image: INImage?
|
||||||
|
if let avatar = account.avatar,
|
||||||
|
let (data, resp) = try? await URLSession.shared.data(from: avatar),
|
||||||
|
let code = (resp as? HTTPURLResponse)?.statusCode,
|
||||||
|
(200...299).contains(code) {
|
||||||
|
image = INImage(imageData: data)
|
||||||
|
} else {
|
||||||
|
image = nil
|
||||||
|
}
|
||||||
|
sender = INPerson(
|
||||||
|
personHandle: handle,
|
||||||
|
nameComponents: nil,
|
||||||
|
displayName: account.displayName,
|
||||||
|
image: image,
|
||||||
|
contactIdentifier: nil,
|
||||||
|
customIdentifier: account.id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sender = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let intent = INSendMessageIntent(
|
||||||
|
recipients: nil,
|
||||||
|
outgoingMessageType: .outgoingMessageText,
|
||||||
|
content: notificationContent,
|
||||||
|
speakableGroupName: nil,
|
||||||
|
conversationIdentifier: conversationIdentifier,
|
||||||
|
serviceName: nil,
|
||||||
|
sender: sender,
|
||||||
|
attachments: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let interaction = INInteraction(intent: intent, response: nil)
|
||||||
|
interaction.direction = .incoming
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await interaction.donate()
|
||||||
|
} catch {
|
||||||
|
logger.error("Error donating interaction: \(String(describing: error))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedContent: UNMutableNotificationContent
|
||||||
|
|
||||||
|
let contentProviding: any UNNotificationContentProviding
|
||||||
|
if #available(iOS 18.0, visionOS 2.0, *),
|
||||||
|
await Preferences.shared.hasFeatureFlag(.pushNotifCustomEmoji) {
|
||||||
|
let attributedString = NSMutableAttributedString(string: content.body)
|
||||||
|
|
||||||
|
for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() {
|
||||||
|
let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
|
||||||
|
guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }),
|
||||||
|
let url = URL(emoji.url),
|
||||||
|
let (data, _) = try? await URLSession.shared.data(from: url),
|
||||||
|
let image = UIImage(data: data) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let attachment = NSTextAttachment(image: image)
|
||||||
|
let attachmentStr = NSAttributedString(attachment: attachment)
|
||||||
|
attributedString.replaceCharacters(in: match.range, with: attachmentStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributedCtx = UNNotificationAttributedMessageContext(sendMessageIntent: intent, attributedContent: attributedString)
|
||||||
|
contentProviding = attributedCtx
|
||||||
|
} else {
|
||||||
|
contentProviding = intent
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let newContent = try content.updating(from: contentProviding)
|
||||||
|
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
|
||||||
|
pendingRequest?.0 = newMutableContent
|
||||||
|
updatedContent = newMutableContent
|
||||||
|
} else {
|
||||||
|
updatedContent = content
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Error updating notification from intent: \(String(describing: error))")
|
||||||
|
updatedContent = content
|
||||||
|
}
|
||||||
|
|
||||||
|
if let localAttachmentURL = await attachmentDataTask?.value,
|
||||||
|
let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) {
|
||||||
|
updatedContent.attachments = [
|
||||||
|
attachment
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
// this is necessary because of a swift bug: https://github.com/apple/swift/pull/72507
|
||||||
|
MainActor.runUnsafely {
|
||||||
|
PushManager.shared.pushSubscription(account: account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decryptNotification(subscription: PushNotifications.PushSubscription, serverPublicKeyData: Data, salt: Data, encryptedBody: Data) -> Data? {
|
||||||
|
// See https://github.com/ClearlyClaire/webpush/blob/f14a4d52e201128b1b00245d11b6de80d6cfdcd9/lib/webpush/encryption.rb
|
||||||
|
|
||||||
|
var context = Data()
|
||||||
|
context.append(0)
|
||||||
|
let clientPublicKey = subscription.secretKey.publicKey.x963Representation
|
||||||
|
let clientPublicKeyLength = UInt16(clientPublicKey.count)
|
||||||
|
context.append(UInt8((clientPublicKeyLength >> 8) & 0xFF))
|
||||||
|
context.append(UInt8(clientPublicKeyLength & 0xFF))
|
||||||
|
context.append(clientPublicKey)
|
||||||
|
let serverPublicKeyLength = UInt16(serverPublicKeyData.count)
|
||||||
|
context.append(UInt8((serverPublicKeyLength >> 8) & 0xFF))
|
||||||
|
context.append(UInt8(serverPublicKeyLength & 0xFF))
|
||||||
|
context.append(serverPublicKeyData)
|
||||||
|
|
||||||
|
func info(encoding: String) -> Data {
|
||||||
|
var info = Data("Content-Encoding: \(encoding)\0P-256".utf8)
|
||||||
|
info.append(context)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
let sharedSecret: SharedSecret
|
||||||
|
do {
|
||||||
|
let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData)
|
||||||
|
sharedSecret = try subscription.secretKey.sharedSecretFromKeyAgreement(with: serverPublicKey)
|
||||||
|
} catch {
|
||||||
|
logger.error("Error getting shared secret: \(String(describing: error))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let sharedInfo = Data("Content-Encoding: auth\0".utf8)
|
||||||
|
let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: subscription.authSecret, sharedInfo: sharedInfo, outputByteCount: 32)
|
||||||
|
let contentEncryptionKeyInfo = info(encoding: "aesgcm")
|
||||||
|
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: contentEncryptionKeyInfo, outputByteCount: 16)
|
||||||
|
let nonceInfo = info(encoding: "nonce")
|
||||||
|
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: nonceInfo, outputByteCount: 12)
|
||||||
|
|
||||||
|
let nonceAndEncryptedBody = nonce.withUnsafeBytes { noncePtr in
|
||||||
|
var data = Data(buffer: noncePtr.bindMemory(to: UInt8.self))
|
||||||
|
data.append(encryptedBody)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let sealedBox = try AES.GCM.SealedBox(combined: nonceAndEncryptedBody)
|
||||||
|
let decrypted = try AES.GCM.open(sealedBox, using: contentEncryptionKey)
|
||||||
|
return decrypted
|
||||||
|
} catch {
|
||||||
|
logger.error("Error decrypting push: \(String(describing: error))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MainActor {
|
||||||
|
@_unavailableFromAsync
|
||||||
|
@available(macOS, obsoleted: 14.0)
|
||||||
|
@available(iOS, obsoleted: 17.0)
|
||||||
|
@available(watchOS, obsoleted: 10.0)
|
||||||
|
@available(tvOS, obsoleted: 17.0)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
|
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||||
|
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
||||||
|
return try MainActor.assumeIsolated(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
|
return try withoutActuallyEscaping(body) { fn in
|
||||||
|
try unsafeBitCast(fn, to: (() throws -> T).self)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeBase64URL(_ s: String) -> Data? {
|
||||||
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
if str.count % 4 != 0 {
|
||||||
|
str.append(String(repeating: "=", count: 4 - str.count % 4))
|
||||||
|
}
|
||||||
|
return Data(base64Encoded: str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copied from HTMLConverter.Callbacks, blergh
|
||||||
|
private struct HTMLCallbacks: HTMLConversionCallbacks {
|
||||||
|
static func makeURL(string: String) -> URL? {
|
||||||
|
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||||
|
// serializing the WebURL as a string and then having Foundation parse it again),
|
||||||
|
// so, if available, use the system parser which doesn't require another round trip.
|
||||||
|
if let url = try? URL.ParseStrategy().parse(string) {
|
||||||
|
url
|
||||||
|
} else if let web = WebURL(string),
|
||||||
|
let url = URL(web) {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||||
|
guard name == "span" else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
let clazz = attributes.attributeValue(for: "class")
|
||||||
|
if clazz == "invisible" {
|
||||||
|
return .skip
|
||||||
|
} else if clazz == "ellipsis" {
|
||||||
|
return .append("…")
|
||||||
|
} else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>1C8F.1</string>
|
||||||
|
</array>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -24,6 +24,8 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionAttributes</key>
|
<key>NSExtensionAttributes</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSExtensionServiceRoleType</key>
|
||||||
|
<string>NSExtensionServiceRoleTypeViewer</string>
|
||||||
<key>NSExtensionActivationRule</key>
|
<key>NSExtensionActivationRule</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 5.7
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ComposeUI",
|
name: "ComposeUI",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -26,9 +26,15 @@ let package = Package(
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "ComposeUI",
|
name: "ComposeUI",
|
||||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]),
|
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ComposeUITests",
|
name: "ComposeUITests",
|
||||||
dependencies: ["ComposeUI"]),
|
dependencies: ["ComposeUI"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -156,7 +156,7 @@ class AttachmentRowController: ViewController {
|
||||||
Button(role: .destructive, action: controller.removeAttachment) {
|
Button(role: .destructive, action: controller.removeAttachment) {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
} previewIfAvailable: {
|
} preview: {
|
||||||
ControllerView(controller: { controller.thumbnailController })
|
ControllerView(controller: { controller.thumbnailController })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -221,16 +221,3 @@ extension AttachmentRowController {
|
||||||
case allowEntry, recognizingText
|
case allowEntry, recognizingText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension View {
|
|
||||||
@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 #available(iOS 16.0, *) {
|
|
||||||
self.contextMenu(menuItems: menuItems, preview: preview)
|
|
||||||
} else {
|
|
||||||
self.contextMenu(menuItems: menuItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -40,6 +40,7 @@ class AttachmentThumbnailController: ViewController {
|
||||||
case .video, .gifv:
|
case .video, .gifv:
|
||||||
let asset = AVURLAsset(url: url)
|
let asset = AVURLAsset(url: url)
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
imageGenerator.appliesPreferredTrackTransform = true
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||||
#else
|
#else
|
||||||
|
@ -91,6 +92,7 @@ class AttachmentThumbnailController: ViewController {
|
||||||
if type.conforms(to: .movie) {
|
if type.conforms(to: .movie) {
|
||||||
let asset = AVURLAsset(url: url)
|
let asset = AVURLAsset(url: url)
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
imageGenerator.appliesPreferredTrackTransform = true
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||||
#else
|
#else
|
||||||
|
|
|
@ -214,44 +214,6 @@ fileprivate extension View {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
@ViewBuilder
|
|
||||||
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
|
|
||||||
} else {
|
|
||||||
self.popover(isPresented: isPresented, content: content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
@ViewBuilder
|
|
||||||
func withSheetDetentsIfAvailable() -> some View {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
self
|
|
||||||
.presentationDetents([.medium, .large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
|
||||||
fileprivate struct SheetOrPopover<V: View>: ViewModifier {
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
@ViewBuilder let view: () -> V
|
|
||||||
|
|
||||||
@Environment(\.horizontalSizeClass) var sizeClass
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if sizeClass == .compact {
|
|
||||||
content.sheet(isPresented: $isPresented, content: view)
|
|
||||||
} else {
|
|
||||||
content.popover(isPresented: $isPresented, content: view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(visionOS 1.0, *)
|
@available(visionOS 1.0, *)
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
class AutocompleteEmojisController: ViewController {
|
class AutocompleteEmojisController: ViewController {
|
||||||
unowned let composeController: ComposeController
|
unowned let composeController: ComposeController
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
class AutocompleteHashtagsController: ViewController {
|
class AutocompleteHashtagsController: ViewController {
|
||||||
unowned let composeController: ComposeController
|
unowned let composeController: ComposeController
|
||||||
|
|
|
@ -125,9 +125,7 @@ public final class ComposeController: ViewController {
|
||||||
self.toolbarController = ToolbarController(parent: self)
|
self.toolbarController = ToolbarController(parent: self)
|
||||||
self.attachmentsListController = AttachmentsListController(parent: self)
|
self.attachmentsListController = AttachmentsListController(parent: self)
|
||||||
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
||||||
}
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
|
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -324,16 +322,17 @@ public final class ComposeController: ViewController {
|
||||||
ControllerView(controller: { controller.toolbarController })
|
ControllerView(controller: { controller.toolbarController })
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if !os(visionOS)
|
|
||||||
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
|
||||||
.padding(.bottom, keyboardInset)
|
|
||||||
#endif
|
|
||||||
.transition(.move(edge: .bottom))
|
.transition(.move(edge: .bottom))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
ToolbarItem(placement: .topBarTrailing) { draftsButton }
|
||||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||||
|
#else
|
||||||
|
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
|
||||||
|
#endif
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
ToolbarItem(placement: .bottomOrnament) {
|
ToolbarItem(placement: .bottomOrnament) {
|
||||||
ControllerView(controller: { controller.toolbarController })
|
ControllerView(controller: { controller.toolbarController })
|
||||||
|
@ -431,7 +430,7 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
.scrollDismissesKeyboard(.interactively)
|
||||||
#endif
|
#endif
|
||||||
.disabled(controller.isPosting)
|
.disabled(controller.isPosting)
|
||||||
}
|
}
|
||||||
|
@ -461,43 +460,26 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var postButton: some View {
|
private var postOrDraftsButton: some View {
|
||||||
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||||
|
postButton
|
||||||
|
} else {
|
||||||
|
draftsButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var draftsButton: some View {
|
||||||
|
Button(action: controller.showDrafts) {
|
||||||
|
Text("Drafts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var postButton: some View {
|
||||||
Button(action: controller.postStatus) {
|
Button(action: controller.postStatus) {
|
||||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
.disabled(!controller.postButtonEnabled)
|
.disabled(!controller.postButtonEnabled)
|
||||||
} else {
|
|
||||||
Button(action: controller.showDrafts) {
|
|
||||||
Text("Drafts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
private var keyboardInset: CGFloat {
|
|
||||||
if #unavailable(iOS 16.0),
|
|
||||||
UIDevice.current.userInterfaceIdiom == .pad,
|
|
||||||
keyboardReader.isVisible {
|
|
||||||
return ToolbarController.height
|
|
||||||
} else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension View {
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
@ViewBuilder
|
|
||||||
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
self.scrollDismissesKeyboard(.interactively)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,14 +51,11 @@ class FocusedAttachmentController: ViewController {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
} else if #available(iOS 16.0, *) {
|
} else {
|
||||||
ZoomableScrollView {
|
ZoomableScrollView {
|
||||||
attachmentView
|
attachmentView
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
.matchedGeometryDestination(id: attachment.id)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
attachmentView
|
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
|
@ -96,7 +96,7 @@ class PollController: ViewController {
|
||||||
.onMove(perform: controller.moveOptions)
|
.onMove(perform: controller.moveOptions)
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollDisabledIfAvailable(true)
|
.scrollDisabled(true)
|
||||||
.frame(height: 44 * CGFloat(poll.options.count))
|
.frame(height: 44 * CGFloat(poll.options.count))
|
||||||
|
|
||||||
Button(action: controller.addOption) {
|
Button(action: controller.addOption) {
|
||||||
|
|
|
@ -66,7 +66,7 @@ class ToolbarController: ViewController {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
|
||||||
.frame(height: ToolbarController.height)
|
.frame(height: ToolbarController.height)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||||
|
@ -122,8 +122,7 @@ class ToolbarController: ViewController {
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if #available(iOS 16.0, *),
|
if composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||||
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
|
||||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -181,13 +180,8 @@ class ToolbarController: ViewController {
|
||||||
private var formatButtons: some View {
|
private var formatButtons: some View {
|
||||||
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
||||||
Button(action: controller.formatAction(format)) {
|
Button(action: controller.formatAction(format)) {
|
||||||
if let imageName = format.imageName {
|
Image(systemName: format.imageName)
|
||||||
Image(systemName: imageName)
|
|
||||||
.font(.system(size: imageSize))
|
.font(.system(size: imageSize))
|
||||||
} else if let (str, attrs) = format.title {
|
|
||||||
let container = try! AttributeContainer(attrs, including: \.uiKit)
|
|
||||||
Text(AttributedString(str, attributes: container))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.accessibilityLabel(format.accessibilityLabel)
|
.accessibilityLabel(format.accessibilityLabel)
|
||||||
.padding(5)
|
.padding(5)
|
||||||
|
|
|
@ -167,11 +167,23 @@ extension DraftAttachment: NSItemProviderReading {
|
||||||
type = .png
|
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)
|
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
|
||||||
attachment.id = UUID()
|
attachment.id = UUID()
|
||||||
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
|
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
|
||||||
attachment.fileType = type.identifier
|
attachment.fileType = type.identifier
|
||||||
attachment.attachmentDescription = ""
|
attachment.attachmentDescription = caption
|
||||||
return attachment
|
return attachment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,8 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
public static let shared = DraftsPersistentContainer()
|
public static let shared = DraftsPersistentContainer()
|
||||||
|
|
||||||
|
public static var captureError: ((any Error) -> Void)?
|
||||||
|
|
||||||
private static let managedObjectModel: NSManagedObjectModel = {
|
private static let managedObjectModel: NSManagedObjectModel = {
|
||||||
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
|
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
|
||||||
return NSManagedObjectModel(contentsOf: url)!
|
return NSManagedObjectModel(contentsOf: url)!
|
||||||
|
@ -39,6 +41,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
loadPersistentStores { _, error in
|
loadPersistentStores { _, error in
|
||||||
if let error {
|
if let error {
|
||||||
|
DraftsPersistentContainer.captureError?(error)
|
||||||
fatalError("Loading persistent store: \(error)")
|
fatalError("Loading persistent store: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,6 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
class KeyboardReader: ObservableObject {
|
class KeyboardReader: ObservableObject {
|
||||||
// @Published var isVisible = false
|
// @Published var isVisible = false
|
||||||
@Published var keyboardHeight: CGFloat = 0
|
@Published var keyboardHeight: CGFloat = 0
|
||||||
|
|
|
@ -23,7 +23,7 @@ enum StatusFormat: Int, CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageName: String? {
|
var imageName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .italics:
|
case .italics:
|
||||||
return "italic"
|
return "italic"
|
||||||
|
@ -31,16 +31,8 @@ enum StatusFormat: Int, CaseIterable {
|
||||||
return "bold"
|
return "bold"
|
||||||
case .strikethrough:
|
case .strikethrough:
|
||||||
return "strikethrough"
|
return "strikethrough"
|
||||||
default:
|
case .code:
|
||||||
return nil
|
return "chevron.left.forwardslash.chevron.right"
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: (String, [NSAttributedString.Key: Any])? {
|
|
||||||
if self == .code {
|
|
||||||
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,7 @@ extension TextViewCaretScrolling {
|
||||||
|
|
||||||
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
||||||
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
||||||
|
scrollView.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
self.caretScrollPositionAnimator = animator
|
self.caretScrollPositionAnimator = animator
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
|
@ -259,11 +259,7 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
||||||
if range.length > 0 {
|
if range.length > 0 {
|
||||||
let formatMenu = suggestedActions[index] as! UIMenu
|
let formatMenu = suggestedActions[index] as! UIMenu
|
||||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||||
var image: UIImage?
|
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [weak self] _ in
|
||||||
if let imageName = fmt.imageName {
|
|
||||||
image = UIImage(systemName: imageName)
|
|
||||||
}
|
|
||||||
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
|
|
||||||
self?.applyFormat(fmt)
|
self?.applyFormat(fmt)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -76,13 +76,15 @@ struct ReplyStatusView: View {
|
||||||
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
|
// 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)
|
offset = min(offset, maxOffset)
|
||||||
|
|
||||||
return AvatarImageView(
|
return AvatarContainerRepresentable(offset: offset) {
|
||||||
|
AvatarImageView(
|
||||||
url: status.account.avatar,
|
url: status.account.avatar,
|
||||||
size: 50,
|
size: 50,
|
||||||
style: controller.config.avatarStyle,
|
style: controller.config.avatarStyle,
|
||||||
fetchAvatar: controller.fetchAvatar
|
fetchAvatar: controller.fetchAvatar
|
||||||
)
|
)
|
||||||
.offset(x: 0, y: offset)
|
}
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,3 +96,39 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
|
||||||
value = nextValue()
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 5.7
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Duckable",
|
name: "Duckable",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -23,7 +23,10 @@ let package = Package(
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "Duckable",
|
name: "Duckable",
|
||||||
dependencies: []),
|
dependencies: [],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
// .testTarget(
|
// .testTarget(
|
||||||
// name: "DuckableTests",
|
// name: "DuckableTests",
|
||||||
// dependencies: ["Duckable"]),
|
// dependencies: ["Duckable"]),
|
||||||
|
|
|
@ -33,11 +33,11 @@ public enum DuckAttemptAction {
|
||||||
|
|
||||||
extension UIViewController {
|
extension UIViewController {
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false, completion: (() -> Void)? = nil) -> Bool {
|
||||||
var cur: UIViewController? = self
|
var cur: UIViewController? = self
|
||||||
while let vc = cur {
|
while let vc = cur {
|
||||||
if let container = vc as? DuckableContainerViewController {
|
if let container = vc as? DuckableContainerViewController {
|
||||||
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
|
container._presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: completion)
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
cur = vc.parent
|
cur = vc.parent
|
||||||
|
|
|
@ -58,7 +58,7 @@ public class DuckableContainerViewController: UIViewController {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
|
func _presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
|
||||||
guard case .idle = state else {
|
guard case .idle = state else {
|
||||||
if animated,
|
if animated,
|
||||||
case .ducked(_, placeholder: let placeholder) = state {
|
case .ducked(_, placeholder: let placeholder) = state {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 5.10
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "GalleryVC",
|
name: "GalleryVC",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
@ -18,9 +18,15 @@ let package = Package(
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
.target(
|
.target(
|
||||||
name: "GalleryVC"),
|
name: "GalleryVC",
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "GalleryVCTests",
|
name: "GalleryVCTests",
|
||||||
dependencies: ["GalleryVC"]),
|
dependencies: ["GalleryVC"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -17,7 +17,7 @@ public protocol GalleryContentViewController: UIViewController {
|
||||||
var bottomControlsAccessoryViewController: UIViewController? { get }
|
var bottomControlsAccessoryViewController: UIViewController? { get }
|
||||||
var canAnimateFromSourceView: Bool { get }
|
var canAnimateFromSourceView: Bool { get }
|
||||||
|
|
||||||
func setControlsVisible(_ visible: Bool, animated: Bool)
|
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
|
||||||
func galleryContentDidAppear()
|
func galleryContentDidAppear()
|
||||||
func galleryContentWillDisappear()
|
func galleryContentWillDisappear()
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ public extension GalleryContentViewController {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
func setControlsVisible(_ visible: Bool, animated: Bool) {
|
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func galleryContentDidAppear() {
|
func galleryContentDidAppear() {
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public protocol GalleryContentViewControllerContainer {
|
public protocol GalleryContentViewControllerContainer: AnyObject {
|
||||||
var galleryControlsVisible: Bool { get }
|
var galleryControlsVisible: Bool { get }
|
||||||
|
|
||||||
func setGalleryContentLoading(_ loading: Bool)
|
func setGalleryContentLoading(_ loading: Bool)
|
||||||
|
|
|
@ -52,12 +52,22 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||||
appliedSourceToDestTransform = false
|
appliedSourceToDestTransform = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||||
|
// is in the window's root presentation.
|
||||||
|
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||||
|
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||||
|
// container causees it to be removed when the transition completes.
|
||||||
|
if to.view.superview == nil {
|
||||||
|
to.view.frame = container.bounds
|
||||||
|
container.addSubview(to.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
from.view.frame = container.bounds
|
||||||
|
container.addSubview(from.view)
|
||||||
|
|
||||||
let content = itemViewController.takeContent()
|
let content = itemViewController.takeContent()
|
||||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||||
content.view.layer.masksToBounds = true
|
content.view.layer.masksToBounds = true
|
||||||
|
|
||||||
container.addSubview(to.view)
|
|
||||||
container.addSubview(from.view)
|
|
||||||
container.addSubview(content.view)
|
container.addSubview(content.view)
|
||||||
|
|
||||||
content.view.frame = destFrameInContainer
|
content.view.frame = destFrameInContainer
|
||||||
|
@ -96,7 +106,7 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||||
content.view.frame = sourceFrameInContainer
|
content.view.frame = sourceFrameInContainer
|
||||||
content.view.layer.opacity = 0
|
content.view.layer.opacity = 0
|
||||||
|
|
||||||
itemViewController.setControlsVisible(false, animated: false)
|
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
animator.addCompletion { _ in
|
animator.addCompletion { _ in
|
||||||
|
@ -112,6 +122,8 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toVC.view.frame = transitionContext.containerView.bounds
|
||||||
|
fromVC.view.frame = transitionContext.containerView.bounds
|
||||||
transitionContext.containerView.addSubview(toVC.view)
|
transitionContext.containerView.addSubview(toVC.view)
|
||||||
transitionContext.containerView.addSubview(fromVC.view)
|
transitionContext.containerView.addSubview(fromVC.view)
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import UIKit
|
||||||
@MainActor
|
@MainActor
|
||||||
class GalleryDismissInteraction: NSObject {
|
class GalleryDismissInteraction: NSObject {
|
||||||
|
|
||||||
private let viewController: GalleryViewController
|
private unowned let viewController: GalleryViewController
|
||||||
|
|
||||||
private var content: GalleryContentViewController?
|
private var content: GalleryContentViewController?
|
||||||
private var origContentFrameInGallery: CGRect?
|
private var origContentFrameInGallery: CGRect?
|
||||||
|
@ -42,7 +42,7 @@ class GalleryDismissInteraction: NSObject {
|
||||||
|
|
||||||
origControlsVisible = viewController.currentItemViewController.controlsVisible
|
origControlsVisible = viewController.currentItemViewController.controlsVisible
|
||||||
if origControlsVisible! {
|
if origControlsVisible! {
|
||||||
viewController.currentItemViewController.setControlsVisible(false, animated: true)
|
viewController.currentItemViewController.setControlsVisible(false, animated: true, dueToUserInteraction: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .changed:
|
case .changed:
|
||||||
|
|
|
@ -43,6 +43,8 @@ class GalleryItemViewController: UIViewController {
|
||||||
private(set) var controlsVisible: Bool = true
|
private(set) var controlsVisible: Bool = true
|
||||||
private(set) var scrollAndZoomEnabled = true
|
private(set) var scrollAndZoomEnabled = true
|
||||||
|
|
||||||
|
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
||||||
|
|
||||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||||
return !controlsVisible
|
return !controlsVisible
|
||||||
}
|
}
|
||||||
|
@ -79,10 +81,10 @@ class GalleryItemViewController: UIViewController {
|
||||||
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
|
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(overlayVC.view)
|
view.addSubview(overlayVC.view)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
overlayVC.view.leadingAnchor.constraint(equalTo: content.view.leadingAnchor),
|
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
overlayVC.view.trailingAnchor.constraint(equalTo: content.view.trailingAnchor),
|
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
overlayVC.view.topAnchor.constraint(equalTo: content.view.topAnchor),
|
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
overlayVC.view.bottomAnchor.constraint(equalTo: content.view.bottomAnchor),
|
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,7 +213,7 @@ class GalleryItemViewController: UIViewController {
|
||||||
|
|
||||||
updateZoomScale(resetZoom: false)
|
updateZoomScale(resetZoom: false)
|
||||||
// Ensure the transform is correct if the controls are hidden
|
// Ensure the transform is correct if the controls are hidden
|
||||||
setControlsVisible(controlsVisible, animated: false)
|
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
||||||
|
|
||||||
updateTopControlsInsets()
|
updateTopControlsInsets()
|
||||||
}
|
}
|
||||||
|
@ -219,7 +221,15 @@ class GalleryItemViewController: UIViewController {
|
||||||
override func viewDidLayoutSubviews() {
|
override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
|
|
||||||
|
// When the scrollView size changes, make sure the zoom scale is up-to-date since it depends on the scrollView's bounds.
|
||||||
|
// This might also fix an issue on macOS (Designed for iPad) where the content isn't placed correctly. See #446
|
||||||
|
if scrollViewSizeForLastZoomScaleUpdate != scrollView.bounds.size {
|
||||||
|
scrollViewSizeForLastZoomScaleUpdate = scrollView.bounds.size
|
||||||
|
updateZoomScale(resetZoom: true)
|
||||||
|
}
|
||||||
centerContent()
|
centerContent()
|
||||||
|
// Ensure the transform is correct if the controls are hidden and their size changed.
|
||||||
|
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
@ -240,7 +250,7 @@ class GalleryItemViewController: UIViewController {
|
||||||
func addContent() {
|
func addContent() {
|
||||||
content.loadViewIfNeeded()
|
content.loadViewIfNeeded()
|
||||||
|
|
||||||
content.setControlsVisible(controlsVisible, animated: false)
|
content.setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
||||||
|
|
||||||
content.view.translatesAutoresizingMaskIntoConstraints = false
|
content.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
if content.parent != self {
|
if content.parent != self {
|
||||||
|
@ -280,16 +290,18 @@ class GalleryItemViewController: UIViewController {
|
||||||
content.view.layoutIfNeeded()
|
content.view.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setControlsVisible(_ visible: Bool, animated: Bool) {
|
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||||
controlsVisible = visible
|
controlsVisible = visible
|
||||||
|
|
||||||
guard let topControlsView,
|
guard let topControlsView,
|
||||||
let bottomControlsView else {
|
let bottomControlsView else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateControlsViews() {
|
func updateControlsViews() {
|
||||||
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
|
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
|
||||||
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
|
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
|
||||||
content.setControlsVisible(visible, animated: animated)
|
content.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
|
||||||
}
|
}
|
||||||
if animated {
|
if animated {
|
||||||
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
|
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
|
||||||
|
@ -303,6 +315,8 @@ class GalleryItemViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateZoomScale(resetZoom: Bool) {
|
func updateZoomScale(resetZoom: Bool) {
|
||||||
|
scrollView.contentSize = content.contentSize
|
||||||
|
|
||||||
guard scrollAndZoomEnabled else {
|
guard scrollAndZoomEnabled else {
|
||||||
scrollView.maximumZoomScale = 1
|
scrollView.maximumZoomScale = 1
|
||||||
scrollView.minimumZoomScale = 1
|
scrollView.minimumZoomScale = 1
|
||||||
|
@ -364,9 +378,6 @@ class GalleryItemViewController: UIViewController {
|
||||||
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
|
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
|
||||||
50, // iPhone 12 mini, 13 mini
|
50, // iPhone 12 mini, 13 mini
|
||||||
]
|
]
|
||||||
let islandDeviceTopInsets: [CGFloat] = [
|
|
||||||
59, // iPhone 14 Pro, 14 Pro Max, 15 Pro, 15 Pro Max
|
|
||||||
]
|
|
||||||
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
||||||
// the notch width is not the same for the iPhones 13,
|
// the notch width is not the same for the iPhones 13,
|
||||||
// but what we actually want is the same offset from the edges
|
// but what we actually want is the same offset from the edges
|
||||||
|
@ -376,16 +387,18 @@ class GalleryItemViewController: UIViewController {
|
||||||
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
|
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
|
||||||
shareButtonLeadingConstraint.constant = offset
|
shareButtonLeadingConstraint.constant = offset
|
||||||
closeButtonTrailingConstraint.constant = offset
|
closeButtonTrailingConstraint.constant = offset
|
||||||
} else if islandDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
} else if view.safeAreaInsets.top == 0 {
|
||||||
shareButtonLeadingConstraint.constant = 24
|
// square corner devices
|
||||||
shareButtonTopConstraint.constant = 24
|
|
||||||
closeButtonTrailingConstraint.constant = 24
|
|
||||||
closeButtonTopConstraint.constant = 24
|
|
||||||
} else {
|
|
||||||
shareButtonLeadingConstraint.constant = 8
|
shareButtonLeadingConstraint.constant = 8
|
||||||
shareButtonTopConstraint.constant = 8
|
shareButtonTopConstraint.constant = 8
|
||||||
closeButtonTrailingConstraint.constant = 8
|
closeButtonTrailingConstraint.constant = 8
|
||||||
closeButtonTopConstraint.constant = 8
|
closeButtonTopConstraint.constant = 8
|
||||||
|
} else {
|
||||||
|
// dynamic island devices
|
||||||
|
shareButtonLeadingConstraint.constant = 24
|
||||||
|
shareButtonTopConstraint.constant = 24
|
||||||
|
closeButtonTrailingConstraint.constant = 24
|
||||||
|
closeButtonTopConstraint.constant = 24
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -415,7 +428,7 @@ class GalleryItemViewController: UIViewController {
|
||||||
scrollView.zoomScale > scrollView.minimumZoomScale {
|
scrollView.zoomScale > scrollView.minimumZoomScale {
|
||||||
animateZoomOut()
|
animateZoomOut()
|
||||||
} else {
|
} else {
|
||||||
setControlsVisible(!controlsVisible, animated: true)
|
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -517,7 +530,7 @@ extension GalleryItemViewController: GalleryContentViewControllerContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
|
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
|
||||||
setControlsVisible(visible, animated: animated)
|
setControlsVisible(visible, animated: animated, dueToUserInteraction: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -532,9 +545,9 @@ extension GalleryItemViewController: UIScrollViewDelegate {
|
||||||
|
|
||||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||||
setControlsVisible(true, animated: true)
|
setControlsVisible(true, animated: true, dueToUserInteraction: true)
|
||||||
} else {
|
} else {
|
||||||
setControlsVisible(false, animated: true)
|
setControlsVisible(false, animated: true, dueToUserInteraction: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
centerContent()
|
centerContent()
|
||||||
|
|
|
@ -75,7 +75,7 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||||
container.layoutIfNeeded()
|
container.layoutIfNeeded()
|
||||||
|
|
||||||
// This needs to take place after the layout, so that the transform is correct.
|
// This needs to take place after the layout, so that the transform is correct.
|
||||||
itemViewController.setControlsVisible(false, animated: false)
|
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||||
|
|
||||||
let duration = self.transitionDuration(using: transitionContext)
|
let duration = self.transitionDuration(using: transitionContext)
|
||||||
// rougly equivalent to duration: 0.35, bounce: 0.3
|
// rougly equivalent to duration: 0.35, bounce: 0.3
|
||||||
|
@ -90,7 +90,7 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||||
content.view.frame = destFrameInContainer
|
content.view.frame = destFrameInContainer
|
||||||
content.view.layer.opacity = 1
|
content.view.layer.opacity = 1
|
||||||
|
|
||||||
itemViewController.setControlsVisible(true, animated: false)
|
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
|
||||||
|
|
||||||
if let sourceToDestTransform {
|
if let sourceToDestTransform {
|
||||||
self.sourceView.transform = sourceToDestTransform
|
self.sourceView.transform = sourceToDestTransform
|
||||||
|
@ -109,8 +109,6 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||||
itemViewController.addContent()
|
itemViewController.addContent()
|
||||||
|
|
||||||
transitionContext.completeTransition(true)
|
transitionContext.completeTransition(true)
|
||||||
|
|
||||||
to.presentationAnimationCompleted()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
|
@ -121,8 +119,9 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transitionContext.containerView.addSubview(to.view)
|
|
||||||
to.view.alpha = 0
|
to.view.alpha = 0
|
||||||
|
to.view.frame = transitionContext.containerView.bounds
|
||||||
|
transitionContext.containerView.addSubview(to.view)
|
||||||
|
|
||||||
let duration = transitionDuration(using: transitionContext)
|
let duration = transitionDuration(using: transitionContext)
|
||||||
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
|
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
|
||||||
|
@ -131,8 +130,6 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||||
}
|
}
|
||||||
animator.addCompletion { _ in
|
animator.addCompletion { _ in
|
||||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||||
|
|
||||||
to.presentationAnimationCompleted()
|
|
||||||
}
|
}
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,6 +68,17 @@ public class GalleryViewController: UIPageViewController {
|
||||||
setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false)
|
setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
// Wait until the transition is no longer in-progress, otherwise things will just get deferred again.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.presentationAnimationCompleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
|
@ -114,6 +125,8 @@ extension GalleryViewController: UIPageViewControllerDataSource {
|
||||||
extension GalleryViewController: UIPageViewControllerDelegate {
|
extension GalleryViewController: UIPageViewControllerDelegate {
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||||
currentItemViewController.content.galleryContentWillDisappear()
|
currentItemViewController.content.galleryContentWillDisappear()
|
||||||
|
let new = pendingViewControllers[0] as! GalleryItemViewController
|
||||||
|
new.setControlsVisible(currentItemViewController.controlsVisible, animated: false, dueToUserInteraction: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||||
|
@ -141,14 +154,21 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
|
||||||
|
|
||||||
extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
||||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
#if os(visionOS)
|
||||||
|
return nil
|
||||||
|
#else
|
||||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
|
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
|
||||||
return GalleryPresentationAnimationController(sourceView: sourceView)
|
return GalleryPresentationAnimationController(sourceView: sourceView)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
#if os(visionOS)
|
||||||
|
return nil
|
||||||
|
#else
|
||||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
|
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
|
||||||
let translation: CGPoint?
|
let translation: CGPoint?
|
||||||
let velocity: CGPoint?
|
let velocity: CGPoint?
|
||||||
|
@ -164,5 +184,6 @@ extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 5.7
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "InstanceFeatures",
|
name: "InstanceFeatures",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -23,9 +23,15 @@ let package = Package(
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "InstanceFeatures",
|
name: "InstanceFeatures",
|
||||||
dependencies: ["Pachyderm"]),
|
dependencies: ["Pachyderm"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "InstanceFeaturesTests",
|
name: "InstanceFeaturesTests",
|
||||||
dependencies: ["InstanceFeatures"]),
|
dependencies: ["InstanceFeatures"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -157,7 +157,7 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public var needsEditAttachmentsInSeparateRequest: Bool {
|
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||||
instanceType.isPleroma(.akkoma(nil))
|
instanceType.isPleroma
|
||||||
}
|
}
|
||||||
|
|
||||||
public var composeDirectStatuses: Bool {
|
public var composeDirectStatuses: Bool {
|
||||||
|
@ -184,6 +184,51 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil))
|
hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var pushNotificationTypeStatus: Bool {
|
||||||
|
hasMastodonVersion(3, 3, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var pushNotificationTypeFollowRequest: Bool {
|
||||||
|
hasMastodonVersion(3, 1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var pushNotificationTypeUpdate: Bool {
|
||||||
|
hasMastodonVersion(3, 5, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var pushNotificationPolicy: Bool {
|
||||||
|
hasMastodonVersion(3, 5, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var pushNotificationPolicyMissingFromResponse: Bool {
|
||||||
|
switch instanceType {
|
||||||
|
case .mastodon(_, let version):
|
||||||
|
return version >= Version(3, 5, 0) && version < Version(4, 1, 0)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var instanceAnnouncements: Bool {
|
||||||
|
hasMastodonVersion(3, 1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var emojiReactionNotifications: Bool {
|
||||||
|
instanceType.isPleroma
|
||||||
|
}
|
||||||
|
|
||||||
|
public var muteNotifications: Bool {
|
||||||
|
!instanceType.isPixelfed
|
||||||
|
}
|
||||||
|
|
||||||
|
public var blockDomains: Bool {
|
||||||
|
!instanceType.isPixelfed
|
||||||
|
}
|
||||||
|
|
||||||
|
public var hideReblogs: Bool {
|
||||||
|
!instanceType.isPixelfed
|
||||||
|
}
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -305,6 +350,14 @@ extension InstanceFeatures {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isPixelfed: Bool {
|
||||||
|
if case .pixelfed = self {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@_spi(InstanceType) public enum MastodonType {
|
@_spi(InstanceType) public enum MastodonType {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 5.8
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "MatchedGeometryPresentation",
|
name: "MatchedGeometryPresentation",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
@ -18,7 +18,10 @@ let package = Package(
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
.target(
|
.target(
|
||||||
name: "MatchedGeometryPresentation"),
|
name: "MatchedGeometryPresentation",
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
// .testTarget(
|
// .testTarget(
|
||||||
// name: "MatchedGeometryPresentationTests",
|
// name: "MatchedGeometryPresentationTests",
|
||||||
// dependencies: ["MatchedGeometryPresentation"]),
|
// dependencies: ["MatchedGeometryPresentation"]),
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 5.6
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Pachyderm",
|
name: "Pachyderm",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -16,7 +16,7 @@ let package = Package(
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
// Dependencies declare other packages that this package depends on.
|
// Dependencies declare other packages that this package depends on.
|
||||||
.package(url: "https://github.com/karwa/swift-url.git", branch: "main"),
|
.package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
|
@ -26,9 +26,15 @@ let package = Package(
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "WebURL", package: "swift-url"),
|
.product(name: "WebURL", package: "swift-url"),
|
||||||
.product(name: "WebURLFoundationExtras", package: "swift-url"),
|
.product(name: "WebURLFoundationExtras", package: "swift-url"),
|
||||||
|
],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
]),
|
]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "PachydermTests",
|
name: "PachydermTests",
|
||||||
dependencies: ["Pachyderm"]),
|
dependencies: ["Pachyderm"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -42,7 +42,7 @@ public struct Client: Sendable {
|
||||||
} else if let date = iso8601.date(from: str) {
|
} else if let date = iso8601.date(from: str) {
|
||||||
return date
|
return date
|
||||||
} else {
|
} else {
|
||||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
|
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -204,8 +204,8 @@ public struct Client: Sendable {
|
||||||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
|
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
@ -341,6 +341,10 @@ public struct Client: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
|
public static func getNotification(id: String) -> Request<Notification> {
|
||||||
|
return Request(method: .get, path: "/api/v1/notifications/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||||
"types" => allowedTypes.map { $0.rawValue }
|
"types" => allowedTypes.map { $0.rawValue }
|
||||||
|
@ -452,14 +456,13 @@ public struct Client: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timelines
|
// MARK: - Timelines
|
||||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||||
return timeline.request(range: range)
|
return timeline.request(range: range)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Bookmarks
|
// MARK: - Bookmarks
|
||||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
@ -487,7 +490,7 @@ public struct Client: Sendable {
|
||||||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
|
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
|
||||||
var parameters: [Parameter] = []
|
var parameters: [Parameter] = []
|
||||||
if let limit {
|
if let limit {
|
||||||
parameters.append("limit" => limit)
|
parameters.append("limit" => limit)
|
||||||
|
|
|
@ -40,8 +40,9 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
||||||
self.displayName = try container.decode(String.self, forKey: .displayName)
|
self.displayName = try container.decode(String.self, forKey: .displayName)
|
||||||
self.locked = try container.decode(Bool.self, forKey: .locked)
|
self.locked = try container.decode(Bool.self, forKey: .locked)
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
|
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
|
||||||
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
|
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
|
||||||
|
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||||
self.note = try container.decode(String.self, forKey: .note)
|
self.note = try container.decode(String.self, forKey: .note)
|
||||||
self.url = try container.decode(URL.self, forKey: .url)
|
self.url = try container.decode(URL.self, forKey: .url)
|
||||||
|
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
|
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||||
"only_media" => onlyMedia,
|
"only_media" => onlyMedia,
|
||||||
"pinned" => pinned,
|
"pinned" => pinned,
|
||||||
"exclude_replies" => excludeReplies,
|
"exclude_replies" => excludeReplies,
|
||||||
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
//
|
||||||
|
// Announcement.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/16/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
|
public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let content: String
|
||||||
|
public let startsAt: Date?
|
||||||
|
public let endsAt: Date?
|
||||||
|
public let allDay: Bool
|
||||||
|
public let publishedAt: Date
|
||||||
|
public let updatedAt: Date
|
||||||
|
public let read: Bool?
|
||||||
|
public let mentions: [Account]
|
||||||
|
public let statuses: [Status]
|
||||||
|
public let tags: [Hashtag]
|
||||||
|
public let emojis: [Emoji]
|
||||||
|
public var reactions: [Reaction]
|
||||||
|
|
||||||
|
public static func all() -> Request<[Announcement]> {
|
||||||
|
return Request(method: .get, path: "/api/v1/announcements")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func dismiss(id: String) -> Request<Empty> {
|
||||||
|
return Request(method: .post, path: "/api/v1/announcements/\(id)/dismiss")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func react(id: String, name: String) -> Request<Empty> {
|
||||||
|
return Request(method: .put, path: "/api/v1/announcements/\(id)/reactions/\(name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func unreact(id: String, name: String) -> Request<Empty> {
|
||||||
|
return Request(method: .delete, path: "/api/v1/announcements/\(id)/reactions/\(name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case content
|
||||||
|
case startsAt = "starts_at"
|
||||||
|
case endsAt = "ends_at"
|
||||||
|
case allDay = "all_day"
|
||||||
|
case publishedAt = "published_at"
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
case read
|
||||||
|
case mentions
|
||||||
|
case statuses
|
||||||
|
case tags
|
||||||
|
case emojis
|
||||||
|
case reactions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Announcement {
|
||||||
|
public struct Account: Decodable, Sendable, Hashable {
|
||||||
|
public let id: String
|
||||||
|
public let username: String
|
||||||
|
public let url: WebURL
|
||||||
|
public let acct: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Announcement {
|
||||||
|
public struct Status: Decodable, Sendable, Hashable {
|
||||||
|
public let id: String
|
||||||
|
public let url: WebURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Announcement {
|
||||||
|
public struct Reaction: Decodable, Sendable, Hashable {
|
||||||
|
public let name: String
|
||||||
|
public var count: Int
|
||||||
|
public var me: Bool?
|
||||||
|
public let url: URL?
|
||||||
|
public let staticURL: URL?
|
||||||
|
|
||||||
|
public init(name: String, count: Int, me: Bool?, url: URL?, staticURL: URL?) {
|
||||||
|
self.name = name
|
||||||
|
self.count = count
|
||||||
|
self.me = me
|
||||||
|
self.url = url
|
||||||
|
self.staticURL = staticURL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case count
|
||||||
|
case me
|
||||||
|
case url
|
||||||
|
case staticURL = "static_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,6 +25,17 @@ public struct Attachment: Codable, Sendable {
|
||||||
], nil))
|
], nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(id: String, kind: Attachment.Kind, url: URL, remoteURL: URL? = nil, previewURL: URL? = nil, meta: Attachment.Metadata? = nil, description: String? = nil, blurHash: String? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.kind = kind
|
||||||
|
self.url = url
|
||||||
|
self.remoteURL = remoteURL
|
||||||
|
self.previewURL = previewURL
|
||||||
|
self.meta = meta
|
||||||
|
self.description = description
|
||||||
|
self.blurHash = blurHash
|
||||||
|
}
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
|
|
|
@ -26,6 +26,38 @@ public struct Card: Codable, Sendable {
|
||||||
/// Only present when returned from the trending links endpoint
|
/// Only present when returned from the trending links endpoint
|
||||||
public let history: [History]?
|
public let history: [History]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: WebURL,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
image: WebURL? = nil,
|
||||||
|
kind: Card.Kind,
|
||||||
|
authorName: String? = nil,
|
||||||
|
authorURL: WebURL? = nil,
|
||||||
|
providerName: String? = nil,
|
||||||
|
providerURL: WebURL? = nil,
|
||||||
|
html: String? = nil,
|
||||||
|
width: Int? = nil,
|
||||||
|
height: Int? = nil,
|
||||||
|
blurhash: String? = nil,
|
||||||
|
history: [History]? = nil
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.image = image
|
||||||
|
self.kind = kind
|
||||||
|
self.authorName = authorName
|
||||||
|
self.authorURL = authorURL
|
||||||
|
self.providerName = providerName
|
||||||
|
self.providerURL = providerURL
|
||||||
|
self.html = html
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.blurhash = blurhash
|
||||||
|
self.history = history
|
||||||
|
}
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
|
|
@ -43,8 +43,13 @@ extension Emoji: CustomDebugStringConvertible {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Emoji: Equatable {
|
extension Emoji: Equatable, Hashable {
|
||||||
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
|
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
|
||||||
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
|
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(shortcode)
|
||||||
|
hasher.combine(url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,7 +53,7 @@ extension InstanceV2 {
|
||||||
public struct Thumbnail: Decodable, Sendable {
|
public struct Thumbnail: Decodable, Sendable {
|
||||||
public let url: String
|
public let url: String
|
||||||
public let blurhash: String?
|
public let blurhash: String?
|
||||||
public let versions: ThumbnailVersions
|
public let versions: ThumbnailVersions?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ThumbnailVersions: Decodable, Sendable {
|
public struct ThumbnailVersions: Decodable, Sendable {
|
||||||
|
@ -120,6 +120,6 @@ extension InstanceV2 {
|
||||||
extension InstanceV2 {
|
extension InstanceV2 {
|
||||||
public struct Contact: Decodable, Sendable {
|
public struct Contact: Decodable, Sendable {
|
||||||
public let email: String
|
public let email: String
|
||||||
public let account: Account
|
public let account: Account?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
public struct Notification: Decodable, Sendable {
|
public struct Notification: Decodable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
|
@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable {
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public let account: Account
|
public let account: Account
|
||||||
public let status: Status?
|
public let status: Status?
|
||||||
|
// Only present for pleroma emoji reactions
|
||||||
|
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
|
||||||
|
public let emoji: String?
|
||||||
|
public let emojiURL: WebURL?
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
@ -27,6 +32,8 @@ public struct Notification: Decodable, Sendable {
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
self.account = try container.decode(Account.self, forKey: .account)
|
self.account = try container.decode(Account.self, forKey: .account)
|
||||||
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||||
|
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
|
||||||
|
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||||
|
@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable {
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case account
|
case account
|
||||||
case status
|
case status
|
||||||
|
case emoji
|
||||||
|
case emojiURL = "emoji_url"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,6 +61,7 @@ extension Notification {
|
||||||
case poll
|
case poll
|
||||||
case update
|
case update
|
||||||
case status
|
case status
|
||||||
|
case emojiReaction = "pleroma:emoji_reaction"
|
||||||
case unknown
|
case unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ public struct Poll: Codable, Sendable {
|
||||||
public let votesCount: Int
|
public let votesCount: Int
|
||||||
public let votersCount: Int?
|
public let votersCount: Int?
|
||||||
public let voted: Bool?
|
public let voted: Bool?
|
||||||
public let ownVotes: [Int]?
|
public let ownVotes: [Int?]?
|
||||||
public let options: [Option]
|
public let options: [Option]
|
||||||
public let emojis: [Emoji]
|
public let emojis: [Emoji]
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ public struct Poll: Codable, Sendable {
|
||||||
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
|
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
|
||||||
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
|
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
|
||||||
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
|
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
|
||||||
self.ownVotes = try container.decodeIfPresent([Int].self, forKey: .ownVotes)
|
self.ownVotes = try container.decodeIfPresent([Int?].self, forKey: .ownVotes)
|
||||||
self.options = try container.decode([Poll.Option].self, forKey: .options)
|
self.options = try container.decode([Poll.Option].self, forKey: .options)
|
||||||
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
//
|
||||||
|
// PushNotification.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/9/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
|
public struct PushNotification: Decodable {
|
||||||
|
public let accessToken: String
|
||||||
|
public let preferredLocale: String
|
||||||
|
public let notificationID: String
|
||||||
|
public let notificationType: Notification.Kind
|
||||||
|
public let icon: WebURL
|
||||||
|
public let title: String
|
||||||
|
public let body: String
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.accessToken = try container.decode(String.self, forKey: .accessToken)
|
||||||
|
self.preferredLocale = try container.decode(String.self, forKey: .preferredLocale)
|
||||||
|
// this should be a string, but mastodon encodes it as a json number
|
||||||
|
if let s = try? container.decode(String.self, forKey: .notificationID) {
|
||||||
|
self.notificationID = s
|
||||||
|
} else {
|
||||||
|
let i = try container.decode(Int.self, forKey: .notificationID)
|
||||||
|
self.notificationID = i.description
|
||||||
|
}
|
||||||
|
self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType)
|
||||||
|
self.icon = try container.decode(WebURL.self, forKey: .icon)
|
||||||
|
self.title = try container.decode(String.self, forKey: .title)
|
||||||
|
self.body = try container.decode(String.self, forKey: .body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case preferredLocale = "preferred_locale"
|
||||||
|
case notificationID = "notification_id"
|
||||||
|
case notificationType = "notification_type"
|
||||||
|
case icon
|
||||||
|
case title
|
||||||
|
case body
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,16 +9,144 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct PushSubscription: Decodable, Sendable {
|
public struct PushSubscription: Decodable, Sendable {
|
||||||
public let id: String
|
public var id: String
|
||||||
public let endpoint: URL
|
public var endpoint: URL
|
||||||
public let serverKey: String
|
public var serverKey: String
|
||||||
// TODO: WTF is this?
|
public var alerts: Alerts
|
||||||
// public let alerts
|
public var policy: Policy
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
// id is documented as being a string, but mastodon returns a json number
|
||||||
|
if let s = try? container.decode(String.self, forKey: .id) {
|
||||||
|
self.id = s
|
||||||
|
} else {
|
||||||
|
let i = try container.decode(Int.self, forKey: .id)
|
||||||
|
self.id = i.description
|
||||||
|
}
|
||||||
|
self.endpoint = try container.decode(URL.self, forKey: .endpoint)
|
||||||
|
self.serverKey = try container.decode(String.self, forKey: .serverKey)
|
||||||
|
self.alerts = try container.decode(PushSubscription.Alerts.self, forKey: .alerts)
|
||||||
|
// added in mastodon 4.1.0
|
||||||
|
self.policy = try container.decodeIfPresent(PushSubscription.Policy.self, forKey: .policy) ?? .all
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func create(endpoint: URL, publicKey: Data, authSecret: Data, alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
|
||||||
|
return Request(method: .post, path: "/api/v1/push/subscription", body: ParametersBody([
|
||||||
|
"subscription[endpoint]" => endpoint.absoluteString,
|
||||||
|
"subscription[keys][p256dh]" => publicKey.base64EncodedString(),
|
||||||
|
"subscription[keys][auth]" => authSecret.base64EncodedString(),
|
||||||
|
"data[alerts][mention]" => alerts.mention,
|
||||||
|
"data[alerts][status]" => alerts.status,
|
||||||
|
"data[alerts][reblog]" => alerts.reblog,
|
||||||
|
"data[alerts][follow]" => alerts.follow,
|
||||||
|
"data[alerts][follow_request]" => alerts.followRequest,
|
||||||
|
"data[alerts][favourite]" => alerts.favourite,
|
||||||
|
"data[alerts][poll]" => alerts.poll,
|
||||||
|
"data[alerts][update]" => alerts.update,
|
||||||
|
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
|
||||||
|
"data[policy]" => policy.rawValue,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func update(alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
|
||||||
|
return Request(method: .put, path: "/api/v1/push/subscription", body: ParametersBody([
|
||||||
|
"data[alerts][mention]" => alerts.mention,
|
||||||
|
"data[alerts][status]" => alerts.status,
|
||||||
|
"data[alerts][reblog]" => alerts.reblog,
|
||||||
|
"data[alerts][follow]" => alerts.follow,
|
||||||
|
"data[alerts][follow_request]" => alerts.followRequest,
|
||||||
|
"data[alerts][favourite]" => alerts.favourite,
|
||||||
|
"data[alerts][poll]" => alerts.poll,
|
||||||
|
"data[alerts][update]" => alerts.update,
|
||||||
|
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
|
||||||
|
"data[policy]" => policy.rawValue,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func delete() -> Request<Empty> {
|
||||||
|
return Request(method: .delete, path: "/api/v1/push/subscription")
|
||||||
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case endpoint
|
case endpoint
|
||||||
case serverKey = "server_key"
|
case serverKey = "server_key"
|
||||||
// case alerts
|
case alerts
|
||||||
|
case policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PushSubscription {
|
||||||
|
public struct Alerts: Decodable, Sendable {
|
||||||
|
public let mention: Bool
|
||||||
|
public let status: Bool
|
||||||
|
public let reblog: Bool
|
||||||
|
public let follow: Bool
|
||||||
|
public let followRequest: Bool
|
||||||
|
public let favourite: Bool
|
||||||
|
public let poll: Bool
|
||||||
|
public let update: Bool
|
||||||
|
public let emojiReaction: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
mention: Bool,
|
||||||
|
status: Bool,
|
||||||
|
reblog: Bool,
|
||||||
|
follow: Bool,
|
||||||
|
followRequest: Bool,
|
||||||
|
favourite: Bool,
|
||||||
|
poll: Bool,
|
||||||
|
update: Bool,
|
||||||
|
emojiReaction: Bool
|
||||||
|
) {
|
||||||
|
self.mention = mention
|
||||||
|
self.status = status
|
||||||
|
self.reblog = reblog
|
||||||
|
self.follow = follow
|
||||||
|
self.followRequest = followRequest
|
||||||
|
self.favourite = favourite
|
||||||
|
self.poll = poll
|
||||||
|
self.update = update
|
||||||
|
self.emojiReaction = emojiReaction
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let container: KeyedDecodingContainer<PushSubscription.Alerts.CodingKeys> = try decoder.container(keyedBy: PushSubscription.Alerts.CodingKeys.self)
|
||||||
|
self.mention = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.mention)
|
||||||
|
// status added in mastodon 3.3.0
|
||||||
|
self.status = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.status) ?? false
|
||||||
|
self.reblog = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.reblog)
|
||||||
|
self.follow = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.follow)
|
||||||
|
// follow_request added in 3.1.0
|
||||||
|
self.followRequest = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.followRequest) ?? false
|
||||||
|
self.favourite = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.favourite)
|
||||||
|
self.poll = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.poll)
|
||||||
|
// update added in mastodon 3.5.0
|
||||||
|
self.update = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.update) ?? false
|
||||||
|
// pleroma/akkoma only
|
||||||
|
self.emojiReaction = try container.decodeIfPresent(Bool.self, forKey: .emojiReaction) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case mention
|
||||||
|
case status
|
||||||
|
case reblog
|
||||||
|
case follow
|
||||||
|
case followRequest = "follow_request"
|
||||||
|
case favourite
|
||||||
|
case poll
|
||||||
|
case update
|
||||||
|
case emojiReaction = "pleroma:emoji_reaction"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PushSubscription {
|
||||||
|
public enum Policy: String, Decodable, Sendable {
|
||||||
|
case all
|
||||||
|
case followed
|
||||||
|
case followers
|
||||||
|
case none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,10 +27,13 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
|
||||||
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
||||||
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
||||||
self.muting = try container.decode(Bool.self, forKey: .muting)
|
self.muting = try container.decode(Bool.self, forKey: .muting)
|
||||||
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
|
// not supported on pixelfed
|
||||||
|
self.mutingNotifications = try container.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false
|
||||||
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
||||||
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
|
// not supported on pixelfed
|
||||||
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
|
self.domainBlocking = try container.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false
|
||||||
|
// not supported on pixelfed
|
||||||
|
self.showingReblogs = try container.decodeIfPresent(Bool.self, forKey: .showingReblogs) ?? true
|
||||||
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ public enum Scope: String, Sendable {
|
||||||
case read
|
case read
|
||||||
case write
|
case write
|
||||||
case follow
|
case follow
|
||||||
|
case push
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == Scope {
|
extension Array where Element == Scope {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
||||||
|
|
||||||
public struct SearchResults: Decodable, Sendable {
|
public struct SearchResults: Decodable, Sendable {
|
||||||
public let accounts: [Account]
|
public let accounts: [Account]
|
||||||
public let statuses: [Status]
|
public let statuses: [TryDecode<Status>]
|
||||||
public let hashtags: [Hashtag]
|
public let hashtags: [Hashtag]
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|
|
@ -46,6 +46,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
public let localOnly: Bool?
|
public let localOnly: Bool?
|
||||||
public let editedAt: Date?
|
public let editedAt: Date?
|
||||||
|
|
||||||
|
public let pleromaExtras: PleromaExtras?
|
||||||
|
|
||||||
public var applicationName: String? { application?.name }
|
public var applicationName: String? { application?.name }
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
|
@ -98,6 +100,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
||||||
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
||||||
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
||||||
|
|
||||||
|
self.pleromaExtras = try container.decodeIfPresent(PleromaExtras.self, forKey: .pleromaExtras)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||||
|
@ -120,6 +124,12 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func getReactions(_ statusID: String, emoji: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
|
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reactions/\(emoji)")
|
||||||
|
request.range = range
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
public static func delete(_ statusID: String) -> Request<Empty> {
|
public static func delete(_ statusID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
|
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
|
||||||
}
|
}
|
||||||
|
@ -212,7 +222,15 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
case poll
|
case poll
|
||||||
case localOnly = "local_only"
|
case localOnly = "local_only"
|
||||||
case editedAt = "edited_at"
|
case editedAt = "edited_at"
|
||||||
|
|
||||||
|
case pleromaExtras = "pleroma"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Status: Identifiable {}
|
extension Status: Identifiable {}
|
||||||
|
|
||||||
|
extension Status {
|
||||||
|
public struct PleromaExtras: Decodable, Sendable {
|
||||||
|
public let context: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -32,8 +32,8 @@ extension Timeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(range: RequestRange) -> Request<[Status]> {
|
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
|
||||||
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
|
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
|
||||||
if case .public(true) = self {
|
if case .public(true) = self {
|
||||||
request.queryParameters.append("local" => true)
|
request.queryParameters.append("local" => true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,26 +7,53 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct TimelineMarkers: Decodable, Sendable {
|
public struct TimelineMarkers {
|
||||||
public let home: Marker?
|
private init() {}
|
||||||
public let notifications: Marker?
|
|
||||||
|
|
||||||
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> {
|
public static func request<T: TimelineMarkerType>(timeline: T) -> Request<TimelineMarker<T.Payload>> {
|
||||||
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue))
|
Request(method: .get, path: "/api/v1/markers", queryParameters: ["timeline[]" => T.name])
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> {
|
public static func update<T: TimelineMarkerType>(timeline: T, lastReadID: String) -> Request<Empty> {
|
||||||
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
||||||
"\(timeline.rawValue)[last_read_id]" => lastReadID,
|
"\(T.name)[last_read_id]" => lastReadID
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Timeline: String {
|
|
||||||
case home
|
|
||||||
case notifications
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Marker: Decodable, Sendable {
|
public struct TimelineMarker<Payload: TimelineMarkerTypePayload>: Decodable, Sendable {
|
||||||
|
let payload: Payload
|
||||||
|
|
||||||
|
public var lastReadID: String {
|
||||||
|
payload.payload.lastReadID
|
||||||
|
}
|
||||||
|
public var version: Int {
|
||||||
|
payload.payload.version
|
||||||
|
}
|
||||||
|
public var updatedAt: Date {
|
||||||
|
payload.payload.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
self.payload = try Payload(from: decoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol TimelineMarkerTypePayload: Decodable, Sendable {
|
||||||
|
var payload: MarkerPayload { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HomeMarkerPayload: TimelineMarkerTypePayload {
|
||||||
|
public var home: MarkerPayload
|
||||||
|
public var payload: MarkerPayload { home }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NotificationsMarkerPayload: TimelineMarkerTypePayload {
|
||||||
|
public var notifications: MarkerPayload
|
||||||
|
public var payload: MarkerPayload { notifications }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MarkerPayload: Decodable, Sendable {
|
||||||
public let lastReadID: String
|
public let lastReadID: String
|
||||||
public let version: Int
|
public let version: Int
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
|
@ -37,4 +64,26 @@ public struct TimelineMarkers: Decodable, Sendable {
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public protocol TimelineMarkerType {
|
||||||
|
static var name: String { get }
|
||||||
|
associatedtype Payload: TimelineMarkerTypePayload
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimelineMarkerType where Self == HomeMarker {
|
||||||
|
public static var home: Self { .init() }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimelineMarkerType where Self == NotificationsMarker {
|
||||||
|
public static var notifications: Self { .init() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HomeMarker: TimelineMarkerType {
|
||||||
|
public typealias Payload = HomeMarkerPayload
|
||||||
|
public static var name: String { "home" }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NotificationsMarker: TimelineMarkerType {
|
||||||
|
public typealias Payload = NotificationsMarkerPayload
|
||||||
|
public static var name: String { "notifications" }
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,17 +7,18 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||||
public private(set) var notifications: [Notification]
|
public private(set) var notifications: [Notification]
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Notification.Kind
|
public let kind: Kind
|
||||||
|
|
||||||
public init?(notifications: [Notification]) {
|
public init?(notifications: [Notification], kind: Kind) {
|
||||||
guard !notifications.isEmpty else { return nil }
|
guard !notifications.isEmpty else { return nil }
|
||||||
self.notifications = notifications
|
self.notifications = notifications
|
||||||
self.id = notifications.first!.id
|
self.id = notifications.first!.id
|
||||||
self.kind = notifications.first!.kind
|
self.kind = kind
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
||||||
|
@ -44,30 +45,61 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||||
notifications.append(contentsOf: group.notifications)
|
notifications.append(contentsOf: group.notifications)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func groupKind(for notification: Notification) -> Kind {
|
||||||
|
switch notification.kind {
|
||||||
|
case .mention:
|
||||||
|
return .mention
|
||||||
|
case .reblog:
|
||||||
|
return .reblog
|
||||||
|
case .favourite:
|
||||||
|
return .favourite
|
||||||
|
case .follow:
|
||||||
|
return .follow
|
||||||
|
case .followRequest:
|
||||||
|
return .followRequest
|
||||||
|
case .poll:
|
||||||
|
return .poll
|
||||||
|
case .update:
|
||||||
|
return .update
|
||||||
|
case .status:
|
||||||
|
return .status
|
||||||
|
case .emojiReaction:
|
||||||
|
if let emoji = notification.emoji {
|
||||||
|
return .emojiReaction(emoji, notification.emojiURL)
|
||||||
|
} else {
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
case .unknown:
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||||
var groups = [NotificationGroup]()
|
var groups = [NotificationGroup]()
|
||||||
for notification in notifications {
|
for notification in notifications {
|
||||||
|
let groupKind = groupKind(for: notification)
|
||||||
|
|
||||||
if allowedTypes.contains(notification.kind) {
|
if allowedTypes.contains(notification.kind) {
|
||||||
if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) {
|
if let lastGroup = groups.last, canMerge(notification: notification, kind: groupKind, into: lastGroup) {
|
||||||
groups[groups.count - 1].append(notification)
|
groups[groups.count - 1].append(notification)
|
||||||
continue
|
continue
|
||||||
} else if groups.count >= 2 {
|
} else if groups.count >= 2 {
|
||||||
let secondToLastGroup = groups[groups.count - 2]
|
let secondToLastGroup = groups[groups.count - 2]
|
||||||
if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) {
|
if allowedTypes.contains(notification.kind), canMerge(notification: notification, kind: groupKind, into: secondToLastGroup) {
|
||||||
groups[groups.count - 2].append(notification)
|
groups[groups.count - 2].append(notification)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.append(NotificationGroup(notifications: [notification])!)
|
groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
|
private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
|
||||||
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
return kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||||
|
@ -82,21 +114,21 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||||
var second = second
|
var second = second
|
||||||
merged.reserveCapacity(second.count)
|
merged.reserveCapacity(second.count)
|
||||||
while let firstGroupFromSecond = second.first,
|
while let firstGroupFromSecond = second.first,
|
||||||
allowedTypes.contains(firstGroupFromSecond.kind) {
|
allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
|
||||||
|
|
||||||
second.removeFirst()
|
second.removeFirst()
|
||||||
|
|
||||||
guard let lastGroup = merged.last,
|
guard let lastGroup = merged.last,
|
||||||
allowedTypes.contains(lastGroup.kind) else {
|
allowedTypes.contains(lastGroup.kind.notificationKind) else {
|
||||||
merged.append(firstGroupFromSecond)
|
merged.append(firstGroupFromSecond)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) {
|
if canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: lastGroup) {
|
||||||
merged[merged.count - 1].append(group: firstGroupFromSecond)
|
merged[merged.count - 1].append(group: firstGroupFromSecond)
|
||||||
} else if merged.count >= 2 {
|
} else if merged.count >= 2 {
|
||||||
let secondToLastGroup = merged[merged.count - 2]
|
let secondToLastGroup = merged[merged.count - 2]
|
||||||
if allowedTypes.contains(secondToLastGroup.kind), canMerge(notification: firstGroupFromSecond.notifications.first!, into: secondToLastGroup) {
|
if allowedTypes.contains(secondToLastGroup.kind.notificationKind), canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: secondToLastGroup) {
|
||||||
merged[merged.count - 2].append(group: firstGroupFromSecond)
|
merged[merged.count - 2].append(group: firstGroupFromSecond)
|
||||||
} else {
|
} else {
|
||||||
merged.append(firstGroupFromSecond)
|
merged.append(firstGroupFromSecond)
|
||||||
|
@ -109,4 +141,42 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum Kind: Sendable, Equatable {
|
||||||
|
case mention
|
||||||
|
case reblog
|
||||||
|
case favourite
|
||||||
|
case follow
|
||||||
|
case followRequest
|
||||||
|
case poll
|
||||||
|
case update
|
||||||
|
case status
|
||||||
|
case emojiReaction(String, WebURL?)
|
||||||
|
case unknown
|
||||||
|
|
||||||
|
var notificationKind: Notification.Kind {
|
||||||
|
switch self {
|
||||||
|
case .mention:
|
||||||
|
.mention
|
||||||
|
case .reblog:
|
||||||
|
.reblog
|
||||||
|
case .favourite:
|
||||||
|
.favourite
|
||||||
|
case .follow:
|
||||||
|
.follow
|
||||||
|
case .followRequest:
|
||||||
|
.followRequest
|
||||||
|
case .poll:
|
||||||
|
.poll
|
||||||
|
case .update:
|
||||||
|
.update
|
||||||
|
case .status:
|
||||||
|
.status
|
||||||
|
case .emojiReaction(_, _):
|
||||||
|
.emojiReaction
|
||||||
|
case .unknown:
|
||||||
|
.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// TryDecode.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 6/8/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum TryDecode<T: Decodable>: Decodable {
|
||||||
|
case error(String)
|
||||||
|
case value(T)
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
do {
|
||||||
|
self = .value(try T(from: decoder))
|
||||||
|
} catch {
|
||||||
|
self = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var value: T? {
|
||||||
|
if case .value(let value) = self {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TryDecode: Sendable where T: Sendable {
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1530"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "PushNotifications"
|
||||||
|
BuildableName = "PushNotifications"
|
||||||
|
BlueprintName = "PushNotifications"
|
||||||
|
ReferencedContainer = "container:">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "PushNotifications"
|
||||||
|
BuildableName = "PushNotifications"
|
||||||
|
BlueprintName = "PushNotifications"
|
||||||
|
ReferencedContainer = "container:">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
|
@ -0,0 +1,39 @@
|
||||||
|
// 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: "PushNotifications",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v16),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "PushNotifications",
|
||||||
|
targets: ["PushNotifications"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../UserAccounts"),
|
||||||
|
.package(path: "../Pachyderm"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
|
.target(
|
||||||
|
name: "PushNotifications",
|
||||||
|
dependencies: ["UserAccounts", "Pachyderm"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "PushNotificationsTests",
|
||||||
|
dependencies: ["PushNotifications"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,47 @@
|
||||||
|
//
|
||||||
|
// DisabledPushManager.swift
|
||||||
|
// PushNotifications
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UserAccounts
|
||||||
|
|
||||||
|
class DisabledPushManager: _PushManager {
|
||||||
|
var enabled: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscriptions: [PushSubscription] {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
|
||||||
|
throw Disabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSubscription(account: UserAccountInfo) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
||||||
|
}
|
||||||
|
func didFailToRegisterForRemoteNotifications(error: any Error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Disabled: LocalizedError {
|
||||||
|
var errorDescription: String? {
|
||||||
|
"Push notifications disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
//
|
||||||
|
// PushManager.swift
|
||||||
|
// PushNotifications
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Pachyderm
|
||||||
|
import UserAccounts
|
||||||
|
|
||||||
|
public struct PushManager {
|
||||||
|
@MainActor
|
||||||
|
public static let shared = createPushManager()
|
||||||
|
|
||||||
|
public static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager")
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public static var captureError: ((any Error) -> Void)?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func createPushManager() -> any _PushManager {
|
||||||
|
guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any],
|
||||||
|
let host = info["PushProxyHost"] as? String,
|
||||||
|
!host.isEmpty else {
|
||||||
|
logger.debug("Missing proxy info, push disabled")
|
||||||
|
return DisabledPushManager()
|
||||||
|
}
|
||||||
|
var endpoint = URLComponents()
|
||||||
|
endpoint.scheme = "https"
|
||||||
|
endpoint.host = host
|
||||||
|
let url = endpoint.url!
|
||||||
|
logger.debug("Push notifications enabled with proxy \(url.absoluteString, privacy: .public)")
|
||||||
|
return PushManagerImpl(endpoint: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public protocol _PushManager {
|
||||||
|
var enabled: Bool { get }
|
||||||
|
|
||||||
|
var subscriptions: [PushSubscription] { get }
|
||||||
|
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription
|
||||||
|
func removeSubscription(account: UserAccountInfo)
|
||||||
|
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy)
|
||||||
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription?
|
||||||
|
|
||||||
|
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async
|
||||||
|
|
||||||
|
func didRegisterForRemoteNotifications(deviceToken: Data)
|
||||||
|
func didFailToRegisterForRemoteNotifications(error: any Error)
|
||||||
|
}
|
|
@ -0,0 +1,203 @@
|
||||||
|
//
|
||||||
|
// PushManagerImpl.swift
|
||||||
|
// PushNotifications
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import UserAccounts
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
class PushManagerImpl: _PushManager {
|
||||||
|
private let endpoint: URL
|
||||||
|
|
||||||
|
var enabled: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var apnsEnvironment: String {
|
||||||
|
#if DEBUG
|
||||||
|
"development"
|
||||||
|
#else
|
||||||
|
"production"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
|
||||||
|
|
||||||
|
private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
|
||||||
|
public private(set) var subscriptions: [PushSubscription] {
|
||||||
|
get {
|
||||||
|
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
|
||||||
|
return array.compactMap(PushSubscription.init(defaultsDict:))
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
defaults.setValue(newValue.map(\.defaultsDict), forKey: "PushSubscriptions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(endpoint: URL) {
|
||||||
|
self.endpoint = endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
|
||||||
|
if let existing = pushSubscription(account: account) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let key = P256.KeyAgreement.PrivateKey()
|
||||||
|
var authSecret = Data(count: 16)
|
||||||
|
let res = authSecret.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
guard res == errSecSuccess else {
|
||||||
|
throw CreateSubscriptionError.generatingAuthSecret(res)
|
||||||
|
}
|
||||||
|
let token = try await getDeviceToken()
|
||||||
|
let subscription = PushSubscription(
|
||||||
|
accountID: account.id,
|
||||||
|
endpoint: endpointURL(deviceToken: token, accountID: account.id),
|
||||||
|
secretKey: key,
|
||||||
|
authSecret: authSecret,
|
||||||
|
alerts: [],
|
||||||
|
policy: .all
|
||||||
|
)
|
||||||
|
subscriptions.append(subscription)
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endpointURL(deviceToken: Data, accountID: String) -> URL {
|
||||||
|
var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
||||||
|
let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
|
||||||
|
endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)"
|
||||||
|
return endpoint.url!
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSubscription(account: UserAccountInfo) {
|
||||||
|
subscriptions.removeAll { $0.accountID == account.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
|
||||||
|
guard let index = subscriptions.firstIndex(where: { $0.accountID == account.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var copy = subscriptions[index]
|
||||||
|
copy.alerts = alerts
|
||||||
|
copy.policy = policy
|
||||||
|
subscriptions[index] = copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
||||||
|
subscriptions.first { $0.accountID == account.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
|
||||||
|
let subscriptions = self.subscriptions
|
||||||
|
guard !subscriptions.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let token = try await getDeviceToken()
|
||||||
|
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
|
||||||
|
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
|
||||||
|
guard newEndpoint != $0.endpoint else {
|
||||||
|
PushManager.logger.debug("Skipping update of push subscription with endpoint \($0.endpoint, privacy: .public)")
|
||||||
|
return $0
|
||||||
|
}
|
||||||
|
var copy = $0
|
||||||
|
copy.endpoint = newEndpoint
|
||||||
|
if await updateSubscription(copy) {
|
||||||
|
return copy
|
||||||
|
} else {
|
||||||
|
return $0
|
||||||
|
}
|
||||||
|
}.reduce(into: [], { partialResult, el in
|
||||||
|
partialResult.append(el)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
|
||||||
|
PushManager.captureError?(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getDeviceToken() async throws -> Data {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
remoteNotificationsRegistrationContinuation = continuation
|
||||||
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
||||||
|
remoteNotificationsRegistrationContinuation?.resume(returning: deviceToken)
|
||||||
|
remoteNotificationsRegistrationContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func didFailToRegisterForRemoteNotifications(error: any Error) {
|
||||||
|
remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error))
|
||||||
|
remoteNotificationsRegistrationContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PushRegistrationError: LocalizedError {
|
||||||
|
case alreadyRegistering
|
||||||
|
case registeringForRemoteNotifications(any Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .alreadyRegistering:
|
||||||
|
"Already registering"
|
||||||
|
case .registeringForRemoteNotifications(let error):
|
||||||
|
"Remote notifications: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CreateSubscriptionError: LocalizedError {
|
||||||
|
case generatingAuthSecret(OSStatus)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .generatingAuthSecret(let code):
|
||||||
|
"Generating auth secret: \(code)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
func hexEncodedString() -> String {
|
||||||
|
String(unsafeUninitializedCapacity: count * 2) { buffer in
|
||||||
|
let chars = Array("0123456789ABCDEF".utf8)
|
||||||
|
for (i, x) in enumerated() {
|
||||||
|
let (upper, lower) = x.quotientAndRemainder(dividingBy: 16)
|
||||||
|
buffer[i * 2] = chars[Int(upper)]
|
||||||
|
buffer[i * 2 + 1] = chars[Int(lower)]
|
||||||
|
}
|
||||||
|
return count * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AsyncSequenceAdaptor<S: Sequence>: AsyncSequence {
|
||||||
|
typealias Element = S.Element
|
||||||
|
|
||||||
|
let base: S
|
||||||
|
|
||||||
|
init(wrapping base: S) {
|
||||||
|
self.base = base
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAsyncIterator() -> AsyncIterator {
|
||||||
|
AsyncIterator(base: base.makeIterator())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AsyncIterator: AsyncIteratorProtocol {
|
||||||
|
var base: S.Iterator
|
||||||
|
|
||||||
|
mutating func next() async -> Element? {
|
||||||
|
base.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,81 @@
|
||||||
|
//
|
||||||
|
// PushSubscription.swift
|
||||||
|
// PushNotifications
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
public struct PushSubscription {
|
||||||
|
public let accountID: String
|
||||||
|
public internal(set) var endpoint: URL
|
||||||
|
public let secretKey: P256.KeyAgreement.PrivateKey
|
||||||
|
public let authSecret: Data
|
||||||
|
public var alerts: Alerts
|
||||||
|
public var policy: Policy
|
||||||
|
|
||||||
|
var defaultsDict: [String: Any] {
|
||||||
|
[
|
||||||
|
"accountID": accountID,
|
||||||
|
"endpoint": endpoint.absoluteString,
|
||||||
|
"secretKey": secretKey.rawRepresentation,
|
||||||
|
"authSecret": authSecret,
|
||||||
|
"alerts": alerts.rawValue,
|
||||||
|
"policy": policy.rawValue
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(defaultsDict: [String: Any]) {
|
||||||
|
guard let accountID = defaultsDict["accountID"] as? String,
|
||||||
|
let endpoint = (defaultsDict["endpoint"] as? String).flatMap(URL.init(string:)),
|
||||||
|
let secretKey = (defaultsDict["secretKey"] as? Data).flatMap({ try? P256.KeyAgreement.PrivateKey(rawRepresentation: $0) }),
|
||||||
|
let authSecret = defaultsDict["authSecret"] as? Data,
|
||||||
|
let alerts = defaultsDict["alerts"] as? Int,
|
||||||
|
let policy = (defaultsDict["policy"] as? String).flatMap(Policy.init(rawValue:)) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.accountID = accountID
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.secretKey = secretKey
|
||||||
|
self.authSecret = authSecret
|
||||||
|
self.alerts = Alerts(rawValue: alerts)
|
||||||
|
self.policy = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
init(accountID: String, endpoint: URL, secretKey: P256.KeyAgreement.PrivateKey, authSecret: Data, alerts: Alerts, policy: Policy) {
|
||||||
|
self.accountID = accountID
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.secretKey = secretKey
|
||||||
|
self.authSecret = authSecret
|
||||||
|
self.alerts = alerts
|
||||||
|
self.policy = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Policy: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
case all, followed, followers
|
||||||
|
|
||||||
|
public var id: some Hashable {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Alerts: OptionSet, Hashable, Sendable {
|
||||||
|
public static let mention = Alerts(rawValue: 1 << 0)
|
||||||
|
public static let status = Alerts(rawValue: 1 << 1)
|
||||||
|
public static let reblog = Alerts(rawValue: 1 << 2)
|
||||||
|
public static let follow = Alerts(rawValue: 1 << 3)
|
||||||
|
public static let followRequest = Alerts(rawValue: 1 << 4)
|
||||||
|
public static let favorite = Alerts(rawValue: 1 << 5)
|
||||||
|
public static let poll = Alerts(rawValue: 1 << 6)
|
||||||
|
public static let update = Alerts(rawValue: 1 << 7)
|
||||||
|
public static let emojiReaction = Alerts(rawValue: 1 << 8)
|
||||||
|
|
||||||
|
public let rawValue: Int
|
||||||
|
|
||||||
|
public init(rawValue: Int) {
|
||||||
|
self.rawValue = rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import PushNotifications
|
||||||
|
|
||||||
|
final class PushNotificationsTests: XCTestCase {
|
||||||
|
func testExample() throws {
|
||||||
|
// XCTest Documentation
|
||||||
|
// https://developer.apple.com/documentation/xctest
|
||||||
|
|
||||||
|
// Defining Test Cases and Test Methods
|
||||||
|
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 5.7
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "TTTKit",
|
name: "TTTKit",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -23,9 +23,15 @@ let package = Package(
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "TTTKit",
|
name: "TTTKit",
|
||||||
dependencies: []),
|
dependencies: [],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "TTTKitTests",
|
name: "TTTKitTests",
|
||||||
dependencies: ["TTTKit"]),
|
dependencies: ["TTTKit"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 5.7
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "TuskerComponents",
|
name: "TuskerComponents",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -23,7 +23,10 @@ let package = Package(
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "TuskerComponents",
|
name: "TuskerComponents",
|
||||||
dependencies: []),
|
dependencies: [],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]),
|
||||||
// .testTarget(
|
// .testTarget(
|
||||||
// name: "TuskerComponentsTests",
|
// name: "TuskerComponentsTests",
|
||||||
// dependencies: ["TuskerComponents"]),
|
// dependencies: ["TuskerComponents"]),
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
//
|
||||||
|
// AsyncPicker.swift
|
||||||
|
// TuskerComponents
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/9/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct AsyncPicker<V: Hashable, Content: View>: View {
|
||||||
|
let titleKey: LocalizedStringKey
|
||||||
|
let alignment: Alignment
|
||||||
|
@Binding var value: V
|
||||||
|
let onChange: (V) async -> Bool
|
||||||
|
let content: Content
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
public init(_ titleKey: LocalizedStringKey, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
|
||||||
|
self.titleKey = titleKey
|
||||||
|
self.alignment = alignment
|
||||||
|
self._value = value
|
||||||
|
self.onChange = onChange
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
LabeledContent(titleKey) {
|
||||||
|
picker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var picker: some View {
|
||||||
|
ZStack(alignment: alignment) {
|
||||||
|
Picker(titleKey, selection: Binding(get: {
|
||||||
|
value
|
||||||
|
}, set: { newValue in
|
||||||
|
let oldValue = value
|
||||||
|
value = newValue
|
||||||
|
isLoading = true
|
||||||
|
Task {
|
||||||
|
let operationCompleted = await onChange(newValue)
|
||||||
|
if !operationCompleted {
|
||||||
|
value = oldValue
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
})) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.opacity(isLoading ? 0 : 1)
|
||||||
|
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
@State var value = 0
|
||||||
|
return AsyncPicker("", value: $value) { _ in
|
||||||
|
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||||
|
return true
|
||||||
|
} content: {
|
||||||
|
ForEach(0..<10) {
|
||||||
|
Text("\($0)").tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
//
|
||||||
|
// AsyncToggle.swift
|
||||||
|
// TuskerComponents
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/7/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct AsyncToggle: View {
|
||||||
|
let titleKey: LocalizedStringKey
|
||||||
|
@Binding var mode: Mode
|
||||||
|
let onChange: (Bool) async -> Bool
|
||||||
|
|
||||||
|
public init(_ titleKey: LocalizedStringKey, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
|
||||||
|
self.titleKey = titleKey
|
||||||
|
self._mode = mode
|
||||||
|
self.onChange = onChange
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
LabeledContent(titleKey) {
|
||||||
|
toggleOrSpinner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var toggleOrSpinner: some View {
|
||||||
|
ZStack {
|
||||||
|
Toggle(titleKey, isOn: Binding {
|
||||||
|
mode == .on
|
||||||
|
} set: { newValue in
|
||||||
|
mode = .loading
|
||||||
|
Task {
|
||||||
|
let operationCompleted = await onChange(newValue)
|
||||||
|
if operationCompleted {
|
||||||
|
mode = newValue ? .on : .off
|
||||||
|
} else {
|
||||||
|
mode = newValue ? .off : .on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.labelsHidden()
|
||||||
|
.opacity(mode == .loading ? 0 : 1)
|
||||||
|
|
||||||
|
if mode == .loading {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Mode {
|
||||||
|
case off
|
||||||
|
case loading
|
||||||
|
case on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
@State var mode = AsyncToggle.Mode.on
|
||||||
|
return AsyncToggle("", mode: $mode) { _ in
|
||||||
|
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
//
|
//
|
||||||
// FuzzyMatcher.swift
|
// FuzzyMatcher.swift
|
||||||
// ComposeUI
|
// TuskerComponents
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 10/10/20.
|
// Created by Shadowfacts on 10/10/20.
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct FuzzyMatcher {
|
public struct FuzzyMatcher {
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ struct FuzzyMatcher {
|
||||||
/// +2 points for every char in `pattern` that occurs in `str` sequentially
|
/// +2 points for every char in `pattern` that occurs in `str` sequentially
|
||||||
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
|
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
|
||||||
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
|
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
|
||||||
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
public static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||||
let pattern = pattern.lowercased()
|
let pattern = pattern.lowercased()
|
||||||
let str = str.lowercased()
|
let str = str.lowercased()
|
||||||
|
|
|
@ -47,9 +47,7 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
||||||
|
|
||||||
private func makeConfiguration() -> UIButton.Configuration {
|
private func makeConfiguration() -> UIButton.Configuration {
|
||||||
var config = UIButton.Configuration.borderless()
|
var config = UIButton.Configuration.borderless()
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
config.indicator = .popup
|
config.indicator = .popup
|
||||||
}
|
|
||||||
if buttonStyle.hasIcon {
|
if buttonStyle.hasIcon {
|
||||||
config.image = selectedOption.image
|
config.image = selectedOption.image
|
||||||
}
|
}
|
||||||
|
|
|
@ -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" : "01ad5a103d14839a68c55ee556513e5939008e9e"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 5.8
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "TuskerPreferences",
|
name: "TuskerPreferences",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
@ -22,7 +22,17 @@ let package = Package(
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
.target(
|
.target(
|
||||||
name: "TuskerPreferences",
|
name: "TuskerPreferences",
|
||||||
dependencies: ["Pachyderm"]
|
dependencies: ["Pachyderm"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "TuskerPreferencesTests",
|
||||||
|
dependencies: ["TuskerPreferences"],
|
||||||
|
swiftSettings: [
|
||||||
|
.swiftLanguageMode(.v5)
|
||||||
|
]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,282 @@
|
||||||
|
//
|
||||||
|
// Coding.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
private protocol PreferenceProtocol {
|
||||||
|
associatedtype Key: PreferenceKey
|
||||||
|
var storedValue: Key.Value? { get }
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Preference: PreferenceProtocol {
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PreferenceCoding<Wrapped: Codable>: Codable {
|
||||||
|
let wrapped: Wrapped
|
||||||
|
|
||||||
|
init(wrapped: Wrapped) {
|
||||||
|
self.wrapped = wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
self.wrapped = try Wrapped(from: PreferenceDecoder(wrapped: decoder))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
try wrapped.encode(to: PreferenceEncoder(wrapped: encoder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreferenceDecoder: Decoder {
|
||||||
|
let wrapped: any Decoder
|
||||||
|
|
||||||
|
var codingPath: [any CodingKey] {
|
||||||
|
wrapped.codingPath
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo: [CodingUserInfoKey : Any] {
|
||||||
|
wrapped.userInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> where Key : CodingKey {
|
||||||
|
KeyedDecodingContainer(PreferenceDecodingContainer(wrapped: try wrapped.container(keyedBy: type)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func unkeyedContainer() throws -> any UnkeyedDecodingContainer {
|
||||||
|
throw Error.onlyKeyedContainerSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
func singleValueContainer() throws -> any SingleValueDecodingContainer {
|
||||||
|
throw Error.onlyKeyedContainerSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Error: Swift.Error {
|
||||||
|
case onlyKeyedContainerSupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreferenceDecodingContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
|
||||||
|
let wrapped: KeyedDecodingContainer<Key>
|
||||||
|
|
||||||
|
var codingPath: [any CodingKey] {
|
||||||
|
wrapped.codingPath
|
||||||
|
}
|
||||||
|
|
||||||
|
var allKeys: [Key] {
|
||||||
|
wrapped.allKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(_ key: Key) -> Bool {
|
||||||
|
wrapped.contains(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeNil(forKey key: Key) throws -> Bool {
|
||||||
|
try wrapped.decodeNil(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: String.Type, forKey key: Key) throws -> String {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: Double.Type, forKey key: Key) throws -> Double {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: Int.Type, forKey key: Key) throws -> Int {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 {
|
||||||
|
try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode<T>(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable {
|
||||||
|
if let type = type as? any PreferenceProtocol.Type,
|
||||||
|
!contains(key) {
|
||||||
|
func makePreference<P: PreferenceProtocol>(_: P.Type) -> T {
|
||||||
|
P() as! T
|
||||||
|
}
|
||||||
|
return _openExistential(type, do: makePreference)
|
||||||
|
}
|
||||||
|
return try wrapped.decode(type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer<NestedKey> where NestedKey : CodingKey {
|
||||||
|
try wrapped.nestedContainer(keyedBy: type, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer {
|
||||||
|
try wrapped.nestedUnkeyedContainer(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func superDecoder() throws -> any Decoder {
|
||||||
|
try wrapped.superDecoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
func superDecoder(forKey key: Key) throws -> any Decoder {
|
||||||
|
try wrapped.superDecoder(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreferenceEncoder: Encoder {
|
||||||
|
let wrapped: any Encoder
|
||||||
|
|
||||||
|
var codingPath: [any CodingKey] {
|
||||||
|
wrapped.codingPath
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo: [CodingUserInfoKey : Any] {
|
||||||
|
wrapped.userInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey {
|
||||||
|
KeyedEncodingContainer(PreferenceEncodingContainer(wrapped: wrapped.container(keyedBy: type)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func unkeyedContainer() -> any UnkeyedEncodingContainer {
|
||||||
|
fatalError("Only keyed containers supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func singleValueContainer() -> any SingleValueEncodingContainer {
|
||||||
|
fatalError("Only keyed containers supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreferenceEncodingContainer<Key: CodingKey>: KeyedEncodingContainerProtocol {
|
||||||
|
var wrapped: KeyedEncodingContainer<Key>
|
||||||
|
|
||||||
|
var codingPath: [any CodingKey] {
|
||||||
|
wrapped.codingPath
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encodeNil(forKey key: Key) throws {
|
||||||
|
try wrapped.encodeNil(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: Bool, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: String, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: Double, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: Float, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: Int, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: Int8, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: Int16, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: Int32, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: Int64, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: UInt, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: UInt8, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: UInt16, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: UInt32, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode(_ value: UInt64, forKey key: Key) throws {
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func encode<T>(_ value: T, forKey key: Key) throws where T : Encodable {
|
||||||
|
if let value = value as? any PreferenceProtocol,
|
||||||
|
value.storedValue == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try wrapped.encode(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
|
||||||
|
wrapped.nestedContainer(keyedBy: keyType, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer {
|
||||||
|
wrapped.nestedUnkeyedContainer(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func superEncoder() -> any Encoder {
|
||||||
|
wrapped.superEncoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func superEncoder(forKey key: Key) -> any Encoder {
|
||||||
|
wrapped.superEncoder(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
//
|
||||||
|
// AdvancedKeys.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
struct StatusContentTypeKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: StatusContentType { .plain }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FeatureFlagsKey: MigratablePreferenceKey, CustomCodablePreferenceKey {
|
||||||
|
static var defaultValue: Set<FeatureFlag> { [] }
|
||||||
|
|
||||||
|
static func encode(value: Set<FeatureFlag>, to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(value.map(\.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from decoder: any Decoder) throws -> Set<FeatureFlag>? {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let names = try container.decode([String].self)
|
||||||
|
return Set(names.compactMap(FeatureFlag.init(rawValue:)))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
//
|
||||||
|
// AppearanceKeys.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public struct ThemeKey: MigratablePreferenceKey {
|
||||||
|
public static var defaultValue: Theme { .unspecified }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AccentColorKey: MigratablePreferenceKey {
|
||||||
|
public static var defaultValue: AccentColor { .default }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AvatarStyleKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: AvatarStyle { .roundRect }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LeadingSwipeActionsKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: [StatusSwipeAction] { [.favorite, .reblog] }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrailingSwipeActionsKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: [StatusSwipeAction] { [.reply, .share] }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct WidescreenNavigationModeKey: MigratablePreferenceKey {
|
||||||
|
public static var defaultValue: WidescreenNavigationMode { .multiColumn }
|
||||||
|
|
||||||
|
public static func shouldMigrate(oldValue: WidescreenNavigationMode) -> Bool {
|
||||||
|
oldValue != .splitScreen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AttachmentBlurModeKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: AttachmentBlurMode { .useStatusSetting }
|
||||||
|
|
||||||
|
static func didSet(in store: PreferenceStore, newValue: AttachmentBlurMode) {
|
||||||
|
if newValue == .always {
|
||||||
|
store.blurMediaBehindContentWarning = true
|
||||||
|
} else if newValue == .never {
|
||||||
|
store.blurMediaBehindContentWarning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
//
|
||||||
|
// BehaviorKeys.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct OppositeCollapseKeywordsKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: [String] { [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConfirmReblogKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: Bool {
|
||||||
|
#if os(visionOS)
|
||||||
|
true
|
||||||
|
#else
|
||||||
|
false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimelineSyncModeKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: TimelineSyncMode { .icloud }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InAppSafariKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: Bool {
|
||||||
|
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||||
|
false
|
||||||
|
#else
|
||||||
|
if ProcessInfo.processInfo.isiOSAppOnMac {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
//
|
||||||
|
// CommonKeys.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct TrueKey: MigratablePreferenceKey {
|
||||||
|
public static var defaultValue: Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct FalseKey: MigratablePreferenceKey {
|
||||||
|
public static var defaultValue: Bool { false }
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// ComposingKeys.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PostVisibilityKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: PostVisibility { .serverDefault }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReplyVisibilityKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: ReplyVisibility { .sameAsPost }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentWarningCopyModeKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: ContentWarningCopyMode { .asIs }
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
//
|
||||||
|
// DigitalWellnessKeys.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NotificationsModeKey: MigratablePreferenceKey {
|
||||||
|
static var defaultValue: NotificationsMode { .allNotifications }
|
||||||
|
}
|
|
@ -0,0 +1,205 @@
|
||||||
|
//
|
||||||
|
// LegacyPreferences.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/28/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
public final class LegacyPreferences: Decodable {
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
public required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
||||||
|
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
|
||||||
|
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
|
||||||
|
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
|
||||||
|
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
|
||||||
|
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
||||||
|
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
||||||
|
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
|
||||||
|
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
|
||||||
|
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
|
||||||
|
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||||
|
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
|
||||||
|
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
|
||||||
|
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
|
||||||
|
|
||||||
|
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
|
||||||
|
self.defaultPostVisibility = .visibility(existing)
|
||||||
|
} else {
|
||||||
|
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
|
||||||
|
}
|
||||||
|
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
||||||
|
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||||
|
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||||
|
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||||
|
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
||||||
|
|
||||||
|
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
|
||||||
|
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
|
||||||
|
} else {
|
||||||
|
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
|
||||||
|
}
|
||||||
|
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||||
|
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||||
|
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
|
||||||
|
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
|
||||||
|
self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false
|
||||||
|
|
||||||
|
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||||
|
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||||
|
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
|
||||||
|
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
|
||||||
|
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
|
||||||
|
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
|
||||||
|
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
|
||||||
|
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
|
||||||
|
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
|
||||||
|
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
|
||||||
|
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
|
||||||
|
|
||||||
|
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
|
||||||
|
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
||||||
|
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
|
||||||
|
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
|
||||||
|
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
|
||||||
|
|
||||||
|
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
|
||||||
|
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
|
||||||
|
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
|
||||||
|
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
|
||||||
|
|
||||||
|
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
|
||||||
|
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Appearance
|
||||||
|
@Published public var theme = UIUserInterfaceStyle.unspecified
|
||||||
|
@Published public var pureBlackDarkMode = true
|
||||||
|
@Published public var accentColor = AccentColor.default
|
||||||
|
@Published public var avatarStyle = AvatarStyle.roundRect
|
||||||
|
@Published public var hideCustomEmojiInUsernames = false
|
||||||
|
@Published public var showIsStatusReplyIcon = false
|
||||||
|
@Published public var alwaysShowStatusVisibilityIcon = false
|
||||||
|
@Published public var hideActionsInTimeline = false
|
||||||
|
@Published public var showLinkPreviews = true
|
||||||
|
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
|
||||||
|
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
||||||
|
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
||||||
|
@Published public var widescreenNavigationMode = LegacyPreferences.defaultWidescreenNavigationMode
|
||||||
|
@Published public var underlineTextLinks = false
|
||||||
|
@Published public var showAttachmentsInTimeline = true
|
||||||
|
|
||||||
|
// MARK: Composing
|
||||||
|
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
||||||
|
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
||||||
|
@Published public var requireAttachmentDescriptions = false
|
||||||
|
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||||
|
@Published public var mentionReblogger = false
|
||||||
|
@Published public var useTwitterKeyboard = false
|
||||||
|
|
||||||
|
// MARK: Media
|
||||||
|
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting
|
||||||
|
@Published public var blurMediaBehindContentWarning = true
|
||||||
|
@Published public var automaticallyPlayGifs = true
|
||||||
|
@Published public var showUncroppedMediaInline = true
|
||||||
|
@Published public var showAttachmentBadges = true
|
||||||
|
@Published public var attachmentAltBadgeInverted = false
|
||||||
|
|
||||||
|
// MARK: Behavior
|
||||||
|
@Published public var openLinksInApps = true
|
||||||
|
@Published public var useInAppSafari = true
|
||||||
|
@Published public var inAppSafariAutomaticReaderMode = false
|
||||||
|
@Published public var expandAllContentWarnings = false
|
||||||
|
@Published public var collapseLongPosts = true
|
||||||
|
@Published public var oppositeCollapseKeywords: [String] = []
|
||||||
|
@Published public var confirmBeforeReblog = false
|
||||||
|
@Published public var timelineStateRestoration = true
|
||||||
|
@Published public var timelineSyncMode = TimelineSyncMode.icloud
|
||||||
|
@Published public var hideReblogsInTimelines = false
|
||||||
|
@Published public var hideRepliesInTimelines = false
|
||||||
|
|
||||||
|
// MARK: Digital Wellness
|
||||||
|
@Published public var showFavoriteAndReblogCounts = true
|
||||||
|
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
|
||||||
|
@Published public var grayscaleImages = false
|
||||||
|
@Published public var disableInfiniteScrolling = false
|
||||||
|
@Published public var hideTrends = false
|
||||||
|
|
||||||
|
// MARK: Advanced
|
||||||
|
@Published public var statusContentType: StatusContentType = .plain
|
||||||
|
@Published public var reportErrorsAutomatically = true
|
||||||
|
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
|
||||||
|
|
||||||
|
// MARK:
|
||||||
|
@Published public var hasShownLocalTimelineDescription = false
|
||||||
|
@Published public var hasShownFederatedTimelineDescription = false
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case theme
|
||||||
|
case pureBlackDarkMode
|
||||||
|
case accentColor
|
||||||
|
case avatarStyle
|
||||||
|
case hideCustomEmojiInUsernames
|
||||||
|
case showIsStatusReplyIcon
|
||||||
|
case alwaysShowStatusVisibilityIcon
|
||||||
|
case hideActionsInTimeline
|
||||||
|
case showLinkPreviews
|
||||||
|
case leadingStatusSwipeActions
|
||||||
|
case trailingStatusSwipeActions
|
||||||
|
case widescreenNavigationMode
|
||||||
|
case underlineTextLinks
|
||||||
|
case showAttachmentsInTimeline
|
||||||
|
|
||||||
|
case defaultPostVisibility
|
||||||
|
case defaultReplyVisibility
|
||||||
|
case requireAttachmentDescriptions
|
||||||
|
case contentWarningCopyMode
|
||||||
|
case mentionReblogger
|
||||||
|
case useTwitterKeyboard
|
||||||
|
|
||||||
|
case blurAllMedia // only used for migration
|
||||||
|
case attachmentBlurMode
|
||||||
|
case blurMediaBehindContentWarning
|
||||||
|
case automaticallyPlayGifs
|
||||||
|
case showUncroppedMediaInline
|
||||||
|
case showAttachmentBadges
|
||||||
|
case attachmentAltBadgeInverted
|
||||||
|
|
||||||
|
case openLinksInApps
|
||||||
|
case useInAppSafari
|
||||||
|
case inAppSafariAutomaticReaderMode
|
||||||
|
case expandAllContentWarnings
|
||||||
|
case collapseLongPosts
|
||||||
|
case oppositeCollapseKeywords
|
||||||
|
case confirmBeforeReblog
|
||||||
|
case timelineStateRestoration
|
||||||
|
case timelineSyncMode
|
||||||
|
case hideReblogsInTimelines
|
||||||
|
case hideRepliesInTimelines
|
||||||
|
|
||||||
|
case showFavoriteAndReblogCounts
|
||||||
|
case defaultNotificationsType
|
||||||
|
case grayscaleImages
|
||||||
|
case disableInfiniteScrolling
|
||||||
|
case hideTrends = "hideDiscover"
|
||||||
|
|
||||||
|
case statusContentType
|
||||||
|
case reportErrorsAutomatically
|
||||||
|
case enabledFeatureFlags
|
||||||
|
|
||||||
|
case hasShownLocalTimelineDescription
|
||||||
|
case hasShownFederatedTimelineDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIUserInterfaceStyle: Codable {}
|
|
@ -0,0 +1,106 @@
|
||||||
|
//
|
||||||
|
// PreferenceStore+Migrate.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension PreferenceStore {
|
||||||
|
func migrate(from legacy: LegacyPreferences) {
|
||||||
|
var migrations: [any MigrationProtocol] = [
|
||||||
|
Migration(from: \.theme.theme, to: \.$theme),
|
||||||
|
Migration(from: \.pureBlackDarkMode, to: \.$pureBlackDarkMode),
|
||||||
|
Migration(from: \.accentColor, to: \.$accentColor),
|
||||||
|
Migration(from: \.avatarStyle, to: \.$avatarStyle),
|
||||||
|
Migration(from: \.hideCustomEmojiInUsernames, to: \.$hideCustomEmojiInUsernames),
|
||||||
|
Migration(from: \.showIsStatusReplyIcon, to: \.$showIsStatusReplyIcon),
|
||||||
|
Migration(from: \.alwaysShowStatusVisibilityIcon, to: \.$alwaysShowStatusVisibilityIcon),
|
||||||
|
Migration(from: \.hideActionsInTimeline, to: \.$hideActionsInTimeline),
|
||||||
|
Migration(from: \.showLinkPreviews, to: \.$showLinkPreviews),
|
||||||
|
Migration(from: \.leadingStatusSwipeActions, to: \.$leadingStatusSwipeActions),
|
||||||
|
Migration(from: \.trailingStatusSwipeActions, to: \.$trailingStatusSwipeActions),
|
||||||
|
Migration(from: \.widescreenNavigationMode, to: \.$widescreenNavigationMode),
|
||||||
|
Migration(from: \.underlineTextLinks, to: \.$underlineTextLinks),
|
||||||
|
Migration(from: \.showAttachmentsInTimeline, to: \.$showAttachmentsInTimeline),
|
||||||
|
|
||||||
|
Migration(from: \.defaultPostVisibility, to: \.$defaultPostVisibility),
|
||||||
|
Migration(from: \.defaultReplyVisibility, to: \.$defaultReplyVisibility),
|
||||||
|
Migration(from: \.requireAttachmentDescriptions, to: \.$requireAttachmentDescriptions),
|
||||||
|
Migration(from: \.contentWarningCopyMode, to: \.$contentWarningCopyMode),
|
||||||
|
Migration(from: \.mentionReblogger, to: \.$mentionReblogger),
|
||||||
|
Migration(from: \.useTwitterKeyboard, to: \.$useTwitterKeyboard),
|
||||||
|
|
||||||
|
Migration(from: \.attachmentBlurMode, to: \.$attachmentBlurMode),
|
||||||
|
Migration(from: \.blurMediaBehindContentWarning, to: \.$blurMediaBehindContentWarning),
|
||||||
|
Migration(from: \.automaticallyPlayGifs, to: \.$automaticallyPlayGifs),
|
||||||
|
Migration(from: \.showUncroppedMediaInline, to: \.$showUncroppedMediaInline),
|
||||||
|
Migration(from: \.showAttachmentBadges, to: \.$showAttachmentBadges),
|
||||||
|
Migration(from: \.attachmentAltBadgeInverted, to: \.$attachmentAltBadgeInverted),
|
||||||
|
|
||||||
|
Migration(from: \.openLinksInApps, to: \.$openLinksInApps),
|
||||||
|
Migration(from: \.expandAllContentWarnings, to: \.$expandAllContentWarnings),
|
||||||
|
Migration(from: \.collapseLongPosts, to: \.$collapseLongPosts),
|
||||||
|
Migration(from: \.oppositeCollapseKeywords, to: \.$oppositeCollapseKeywords),
|
||||||
|
Migration(from: \.confirmBeforeReblog, to: \.$confirmBeforeReblog),
|
||||||
|
Migration(from: \.timelineStateRestoration, to: \.$timelineStateRestoration),
|
||||||
|
Migration(from: \.timelineSyncMode, to: \.$timelineSyncMode),
|
||||||
|
Migration(from: \.hideReblogsInTimelines, to: \.$hideReblogsInTimelines),
|
||||||
|
Migration(from: \.hideRepliesInTimelines, to: \.$hideRepliesInTimelines),
|
||||||
|
|
||||||
|
Migration(from: \.showFavoriteAndReblogCounts, to: \.$showFavoriteAndReblogCounts),
|
||||||
|
Migration(from: \.defaultNotificationsMode, to: \.$defaultNotificationsMode),
|
||||||
|
Migration(from: \.grayscaleImages, to: \.$grayscaleImages),
|
||||||
|
Migration(from: \.disableInfiniteScrolling, to: \.$disableInfiniteScrolling),
|
||||||
|
Migration(from: \.hideTrends, to: \.$hideTrends),
|
||||||
|
|
||||||
|
Migration(from: \.statusContentType, to: \.$statusContentType),
|
||||||
|
Migration(from: \.reportErrorsAutomatically, to: \.$reportErrorsAutomatically),
|
||||||
|
Migration(from: \.enabledFeatureFlags, to: \.$enabledFeatureFlags),
|
||||||
|
|
||||||
|
Migration(from: \.hasShownLocalTimelineDescription, to: \.$hasShownLocalTimelineDescription),
|
||||||
|
Migration(from: \.hasShownFederatedTimelineDescription, to: \.$hasShownFederatedTimelineDescription),
|
||||||
|
]
|
||||||
|
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
||||||
|
migrations.append(contentsOf: [
|
||||||
|
Migration(from: \.useInAppSafari, to: \.$useInAppSafari),
|
||||||
|
Migration(from: \.inAppSafariAutomaticReaderMode, to: \.$inAppSafariAutomaticReaderMode),
|
||||||
|
] as [any MigrationProtocol])
|
||||||
|
#endif
|
||||||
|
|
||||||
|
for migration in migrations {
|
||||||
|
migration.migrate(from: legacy, to: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private protocol MigrationProtocol {
|
||||||
|
func migrate(from legacy: LegacyPreferences, to store: PreferenceStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Migration<Key: MigratablePreferenceKey>: MigrationProtocol where Key.Value: Equatable {
|
||||||
|
let from: KeyPath<LegacyPreferences, Key.Value>
|
||||||
|
let to: KeyPath<PreferenceStore, PreferencePublisher<Key>>
|
||||||
|
|
||||||
|
func migrate(from legacy: LegacyPreferences, to store: PreferenceStore) {
|
||||||
|
let value = legacy[keyPath: from]
|
||||||
|
if Key.shouldMigrate(oldValue: value) {
|
||||||
|
Preference.set(enclosingInstance: store, storage: to.appending(path: \.preference), newValue: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIUserInterfaceStyle {
|
||||||
|
var theme: Theme {
|
||||||
|
switch self {
|
||||||
|
case .light:
|
||||||
|
.light
|
||||||
|
case .dark:
|
||||||
|
.dark
|
||||||
|
default:
|
||||||
|
.unspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
//
|
||||||
|
// Preference.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// TODO: once we target iOS 17, use Observable for this
|
||||||
|
@propertyWrapper
|
||||||
|
final class Preference<Key: PreferenceKey>: Codable {
|
||||||
|
@Published private(set) var storedValue: Key.Value?
|
||||||
|
|
||||||
|
var wrappedValue: Key.Value {
|
||||||
|
get {
|
||||||
|
storedValue ?? Key.defaultValue
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
fatalError("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.storedValue = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
if let keyType = Key.self as? any CustomCodablePreferenceKey.Type {
|
||||||
|
self.storedValue = try keyType.decode(from: decoder) as! Key.Value?
|
||||||
|
} else if let container = try? decoder.singleValueContainer() {
|
||||||
|
self.storedValue = try? container.decode(Key.Value.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
if let storedValue {
|
||||||
|
if let keyType = Key.self as? any CustomCodablePreferenceKey.Type {
|
||||||
|
func encode<K: CustomCodablePreferenceKey>(_: K.Type) throws {
|
||||||
|
try K.encode(value: storedValue as! K.Value, to: encoder)
|
||||||
|
}
|
||||||
|
return try _openExistential(keyType, do: encode)
|
||||||
|
} else {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(storedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static subscript(
|
||||||
|
_enclosingInstance instance: PreferenceStore,
|
||||||
|
wrapped wrappedKeyPath: ReferenceWritableKeyPath<PreferenceStore, Key.Value>,
|
||||||
|
storage storageKeyPath: ReferenceWritableKeyPath<PreferenceStore, Preference>
|
||||||
|
) -> Key.Value {
|
||||||
|
get {
|
||||||
|
get(enclosingInstance: instance, storage: storageKeyPath)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
set(enclosingInstance: instance, storage: storageKeyPath, newValue: newValue)
|
||||||
|
Key.didSet(in: instance, newValue: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// for testing only
|
||||||
|
@inline(__always)
|
||||||
|
static func get<Enclosing>(
|
||||||
|
enclosingInstance: Enclosing,
|
||||||
|
storage: KeyPath<Enclosing, Preference>
|
||||||
|
) -> Key.Value where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher {
|
||||||
|
let pref = enclosingInstance[keyPath: storage]
|
||||||
|
return pref.storedValue ?? Key.defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// for testing only
|
||||||
|
@inline(__always)
|
||||||
|
static func set<Enclosing>(
|
||||||
|
enclosingInstance: Enclosing,
|
||||||
|
storage: KeyPath<Enclosing, Preference>,
|
||||||
|
newValue: Key.Value
|
||||||
|
) where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher {
|
||||||
|
enclosingInstance.objectWillChange.send()
|
||||||
|
let pref = enclosingInstance[keyPath: storage]
|
||||||
|
pref.storedValue = newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectedValue: PreferencePublisher<Key> {
|
||||||
|
.init(preference: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PreferencePublisher<Key: PreferenceKey>: Publisher {
|
||||||
|
public typealias Output = Key.Value
|
||||||
|
public typealias Failure = Never
|
||||||
|
|
||||||
|
let preference: Preference<Key>
|
||||||
|
|
||||||
|
public func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, Key.Value == S.Input {
|
||||||
|
preference.$storedValue.map { $0 ?? Key.defaultValue }.receive(subscriber: subscriber)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
//
|
||||||
|
// PreferenceKey.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/12/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol PreferenceKey {
|
||||||
|
associatedtype Value: Codable
|
||||||
|
|
||||||
|
static var defaultValue: Value { get }
|
||||||
|
|
||||||
|
static func didSet(in store: PreferenceStore, newValue: Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PreferenceKey {
|
||||||
|
public static func didSet(in store: PreferenceStore, newValue: Value) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol MigratablePreferenceKey: PreferenceKey where Value: Equatable {
|
||||||
|
static func shouldMigrate(oldValue: Value) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MigratablePreferenceKey {
|
||||||
|
static func shouldMigrate(oldValue: Value) -> Bool {
|
||||||
|
oldValue != defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol CustomCodablePreferenceKey: PreferenceKey {
|
||||||
|
static func encode(value: Value, to encoder: any Encoder) throws
|
||||||
|
static func decode(from decoder: any Decoder) throws -> Value?
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
//
|
||||||
|
// PreferenceStore.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/12/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
public final class PreferenceStore: ObservableObject, Codable {
|
||||||
|
// MARK: Appearance
|
||||||
|
@Preference<ThemeKey> public var theme
|
||||||
|
@Preference<TrueKey> public var pureBlackDarkMode
|
||||||
|
@Preference<AccentColorKey> public var accentColor
|
||||||
|
@Preference<AvatarStyleKey> public var avatarStyle
|
||||||
|
@Preference<FalseKey> public var hideCustomEmojiInUsernames
|
||||||
|
@Preference<FalseKey> public var showIsStatusReplyIcon
|
||||||
|
@Preference<FalseKey> public var alwaysShowStatusVisibilityIcon
|
||||||
|
@Preference<FalseKey> public var hideActionsInTimeline
|
||||||
|
@Preference<TrueKey> public var showLinkPreviews
|
||||||
|
@Preference<LeadingSwipeActionsKey> public var leadingStatusSwipeActions
|
||||||
|
@Preference<TrailingSwipeActionsKey> public var trailingStatusSwipeActions
|
||||||
|
@Preference<WidescreenNavigationModeKey> public var widescreenNavigationMode
|
||||||
|
@Preference<FalseKey> public var underlineTextLinks
|
||||||
|
@Preference<TrueKey> public var showAttachmentsInTimeline
|
||||||
|
@Preference<AttachmentBlurModeKey> public var attachmentBlurMode
|
||||||
|
@Preference<TrueKey> public var blurMediaBehindContentWarning
|
||||||
|
@Preference<TrueKey> public var automaticallyPlayGifs
|
||||||
|
@Preference<TrueKey> public var showUncroppedMediaInline
|
||||||
|
@Preference<TrueKey> public var showAttachmentBadges
|
||||||
|
@Preference<FalseKey> public var attachmentAltBadgeInverted
|
||||||
|
|
||||||
|
// MARK: Composing
|
||||||
|
@Preference<PostVisibilityKey> public var defaultPostVisibility
|
||||||
|
@Preference<ReplyVisibilityKey> public var defaultReplyVisibility
|
||||||
|
@Preference<FalseKey> public var requireAttachmentDescriptions
|
||||||
|
@Preference<ContentWarningCopyModeKey> public var contentWarningCopyMode
|
||||||
|
@Preference<FalseKey> public var mentionReblogger
|
||||||
|
@Preference<FalseKey> public var useTwitterKeyboard
|
||||||
|
|
||||||
|
// MARK: Behavior
|
||||||
|
@Preference<TrueKey> public var openLinksInApps
|
||||||
|
@Preference<InAppSafariKey> public var useInAppSafari
|
||||||
|
@Preference<FalseKey> public var inAppSafariAutomaticReaderMode
|
||||||
|
@Preference<FalseKey> public var expandAllContentWarnings
|
||||||
|
@Preference<TrueKey> public var collapseLongPosts
|
||||||
|
@Preference<OppositeCollapseKeywordsKey> public var oppositeCollapseKeywords
|
||||||
|
@Preference<ConfirmReblogKey> public var confirmBeforeReblog
|
||||||
|
@Preference<TrueKey> public var timelineStateRestoration
|
||||||
|
@Preference<TimelineSyncModeKey> public var timelineSyncMode
|
||||||
|
@Preference<FalseKey> public var hideReblogsInTimelines
|
||||||
|
@Preference<FalseKey> public var hideRepliesInTimelines
|
||||||
|
|
||||||
|
// MARK: Digital Wellness
|
||||||
|
@Preference<TrueKey> public var showFavoriteAndReblogCounts
|
||||||
|
@Preference<NotificationsModeKey> public var defaultNotificationsMode
|
||||||
|
@Preference<FalseKey> public var grayscaleImages
|
||||||
|
@Preference<FalseKey> public var disableInfiniteScrolling
|
||||||
|
@Preference<FalseKey> public var hideTrends
|
||||||
|
|
||||||
|
// MARK: Advanced
|
||||||
|
@Preference<StatusContentTypeKey> public var statusContentType
|
||||||
|
@Preference<TrueKey> public var reportErrorsAutomatically
|
||||||
|
@Preference<FeatureFlagsKey> public var enabledFeatureFlags
|
||||||
|
|
||||||
|
// MARK: Internal
|
||||||
|
@Preference<FalseKey> public var hasShownLocalTimelineDescription
|
||||||
|
@Preference<FalseKey> public var hasShownFederatedTimelineDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PreferenceStore {
|
||||||
|
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
|
||||||
|
enabledFeatureFlags.contains(flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public func getValue<Key: PreferenceKey>(preferenceKeyPath: KeyPath<PreferenceStore, PreferencePublisher<Key>>) -> Key.Value {
|
||||||
|
self[keyPath: preferenceKeyPath].preference.wrappedValue
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,426 +2,42 @@
|
||||||
// Preferences.swift
|
// Preferences.swift
|
||||||
// TuskerPreferences
|
// TuskerPreferences
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 8/28/18.
|
// Created by Shadowfacts on 4/12/24.
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import Foundation
|
||||||
import Pachyderm
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
public final class Preferences: Codable, ObservableObject {
|
|
||||||
|
|
||||||
|
public struct Preferences {
|
||||||
@MainActor
|
@MainActor
|
||||||
public static var shared: Preferences = load()
|
public static let shared: PreferenceStore = load()
|
||||||
|
|
||||||
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||||
private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
|
private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
|
||||||
private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
private static var legacyURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||||
|
private static var preferencesURL = appGroupDirectory.appendingPathComponent("preferences.v2").appendingPathExtension("plist")
|
||||||
|
private static var nonAppGroupURL = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public static func save() {
|
public static func save() {
|
||||||
let encoder = PropertyListEncoder()
|
let encoder = PropertyListEncoder()
|
||||||
let data = try? encoder.encode(shared)
|
let data = try? encoder.encode(PreferenceCoding(wrapped: shared))
|
||||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
try? data?.write(to: preferencesURL, options: .noFileProtection)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func load() -> Preferences {
|
private static func load() -> PreferenceStore {
|
||||||
let decoder = PropertyListDecoder()
|
let decoder = PropertyListDecoder()
|
||||||
if let data = try? Data(contentsOf: archiveURL),
|
if let data = try? Data(contentsOf: preferencesURL),
|
||||||
let preferences = try? decoder.decode(Preferences.self, from: data) {
|
let store = try? decoder.decode(PreferenceCoding<PreferenceStore>.self, from: data) {
|
||||||
return preferences
|
return store.wrapped
|
||||||
}
|
} else if let legacyData = (try? Data(contentsOf: legacyURL)) ?? (try? Data(contentsOf: nonAppGroupURL)),
|
||||||
return Preferences()
|
let legacy = try? decoder.decode(LegacyPreferences.self, from: legacyData) {
|
||||||
}
|
let store = PreferenceStore()
|
||||||
|
store.migrate(from: legacy)
|
||||||
@MainActor
|
return store
|
||||||
public static func migrate(from url: URL) -> Result<Void, any Error> {
|
|
||||||
do {
|
|
||||||
try? FileManager.default.removeItem(at: archiveURL)
|
|
||||||
try FileManager.default.moveItem(at: url, to: archiveURL)
|
|
||||||
} catch {
|
|
||||||
return .failure(error)
|
|
||||||
}
|
|
||||||
shared = load()
|
|
||||||
return .success(())
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
|
||||||
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
|
|
||||||
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
|
|
||||||
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
|
|
||||||
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
|
|
||||||
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
|
||||||
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
|
||||||
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
|
|
||||||
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
|
|
||||||
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
|
|
||||||
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
|
||||||
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
|
|
||||||
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
|
|
||||||
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
|
|
||||||
|
|
||||||
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
|
|
||||||
self.defaultPostVisibility = .visibility(existing)
|
|
||||||
} else {
|
} else {
|
||||||
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
|
return PreferenceStore()
|
||||||
}
|
|
||||||
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
|
||||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
|
||||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
|
||||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
|
||||||
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
|
||||||
|
|
||||||
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
|
|
||||||
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
|
|
||||||
} else {
|
|
||||||
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
|
|
||||||
}
|
|
||||||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
|
||||||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
|
||||||
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
|
|
||||||
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
|
|
||||||
|
|
||||||
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
|
||||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
|
||||||
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
|
|
||||||
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
|
|
||||||
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
|
|
||||||
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
|
|
||||||
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
|
|
||||||
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
|
|
||||||
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
|
|
||||||
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
|
|
||||||
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
|
|
||||||
|
|
||||||
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
|
|
||||||
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
|
||||||
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
|
|
||||||
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
|
|
||||||
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
|
|
||||||
|
|
||||||
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
|
|
||||||
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
|
|
||||||
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
|
|
||||||
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
|
|
||||||
|
|
||||||
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
|
|
||||||
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
try container.encode(theme, forKey: .theme)
|
|
||||||
try container.encode(pureBlackDarkMode, forKey: .pureBlackDarkMode)
|
|
||||||
try container.encode(accentColor, forKey: .accentColor)
|
|
||||||
try container.encode(avatarStyle, forKey: .avatarStyle)
|
|
||||||
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
|
|
||||||
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
|
|
||||||
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
|
|
||||||
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
|
|
||||||
try container.encode(showLinkPreviews, forKey: .showLinkPreviews)
|
|
||||||
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
|
|
||||||
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
|
||||||
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
|
|
||||||
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
|
|
||||||
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
|
|
||||||
|
|
||||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
|
||||||
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
|
||||||
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
|
||||||
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
|
||||||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
|
||||||
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
|
|
||||||
|
|
||||||
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
|
|
||||||
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
|
||||||
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
|
||||||
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
|
|
||||||
try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges)
|
|
||||||
|
|
||||||
try container.encode(openLinksInApps, forKey: .openLinksInApps)
|
|
||||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
|
||||||
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
|
|
||||||
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
|
|
||||||
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
|
|
||||||
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
|
|
||||||
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
|
|
||||||
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
|
|
||||||
try container.encode(timelineSyncMode, forKey: .timelineSyncMode)
|
|
||||||
try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines)
|
|
||||||
try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines)
|
|
||||||
|
|
||||||
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
|
|
||||||
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
|
|
||||||
try container.encode(grayscaleImages, forKey: .grayscaleImages)
|
|
||||||
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
|
|
||||||
try container.encode(hideTrends, forKey: .hideTrends)
|
|
||||||
|
|
||||||
try container.encode(statusContentType, forKey: .statusContentType)
|
|
||||||
try container.encode(reportErrorsAutomatically, forKey: .reportErrorsAutomatically)
|
|
||||||
try container.encode(enabledFeatureFlags, forKey: .enabledFeatureFlags)
|
|
||||||
|
|
||||||
try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription)
|
|
||||||
try container.encode(hasShownFederatedTimelineDescription, forKey: .hasShownFederatedTimelineDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Appearance
|
|
||||||
@Published public var theme = UIUserInterfaceStyle.unspecified
|
|
||||||
@Published public var pureBlackDarkMode = true
|
|
||||||
@Published public var accentColor = AccentColor.default
|
|
||||||
@Published public var avatarStyle = AvatarStyle.roundRect
|
|
||||||
@Published public var hideCustomEmojiInUsernames = false
|
|
||||||
@Published public var showIsStatusReplyIcon = false
|
|
||||||
@Published public var alwaysShowStatusVisibilityIcon = false
|
|
||||||
@Published public var hideActionsInTimeline = false
|
|
||||||
@Published public var showLinkPreviews = true
|
|
||||||
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
|
|
||||||
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
|
||||||
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
|
||||||
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
|
|
||||||
@Published public var underlineTextLinks = false
|
|
||||||
@Published public var showAttachmentsInTimeline = true
|
|
||||||
|
|
||||||
// MARK: Composing
|
|
||||||
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
|
||||||
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
|
||||||
@Published public var requireAttachmentDescriptions = false
|
|
||||||
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
|
||||||
@Published public var mentionReblogger = false
|
|
||||||
@Published public var useTwitterKeyboard = false
|
|
||||||
|
|
||||||
// MARK: Media
|
|
||||||
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
|
|
||||||
didSet {
|
|
||||||
if attachmentBlurMode == .always {
|
|
||||||
blurMediaBehindContentWarning = true
|
|
||||||
} else if attachmentBlurMode == .never {
|
|
||||||
blurMediaBehindContentWarning = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Published public var blurMediaBehindContentWarning = true
|
|
||||||
@Published public var automaticallyPlayGifs = true
|
|
||||||
@Published public var showUncroppedMediaInline = true
|
|
||||||
@Published public var showAttachmentBadges = true
|
|
||||||
|
|
||||||
// MARK: Behavior
|
|
||||||
@Published public var openLinksInApps = true
|
|
||||||
@Published public var useInAppSafari = true
|
|
||||||
@Published public var inAppSafariAutomaticReaderMode = false
|
|
||||||
@Published public var expandAllContentWarnings = false
|
|
||||||
@Published public var collapseLongPosts = true
|
|
||||||
@Published public var oppositeCollapseKeywords: [String] = []
|
|
||||||
@Published public var confirmBeforeReblog = false
|
|
||||||
@Published public var timelineStateRestoration = true
|
|
||||||
@Published public var timelineSyncMode = TimelineSyncMode.icloud
|
|
||||||
@Published public var hideReblogsInTimelines = false
|
|
||||||
@Published public var hideRepliesInTimelines = false
|
|
||||||
|
|
||||||
// MARK: Digital Wellness
|
|
||||||
@Published public var showFavoriteAndReblogCounts = true
|
|
||||||
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
|
|
||||||
@Published public var grayscaleImages = false
|
|
||||||
@Published public var disableInfiniteScrolling = false
|
|
||||||
@Published public var hideTrends = false
|
|
||||||
|
|
||||||
// MARK: Advanced
|
|
||||||
@Published public var statusContentType: StatusContentType = .plain
|
|
||||||
@Published public var reportErrorsAutomatically = true
|
|
||||||
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
|
|
||||||
|
|
||||||
// MARK:
|
|
||||||
@Published public var hasShownLocalTimelineDescription = false
|
|
||||||
@Published public var hasShownFederatedTimelineDescription = false
|
|
||||||
|
|
||||||
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
|
|
||||||
enabledFeatureFlags.contains(flag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case theme
|
|
||||||
case pureBlackDarkMode
|
|
||||||
case accentColor
|
|
||||||
case avatarStyle
|
|
||||||
case hideCustomEmojiInUsernames
|
|
||||||
case showIsStatusReplyIcon
|
|
||||||
case alwaysShowStatusVisibilityIcon
|
|
||||||
case hideActionsInTimeline
|
|
||||||
case showLinkPreviews
|
|
||||||
case leadingStatusSwipeActions
|
|
||||||
case trailingStatusSwipeActions
|
|
||||||
case widescreenNavigationMode
|
|
||||||
case underlineTextLinks
|
|
||||||
case showAttachmentsInTimeline
|
|
||||||
|
|
||||||
case defaultPostVisibility
|
|
||||||
case defaultReplyVisibility
|
|
||||||
case requireAttachmentDescriptions
|
|
||||||
case contentWarningCopyMode
|
|
||||||
case mentionReblogger
|
|
||||||
case useTwitterKeyboard
|
|
||||||
|
|
||||||
case blurAllMedia // only used for migration
|
|
||||||
case attachmentBlurMode
|
|
||||||
case blurMediaBehindContentWarning
|
|
||||||
case automaticallyPlayGifs
|
|
||||||
case showUncroppedMediaInline
|
|
||||||
case showAttachmentBadges
|
|
||||||
|
|
||||||
case openLinksInApps
|
|
||||||
case useInAppSafari
|
|
||||||
case inAppSafariAutomaticReaderMode
|
|
||||||
case expandAllContentWarnings
|
|
||||||
case collapseLongPosts
|
|
||||||
case oppositeCollapseKeywords
|
|
||||||
case confirmBeforeReblog
|
|
||||||
case timelineStateRestoration
|
|
||||||
case timelineSyncMode
|
|
||||||
case hideReblogsInTimelines
|
|
||||||
case hideRepliesInTimelines
|
|
||||||
|
|
||||||
case showFavoriteAndReblogCounts
|
|
||||||
case defaultNotificationsType
|
|
||||||
case grayscaleImages
|
|
||||||
case disableInfiniteScrolling
|
|
||||||
case hideTrends = "hideDiscover"
|
|
||||||
|
|
||||||
case statusContentType
|
|
||||||
case reportErrorsAutomatically
|
|
||||||
case enabledFeatureFlags
|
|
||||||
|
|
||||||
case hasShownLocalTimelineDescription
|
|
||||||
case hasShownFederatedTimelineDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Preferences {
|
|
||||||
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
|
||||||
case useStatusSetting
|
|
||||||
case always
|
|
||||||
case never
|
|
||||||
|
|
||||||
public var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .useStatusSetting:
|
|
||||||
return "Default"
|
|
||||||
case .always:
|
|
||||||
return "Always"
|
|
||||||
case .never:
|
|
||||||
return "Never"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIUserInterfaceStyle: Codable {}
|
|
||||||
|
|
||||||
extension Preferences {
|
|
||||||
public enum AccentColor: String, Codable, CaseIterable {
|
|
||||||
case `default`
|
|
||||||
case purple
|
|
||||||
case indigo
|
|
||||||
case blue
|
|
||||||
case cyan
|
|
||||||
case teal
|
|
||||||
case mint
|
|
||||||
case green
|
|
||||||
// case yellow
|
|
||||||
case orange
|
|
||||||
case red
|
|
||||||
case pink
|
|
||||||
// case brown
|
|
||||||
|
|
||||||
public var color: UIColor? {
|
|
||||||
switch self {
|
|
||||||
case .default:
|
|
||||||
return nil
|
|
||||||
case .blue:
|
|
||||||
return .systemBlue
|
|
||||||
// case .brown:
|
|
||||||
// return .systemBrown
|
|
||||||
case .cyan:
|
|
||||||
return .systemCyan
|
|
||||||
case .green:
|
|
||||||
return .systemGreen
|
|
||||||
case .indigo:
|
|
||||||
return .systemIndigo
|
|
||||||
case .mint:
|
|
||||||
return .systemMint
|
|
||||||
case .orange:
|
|
||||||
return .systemOrange
|
|
||||||
case .pink:
|
|
||||||
return .systemPink
|
|
||||||
case .purple:
|
|
||||||
return .systemPurple
|
|
||||||
case .red:
|
|
||||||
return .systemRed
|
|
||||||
case .teal:
|
|
||||||
return .systemTeal
|
|
||||||
// case .yellow:
|
|
||||||
// return .systemYellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var name: String {
|
|
||||||
switch self {
|
|
||||||
case .default:
|
|
||||||
return "Default"
|
|
||||||
case .blue:
|
|
||||||
return "Blue"
|
|
||||||
// case .brown:
|
|
||||||
// return "Brown"
|
|
||||||
case .cyan:
|
|
||||||
return "Cyan"
|
|
||||||
case .green:
|
|
||||||
return "Green"
|
|
||||||
case .indigo:
|
|
||||||
return "Indigo"
|
|
||||||
case .mint:
|
|
||||||
return "Mint"
|
|
||||||
case .orange:
|
|
||||||
return "Orange"
|
|
||||||
case .pink:
|
|
||||||
return "Pink"
|
|
||||||
case .purple:
|
|
||||||
return "Purple"
|
|
||||||
case .red:
|
|
||||||
return "Red"
|
|
||||||
case .teal:
|
|
||||||
return "Teal"
|
|
||||||
// case .yellow:
|
|
||||||
// return "Yellow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Preferences {
|
|
||||||
public enum TimelineSyncMode: String, Codable {
|
|
||||||
case mastodon
|
|
||||||
case icloud
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Preferences {
|
|
||||||
public enum FeatureFlag: String, Codable {
|
|
||||||
case iPadMultiColumn = "ipad-multi-column"
|
|
||||||
case iPadBrowserNavigation = "ipad-browser-navigation"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Preferences {
|
|
||||||
public enum WidescreenNavigationMode: String, Codable {
|
|
||||||
case stack
|
|
||||||
case splitScreen
|
|
||||||
case multiColumn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
//
|
||||||
|
// AccentColor.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public enum AccentColor: String, Codable, CaseIterable {
|
||||||
|
case `default`
|
||||||
|
case purple
|
||||||
|
case indigo
|
||||||
|
case blue
|
||||||
|
case cyan
|
||||||
|
case teal
|
||||||
|
case mint
|
||||||
|
case green
|
||||||
|
// case yellow
|
||||||
|
case orange
|
||||||
|
case red
|
||||||
|
case pink
|
||||||
|
// case brown
|
||||||
|
|
||||||
|
public var color: UIColor? {
|
||||||
|
switch self {
|
||||||
|
case .default:
|
||||||
|
return nil
|
||||||
|
case .blue:
|
||||||
|
return .systemBlue
|
||||||
|
// case .brown:
|
||||||
|
// return .systemBrown
|
||||||
|
case .cyan:
|
||||||
|
return .systemCyan
|
||||||
|
case .green:
|
||||||
|
return .systemGreen
|
||||||
|
case .indigo:
|
||||||
|
return .systemIndigo
|
||||||
|
case .mint:
|
||||||
|
return .systemMint
|
||||||
|
case .orange:
|
||||||
|
return .systemOrange
|
||||||
|
case .pink:
|
||||||
|
return .systemPink
|
||||||
|
case .purple:
|
||||||
|
return .systemPurple
|
||||||
|
case .red:
|
||||||
|
return .systemRed
|
||||||
|
case .teal:
|
||||||
|
return .systemTeal
|
||||||
|
// case .yellow:
|
||||||
|
// return .systemYellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var name: String {
|
||||||
|
switch self {
|
||||||
|
case .default:
|
||||||
|
return "Default"
|
||||||
|
case .blue:
|
||||||
|
return "Blue"
|
||||||
|
// case .brown:
|
||||||
|
// return "Brown"
|
||||||
|
case .cyan:
|
||||||
|
return "Cyan"
|
||||||
|
case .green:
|
||||||
|
return "Green"
|
||||||
|
case .indigo:
|
||||||
|
return "Indigo"
|
||||||
|
case .mint:
|
||||||
|
return "Mint"
|
||||||
|
case .orange:
|
||||||
|
return "Orange"
|
||||||
|
case .pink:
|
||||||
|
return "Pink"
|
||||||
|
case .purple:
|
||||||
|
return "Purple"
|
||||||
|
case .red:
|
||||||
|
return "Red"
|
||||||
|
case .teal:
|
||||||
|
return "Teal"
|
||||||
|
// case .yellow:
|
||||||
|
// return "Yellow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
//
|
||||||
|
// AttachmentBlurMode.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||||
|
case useStatusSetting
|
||||||
|
case always
|
||||||
|
case never
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .useStatusSetting:
|
||||||
|
return "Default"
|
||||||
|
case .always:
|
||||||
|
return "Always"
|
||||||
|
case .never:
|
||||||
|
return "Never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// FeatureFlag.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum FeatureFlag: String, Codable {
|
||||||
|
case iPadBrowserNavigation = "ipad-browser-navigation"
|
||||||
|
case pushNotifCustomEmoji = "push-notif-custom-emoji"
|
||||||
|
}
|
|
@ -12,7 +12,7 @@ public enum PostVisibility: Codable, Hashable, CaseIterable, Sendable {
|
||||||
case serverDefault
|
case serverDefault
|
||||||
case visibility(Visibility)
|
case visibility(Visibility)
|
||||||
|
|
||||||
public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
|
public private(set) static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
|
||||||
|
|
||||||
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
|
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -57,7 +57,7 @@ public enum ReplyVisibility: Codable, Hashable, CaseIterable {
|
||||||
case sameAsPost
|
case sameAsPost
|
||||||
case visibility(Visibility)
|
case visibility(Visibility)
|
||||||
|
|
||||||
public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
|
public private(set) static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
|
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
|
|
@ -0,0 +1,24 @@
|
||||||
|
//
|
||||||
|
// Theme.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public enum Theme: String, Codable {
|
||||||
|
case unspecified, light, dark
|
||||||
|
|
||||||
|
public var userInterfaceStyle: UIUserInterfaceStyle {
|
||||||
|
switch self {
|
||||||
|
case .unspecified:
|
||||||
|
.unspecified
|
||||||
|
case .light:
|
||||||
|
.light
|
||||||
|
case .dark:
|
||||||
|
.dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue