Compare commits
No commits in common. "develop" and "2024.1-115" have entirely different histories.
develop
...
2024.1-115
|
@ -1,157 +0,0 @@
|
|||
<?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>
|
Before Width: | Height: | Size: 8.1 KiB |
|
@ -1,153 +0,0 @@
|
|||
<?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>
|
Before Width: | Height: | Size: 7.9 KiB |
|
@ -1,162 +0,0 @@
|
|||
<?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>
|
Before Width: | Height: | Size: 8.2 KiB |
|
@ -1,100 +1,3 @@
|
|||
## 2024.4
|
||||
This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.
|
||||
|
||||
Features/Improvements:
|
||||
- Import image description when adding attachments from Photos if possible
|
||||
- iPadOS 18: New floating sidebar/tab bar
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when viewing profiles in certain circumstances
|
||||
- Fix video controls in attachment gallery not auto-hiding
|
||||
- Fix crash if hashtag search results includes duplicates
|
||||
- Fix "no content" text not being removed from list timeline after refreshing
|
||||
- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
|
||||
- macOS: Fix reselecting current item not navigating back
|
||||
|
||||
## 2024.3
|
||||
This update includes a number of bugfixes and performance improvements. See below for a list of fixes.
|
||||
|
||||
Bugfixes:
|
||||
- Fix an issue displaying rich text in certain cases
|
||||
- Fix crash when video attachment finishes playing
|
||||
- Fix video attachment thumbnails being flipped on Compose screen
|
||||
- Fix profile header images being blurry
|
||||
- Fix crash when opening push notifications in certain circumstances
|
||||
- Fix certain links in profile fields not being tappable
|
||||
- Fix gifv playback pausing audio from other apps
|
||||
- Fix gifv playback being paused when returning from background
|
||||
- Fix badges on gifv attachments not appearing
|
||||
- Fix excessive network traffic when opening profile pages
|
||||
- Fix controls visibility not matching across attachment gallery pages
|
||||
- Fix add hashtag/instance pinned timeline sheet in Customize Timelines dismissing instantly
|
||||
- Fix Dynamic Type not applying to status content
|
||||
- Fix mention/status push notifications not showing CW
|
||||
- Fix sensitive attachment thumbnails being shown in push notifications
|
||||
- Fix profile moved overlay visual and VoiceOver issues
|
||||
- Fix opening Mastodon remote status links
|
||||
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
|
||||
- Pleroma/Akkoma: Fix editing attachment descriptions not working
|
||||
- Pixelfed/Firefish: Fix error loading certain accounts
|
||||
- Pixelfed: Fix error loading relationships and follow/block/etc. actions
|
||||
- iPadOS: Fix pointer interactions throughout the app
|
||||
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
||||
- iPadOS: Fix Cmd+1/etc. removing columns when returning to previous tab
|
||||
- iPadOS: Fix multi-column interface not animating for some actions
|
||||
- iPadOS: Fix selecting search results always adding new column
|
||||
|
||||
## 2024.2
|
||||
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
|
||||
|
||||
Features/Improvements:
|
||||
- Push notifications
|
||||
- Add post preview to Appearance preferences
|
||||
- Show instance announcements in Notifications tab
|
||||
- Add subscription option to Tip Jar
|
||||
- iPadOS: Multi-column navigation
|
||||
- Pleroma/Akkoma: Emoji reaction notifications
|
||||
|
||||
Bugfixes:
|
||||
- Fix fetching server info on some instances
|
||||
- Fix attachment captions not displaying while loading in gallery
|
||||
- macOS: Remove in-app Safari preferences
|
||||
- Pleroma: Handle posts with missing creation date
|
||||
|
||||
## 2024.1
|
||||
This update includes a significant improvements for the attachment gallery and displaying rich text posts. See below for a full list of improvements and fixes.
|
||||
|
||||
Features/Improvements:
|
||||
- Improve attachment gallery
|
||||
- Improve animations
|
||||
- Display video captions
|
||||
- Support sharing/saving videos
|
||||
- Resume music playback after playing videos
|
||||
- Improve rich text display in posts
|
||||
- Add See Results button to polls
|
||||
- Add Share and Save to Photos menu items to post attachments
|
||||
- Show verified links in account lists
|
||||
- Display message on empty list timelines
|
||||
- Add preference to indicate attachments lacking alt text
|
||||
- Mark notifications as read on Mastodon web frontend once displayed
|
||||
- iPadOS: Support tapping the selected sidebar item to scroll to top
|
||||
|
||||
Bugfixes:
|
||||
- Fix issue changing scope after searching
|
||||
- Fix crash when searching "from:me"
|
||||
- Fix tapping Followers button on profile opening Following screen
|
||||
- Fix crash when removing poll option on Compose screen
|
||||
- Fix hang when sharing video/GIFV attachments
|
||||
- Fix stretched Save to Photos icon when sharing attachments
|
||||
- Fix GIFV playback preventing device sleep
|
||||
- Fix Notifications tab not scrolling to top when tab bar item tapped
|
||||
- Fix selection not clearing on Trending Hashtags
|
||||
- Fix fast account switcher overlapping iPhone sensor housing in landscape
|
||||
- Fix Edit List screen not updating when adding/removing accounts
|
||||
- Fix changing list reply policy not refreshing timeline
|
||||
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
|
||||
- macOS: Fix attachment gallery displaying improperly when Reduce Motion is on
|
||||
|
||||
## 2023.8
|
||||
This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes.
|
||||
|
||||
|
|
176
CHANGELOG.md
176
CHANGELOG.md
|
@ -1,181 +1,5 @@
|
|||
# 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)
|
||||
Features/Improvements:
|
||||
- Rewrite attachment gallery
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>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>
|
|
@ -1,14 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>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>
|
|
@ -1,397 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
|
@ -24,8 +24,6 @@
|
|||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionServiceRoleType</key>
|
||||
<string>NSExtensionServiceRoleTypeViewer</string>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version: 6.0
|
||||
// swift-tools-version: 5.7
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "ComposeUI",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -26,15 +26,9 @@ let package = Package(
|
|||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "ComposeUI",
|
||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]),
|
||||
.testTarget(
|
||||
name: "ComposeUITests",
|
||||
dependencies: ["ComposeUI"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: ["ComposeUI"]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -156,7 +156,7 @@ class AttachmentRowController: ViewController {
|
|||
Button(role: .destructive, action: controller.removeAttachment) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} preview: {
|
||||
} previewIfAvailable: {
|
||||
ControllerView(controller: { controller.thumbnailController })
|
||||
}
|
||||
|
||||
|
@ -221,3 +221,16 @@ extension AttachmentRowController {
|
|||
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,7 +40,6 @@ class AttachmentThumbnailController: ViewController {
|
|||
case .video, .gifv:
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
|
@ -92,7 +91,6 @@ class AttachmentThumbnailController: ViewController {
|
|||
if type.conforms(to: .movie) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
|
|
|
@ -214,6 +214,44 @@ fileprivate extension View {
|
|||
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, *)
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import SwiftUI
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteEmojisController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteHashtagsController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
|
|
@ -125,7 +125,9 @@ public final class ComposeController: ViewController {
|
|||
self.toolbarController = ToolbarController(parent: self)
|
||||
self.attachmentsListController = AttachmentsListController(parent: self)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
||||
if #available(iOS 16.0, *) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -322,17 +324,16 @@ public final class ComposeController: ViewController {
|
|||
ControllerView(controller: { controller.toolbarController })
|
||||
#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))
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||
#if targetEnvironment(macCatalyst)
|
||||
ToolbarItem(placement: .topBarTrailing) { draftsButton }
|
||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||
#else
|
||||
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
|
||||
#endif
|
||||
#if os(visionOS)
|
||||
ToolbarItem(placement: .bottomOrnament) {
|
||||
ControllerView(controller: { controller.toolbarController })
|
||||
|
@ -430,7 +431,7 @@ public final class ComposeController: ViewController {
|
|||
}
|
||||
.listStyle(.plain)
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
#endif
|
||||
.disabled(controller.isPosting)
|
||||
}
|
||||
|
@ -460,26 +461,43 @@ public final class ComposeController: ViewController {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var postOrDraftsButton: some View {
|
||||
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||
postButton
|
||||
} else {
|
||||
draftsButton
|
||||
}
|
||||
}
|
||||
|
||||
private var draftsButton: some View {
|
||||
Button(action: controller.showDrafts) {
|
||||
Text("Drafts")
|
||||
}
|
||||
}
|
||||
|
||||
private var postButton: some View {
|
||||
Button(action: controller.postStatus) {
|
||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||
Button(action: controller.postStatus) {
|
||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(!controller.postButtonEnabled)
|
||||
} else {
|
||||
Button(action: controller.showDrafts) {
|
||||
Text("Drafts")
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(!controller.postButtonEnabled)
|
||||
}
|
||||
|
||||
#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,11 +51,14 @@ class FocusedAttachmentController: ViewController {
|
|||
.onAppear {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
} else if #available(iOS 16.0, *) {
|
||||
ZoomableScrollView {
|
||||
attachmentView
|
||||
.matchedGeometryDestination(id: attachment.id)
|
||||
}
|
||||
} else {
|
||||
attachmentView
|
||||
.matchedGeometryDestination(id: attachment.id)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
|
|
@ -96,7 +96,7 @@ class PollController: ViewController {
|
|||
.onMove(perform: controller.moveOptions)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollDisabled(true)
|
||||
.scrollDisabledIfAvailable(true)
|
||||
.frame(height: 44 * CGFloat(poll.options.count))
|
||||
|
||||
Button(action: controller.addOption) {
|
||||
|
|
|
@ -66,7 +66,7 @@ class ToolbarController: ViewController {
|
|||
}
|
||||
})
|
||||
}
|
||||
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
|
||||
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
||||
.frame(height: ToolbarController.height)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||
|
@ -122,7 +122,8 @@ class ToolbarController: ViewController {
|
|||
|
||||
Spacer()
|
||||
|
||||
if composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||
if #available(iOS 16.0, *),
|
||||
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||
}
|
||||
}
|
||||
|
@ -180,8 +181,13 @@ class ToolbarController: ViewController {
|
|||
private var formatButtons: some View {
|
||||
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
||||
Button(action: controller.formatAction(format)) {
|
||||
Image(systemName: format.imageName)
|
||||
.font(.system(size: imageSize))
|
||||
if let imageName = format.imageName {
|
||||
Image(systemName: imageName)
|
||||
.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)
|
||||
.padding(5)
|
||||
|
|
|
@ -167,23 +167,11 @@ extension DraftAttachment: NSItemProviderReading {
|
|||
type = .png
|
||||
}
|
||||
|
||||
// Read the caption from the image itself, if there is one.
|
||||
let caption: String
|
||||
if let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceTypeIdentifierHint: typeIdentifier as CFString] as CFDictionary),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any],
|
||||
// This is the dictionary for TIFF properties, but it's present for other image types too
|
||||
let tiffProperties = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any],
|
||||
let imageDescription = tiffProperties[kCGImagePropertyTIFFImageDescription as String] as? String {
|
||||
caption = imageDescription
|
||||
} else {
|
||||
caption = ""
|
||||
}
|
||||
|
||||
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
|
||||
attachment.id = UUID()
|
||||
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
|
||||
attachment.fileType = type.identifier
|
||||
attachment.attachmentDescription = caption
|
||||
attachment.attachmentDescription = ""
|
||||
return attachment
|
||||
}
|
||||
|
||||
|
|
|
@ -16,8 +16,6 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
|||
|
||||
public static let shared = DraftsPersistentContainer()
|
||||
|
||||
public static var captureError: ((any Error) -> Void)?
|
||||
|
||||
private static let managedObjectModel: NSManagedObjectModel = {
|
||||
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
|
||||
return NSManagedObjectModel(contentsOf: url)!
|
||||
|
@ -41,7 +39,6 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
|||
|
||||
loadPersistentStores { _, error in
|
||||
if let error {
|
||||
DraftsPersistentContainer.captureError?(error)
|
||||
fatalError("Loading persistent store: \(error)")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
//
|
||||
// FuzzyMatcher.swift
|
||||
// TuskerComponents
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 10/10/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct FuzzyMatcher {
|
||||
struct FuzzyMatcher {
|
||||
|
||||
private init() {}
|
||||
|
||||
|
@ -21,7 +21,7 @@ public struct FuzzyMatcher {
|
|||
/// +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
|
||||
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
|
||||
public static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||
let pattern = pattern.lowercased()
|
||||
let str = str.lowercased()
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
import UIKit
|
||||
import Combine
|
||||
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
class KeyboardReader: ObservableObject {
|
||||
// @Published var isVisible = false
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
|
|
|
@ -23,7 +23,7 @@ enum StatusFormat: Int, CaseIterable {
|
|||
}
|
||||
}
|
||||
|
||||
var imageName: String {
|
||||
var imageName: String? {
|
||||
switch self {
|
||||
case .italics:
|
||||
return "italic"
|
||||
|
@ -31,8 +31,16 @@ enum StatusFormat: Int, CaseIterable {
|
|||
return "bold"
|
||||
case .strikethrough:
|
||||
return "strikethrough"
|
||||
case .code:
|
||||
return "chevron.left.forwardslash.chevron.right"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var title: (String, [NSAttributedString.Key: Any])? {
|
||||
if self == .code {
|
||||
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -39,7 +39,6 @@ extension TextViewCaretScrolling {
|
|||
|
||||
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
||||
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
||||
scrollView.layoutIfNeeded()
|
||||
}
|
||||
self.caretScrollPositionAnimator = animator
|
||||
animator.startAnimation()
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
//
|
||||
// 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,7 +259,11 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|||
if range.length > 0 {
|
||||
let formatMenu = suggestedActions[index] as! UIMenu
|
||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [weak self] _ in
|
||||
var image: UIImage?
|
||||
if let imageName = fmt.imageName {
|
||||
image = UIImage(systemName: imageName)
|
||||
}
|
||||
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
|
||||
self?.applyFormat(fmt)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -76,15 +76,13 @@ 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
|
||||
offset = min(offset, maxOffset)
|
||||
|
||||
return AvatarContainerRepresentable(offset: offset) {
|
||||
AvatarImageView(
|
||||
url: status.account.avatar,
|
||||
size: 50,
|
||||
style: controller.config.avatarStyle,
|
||||
fetchAvatar: controller.fetchAvatar
|
||||
)
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
return AvatarImageView(
|
||||
url: status.account.avatar,
|
||||
size: 50,
|
||||
style: controller.config.avatarStyle,
|
||||
fetchAvatar: controller.fetchAvatar
|
||||
)
|
||||
.offset(x: 0, y: offset)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
|
@ -96,39 +94,3 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
|
|||
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: 6.0
|
||||
// swift-tools-version: 5.7
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "Duckable",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -23,10 +23,7 @@ let package = Package(
|
|||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "Duckable",
|
||||
dependencies: [],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: []),
|
||||
// .testTarget(
|
||||
// name: "DuckableTests",
|
||||
// dependencies: ["Duckable"]),
|
||||
|
|
|
@ -33,11 +33,11 @@ public enum DuckAttemptAction {
|
|||
|
||||
extension UIViewController {
|
||||
@available(iOS 16.0, *)
|
||||
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false, completion: (() -> Void)? = nil) -> Bool {
|
||||
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
||||
var cur: UIViewController? = self
|
||||
while let vc = cur {
|
||||
if let container = vc as? DuckableContainerViewController {
|
||||
container._presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: completion)
|
||||
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
|
||||
return true
|
||||
} else {
|
||||
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 {
|
||||
if animated,
|
||||
case .ducked(_, placeholder: let placeholder) = state {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version: 6.0
|
||||
// swift-tools-version: 5.10
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "GalleryVC",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
|
@ -18,15 +18,9 @@ let package = Package(
|
|||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "GalleryVC",
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
name: "GalleryVC"),
|
||||
.testTarget(
|
||||
name: "GalleryVCTests",
|
||||
dependencies: ["GalleryVC"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: ["GalleryVC"]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -17,7 +17,7 @@ public protocol GalleryContentViewController: UIViewController {
|
|||
var bottomControlsAccessoryViewController: UIViewController? { get }
|
||||
var canAnimateFromSourceView: Bool { get }
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool)
|
||||
func galleryContentDidAppear()
|
||||
func galleryContentWillDisappear()
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ public extension GalleryContentViewController {
|
|||
true
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool) {
|
||||
}
|
||||
|
||||
func galleryContentDidAppear() {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public protocol GalleryContentViewControllerContainer: AnyObject {
|
||||
public protocol GalleryContentViewControllerContainer {
|
||||
var galleryControlsVisible: Bool { get }
|
||||
|
||||
func setGalleryContentLoading(_ loading: Bool)
|
||||
|
|
|
@ -52,22 +52,12 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
|||
appliedSourceToDestTransform = false
|
||||
}
|
||||
|
||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||
// is in the window's root presentation.
|
||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||
// container causees it to be removed when the transition completes.
|
||||
if to.view.superview == nil {
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
}
|
||||
|
||||
from.view.frame = container.bounds
|
||||
container.addSubview(from.view)
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.layer.masksToBounds = true
|
||||
|
||||
container.addSubview(to.view)
|
||||
container.addSubview(from.view)
|
||||
container.addSubview(content.view)
|
||||
|
||||
content.view.frame = destFrameInContainer
|
||||
|
@ -106,7 +96,7 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
|||
content.view.frame = sourceFrameInContainer
|
||||
content.view.layer.opacity = 0
|
||||
|
||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||
itemViewController.setControlsVisible(false, animated: false)
|
||||
}
|
||||
|
||||
animator.addCompletion { _ in
|
||||
|
@ -122,8 +112,6 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
|||
return
|
||||
}
|
||||
|
||||
toVC.view.frame = transitionContext.containerView.bounds
|
||||
fromVC.view.frame = transitionContext.containerView.bounds
|
||||
transitionContext.containerView.addSubview(toVC.view)
|
||||
transitionContext.containerView.addSubview(fromVC.view)
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import UIKit
|
|||
@MainActor
|
||||
class GalleryDismissInteraction: NSObject {
|
||||
|
||||
private unowned let viewController: GalleryViewController
|
||||
private let viewController: GalleryViewController
|
||||
|
||||
private var content: GalleryContentViewController?
|
||||
private var origContentFrameInGallery: CGRect?
|
||||
|
@ -42,7 +42,7 @@ class GalleryDismissInteraction: NSObject {
|
|||
|
||||
origControlsVisible = viewController.currentItemViewController.controlsVisible
|
||||
if origControlsVisible! {
|
||||
viewController.currentItemViewController.setControlsVisible(false, animated: true, dueToUserInteraction: false)
|
||||
viewController.currentItemViewController.setControlsVisible(false, animated: true)
|
||||
}
|
||||
|
||||
case .changed:
|
||||
|
|
|
@ -43,8 +43,6 @@ class GalleryItemViewController: UIViewController {
|
|||
private(set) var controlsVisible: Bool = true
|
||||
private(set) var scrollAndZoomEnabled = true
|
||||
|
||||
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
||||
|
||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||
return !controlsVisible
|
||||
}
|
||||
|
@ -81,10 +79,10 @@ class GalleryItemViewController: UIViewController {
|
|||
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(overlayVC.view)
|
||||
NSLayoutConstraint.activate([
|
||||
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
overlayVC.view.leadingAnchor.constraint(equalTo: content.view.leadingAnchor),
|
||||
overlayVC.view.trailingAnchor.constraint(equalTo: content.view.trailingAnchor),
|
||||
overlayVC.view.topAnchor.constraint(equalTo: content.view.topAnchor),
|
||||
overlayVC.view.bottomAnchor.constraint(equalTo: content.view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -213,7 +211,7 @@ class GalleryItemViewController: UIViewController {
|
|||
|
||||
updateZoomScale(resetZoom: false)
|
||||
// Ensure the transform is correct if the controls are hidden
|
||||
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
||||
setControlsVisible(controlsVisible, animated: false)
|
||||
|
||||
updateTopControlsInsets()
|
||||
}
|
||||
|
@ -221,15 +219,7 @@ class GalleryItemViewController: UIViewController {
|
|||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
// When the scrollView size changes, make sure the zoom scale is up-to-date since it depends on the scrollView's bounds.
|
||||
// This might also fix an issue on macOS (Designed for iPad) where the content isn't placed correctly. See #446
|
||||
if scrollViewSizeForLastZoomScaleUpdate != scrollView.bounds.size {
|
||||
scrollViewSizeForLastZoomScaleUpdate = scrollView.bounds.size
|
||||
updateZoomScale(resetZoom: true)
|
||||
}
|
||||
centerContent()
|
||||
// Ensure the transform is correct if the controls are hidden and their size changed.
|
||||
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
|
@ -250,7 +240,7 @@ class GalleryItemViewController: UIViewController {
|
|||
func addContent() {
|
||||
content.loadViewIfNeeded()
|
||||
|
||||
content.setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
||||
content.setControlsVisible(controlsVisible, animated: false)
|
||||
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
if content.parent != self {
|
||||
|
@ -290,18 +280,16 @@ class GalleryItemViewController: UIViewController {
|
|||
content.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool) {
|
||||
controlsVisible = visible
|
||||
|
||||
guard let topControlsView,
|
||||
let bottomControlsView else {
|
||||
return
|
||||
}
|
||||
|
||||
func updateControlsViews() {
|
||||
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
|
||||
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
|
||||
content.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
|
||||
content.setControlsVisible(visible, animated: animated)
|
||||
}
|
||||
if animated {
|
||||
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
|
||||
|
@ -315,8 +303,6 @@ class GalleryItemViewController: UIViewController {
|
|||
}
|
||||
|
||||
func updateZoomScale(resetZoom: Bool) {
|
||||
scrollView.contentSize = content.contentSize
|
||||
|
||||
guard scrollAndZoomEnabled else {
|
||||
scrollView.maximumZoomScale = 1
|
||||
scrollView.minimumZoomScale = 1
|
||||
|
@ -378,6 +364,9 @@ class GalleryItemViewController: UIViewController {
|
|||
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
|
||||
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) {
|
||||
// the notch width is not the same for the iPhones 13,
|
||||
// but what we actually want is the same offset from the edges
|
||||
|
@ -387,18 +376,16 @@ class GalleryItemViewController: UIViewController {
|
|||
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
|
||||
shareButtonLeadingConstraint.constant = offset
|
||||
closeButtonTrailingConstraint.constant = offset
|
||||
} else if view.safeAreaInsets.top == 0 {
|
||||
// square corner devices
|
||||
shareButtonLeadingConstraint.constant = 8
|
||||
shareButtonTopConstraint.constant = 8
|
||||
closeButtonTrailingConstraint.constant = 8
|
||||
closeButtonTopConstraint.constant = 8
|
||||
} else {
|
||||
// dynamic island devices
|
||||
} else if islandDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
||||
shareButtonLeadingConstraint.constant = 24
|
||||
shareButtonTopConstraint.constant = 24
|
||||
closeButtonTrailingConstraint.constant = 24
|
||||
closeButtonTopConstraint.constant = 24
|
||||
} else {
|
||||
shareButtonLeadingConstraint.constant = 8
|
||||
shareButtonTopConstraint.constant = 8
|
||||
closeButtonTrailingConstraint.constant = 8
|
||||
closeButtonTopConstraint.constant = 8
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -428,7 +415,7 @@ class GalleryItemViewController: UIViewController {
|
|||
scrollView.zoomScale > scrollView.minimumZoomScale {
|
||||
animateZoomOut()
|
||||
} else {
|
||||
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
|
||||
setControlsVisible(!controlsVisible, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -530,7 +517,7 @@ extension GalleryItemViewController: GalleryContentViewControllerContainer {
|
|||
}
|
||||
|
||||
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
|
||||
setControlsVisible(visible, animated: animated, dueToUserInteraction: false)
|
||||
setControlsVisible(visible, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -545,9 +532,9 @@ extension GalleryItemViewController: UIScrollViewDelegate {
|
|||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||
setControlsVisible(true, animated: true, dueToUserInteraction: true)
|
||||
setControlsVisible(true, animated: true)
|
||||
} else {
|
||||
setControlsVisible(false, animated: true, dueToUserInteraction: true)
|
||||
setControlsVisible(false, animated: true)
|
||||
}
|
||||
|
||||
centerContent()
|
||||
|
|
|
@ -75,7 +75,7 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
|||
container.layoutIfNeeded()
|
||||
|
||||
// This needs to take place after the layout, so that the transform is correct.
|
||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||
itemViewController.setControlsVisible(false, animated: false)
|
||||
|
||||
let duration = self.transitionDuration(using: transitionContext)
|
||||
// rougly equivalent to duration: 0.35, bounce: 0.3
|
||||
|
@ -90,7 +90,7 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
|||
content.view.frame = destFrameInContainer
|
||||
content.view.layer.opacity = 1
|
||||
|
||||
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
|
||||
itemViewController.setControlsVisible(true, animated: false)
|
||||
|
||||
if let sourceToDestTransform {
|
||||
self.sourceView.transform = sourceToDestTransform
|
||||
|
@ -109,6 +109,8 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
|||
itemViewController.addContent()
|
||||
|
||||
transitionContext.completeTransition(true)
|
||||
|
||||
to.presentationAnimationCompleted()
|
||||
}
|
||||
|
||||
animator.startAnimation()
|
||||
|
@ -119,9 +121,8 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
|||
return
|
||||
}
|
||||
|
||||
to.view.alpha = 0
|
||||
to.view.frame = transitionContext.containerView.bounds
|
||||
transitionContext.containerView.addSubview(to.view)
|
||||
to.view.alpha = 0
|
||||
|
||||
let duration = transitionDuration(using: transitionContext)
|
||||
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
|
||||
|
@ -130,6 +131,8 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
|||
}
|
||||
animator.addCompletion { _ in
|
||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||
|
||||
to.presentationAnimationCompleted()
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
|
|
@ -68,17 +68,6 @@ public class GalleryViewController: UIPageViewController {
|
|||
setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false)
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if animated {
|
||||
// Wait until the transition is no longer in-progress, otherwise things will just get deferred again.
|
||||
DispatchQueue.main.async {
|
||||
self.presentationAnimationCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
|
@ -125,8 +114,6 @@ extension GalleryViewController: UIPageViewControllerDataSource {
|
|||
extension GalleryViewController: UIPageViewControllerDelegate {
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
currentItemViewController.content.galleryContentWillDisappear()
|
||||
let new = pendingViewControllers[0] as! GalleryItemViewController
|
||||
new.setControlsVisible(currentItemViewController.controlsVisible, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
|
@ -154,21 +141,14 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
|
|||
|
||||
extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
#if os(visionOS)
|
||||
return nil
|
||||
#else
|
||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
|
||||
return GalleryPresentationAnimationController(sourceView: sourceView)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
#if os(visionOS)
|
||||
return nil
|
||||
#else
|
||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
|
||||
let translation: CGPoint?
|
||||
let velocity: CGPoint?
|
||||
|
@ -184,6 +164,5 @@ extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
|||
} else {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version: 6.0
|
||||
// swift-tools-version: 5.7
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "InstanceFeatures",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -23,15 +23,9 @@ let package = Package(
|
|||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "InstanceFeatures",
|
||||
dependencies: ["Pachyderm"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: ["Pachyderm"]),
|
||||
.testTarget(
|
||||
name: "InstanceFeaturesTests",
|
||||
dependencies: ["InstanceFeatures"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: ["InstanceFeatures"]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -157,7 +157,7 @@ public final class InstanceFeatures: ObservableObject {
|
|||
}
|
||||
|
||||
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||
instanceType.isPleroma
|
||||
instanceType.isPleroma(.akkoma(nil))
|
||||
}
|
||||
|
||||
public var composeDirectStatuses: Bool {
|
||||
|
@ -184,51 +184,6 @@ public final class InstanceFeatures: ObservableObject {
|
|||
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() {
|
||||
}
|
||||
|
||||
|
@ -350,14 +305,6 @@ extension InstanceFeatures {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isPixelfed: Bool {
|
||||
if case .pixelfed = self {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(InstanceType) public enum MastodonType {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version: 6.0
|
||||
// swift-tools-version: 5.8
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "MatchedGeometryPresentation",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
|
@ -18,10 +18,7 @@ let package = Package(
|
|||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "MatchedGeometryPresentation",
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
name: "MatchedGeometryPresentation"),
|
||||
// .testTarget(
|
||||
// name: "MatchedGeometryPresentationTests",
|
||||
// dependencies: ["MatchedGeometryPresentation"]),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version: 6.0
|
||||
// swift-tools-version: 5.6
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "Pachyderm",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// 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 declare other packages that this package depends on.
|
||||
.package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"),
|
||||
.package(url: "https://github.com/karwa/swift-url.git", branch: "main"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
|
@ -26,15 +26,9 @@ let package = Package(
|
|||
dependencies: [
|
||||
.product(name: "WebURL", package: "swift-url"),
|
||||
.product(name: "WebURLFoundationExtras", package: "swift-url"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
.testTarget(
|
||||
name: "PachydermTests",
|
||||
dependencies: ["Pachyderm"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: ["Pachyderm"]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -42,7 +42,7 @@ public struct Client: Sendable {
|
|||
} else if let date = iso8601.date(from: str) {
|
||||
return date
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -204,8 +204,8 @@ public struct Client: Sendable {
|
|||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||
}
|
||||
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -341,10 +341,6 @@ public struct Client: Sendable {
|
|||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
public static func getNotification(id: String) -> Request<Notification> {
|
||||
return Request(method: .get, path: "/api/v1/notifications/\(id)")
|
||||
}
|
||||
|
||||
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||
"types" => allowedTypes.map { $0.rawValue }
|
||||
|
@ -456,13 +452,14 @@ public struct Client: Sendable {
|
|||
}
|
||||
|
||||
// MARK: - Timelines
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
||||
return timeline.request(range: range)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Bookmarks
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -490,7 +487,7 @@ public struct Client: Sendable {
|
|||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
||||
}
|
||||
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
|
|
|
@ -40,9 +40,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
|||
self.displayName = try container.decode(String.self, forKey: .displayName)
|
||||
self.locked = try container.decode(Bool.self, forKey: .locked)
|
||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
|
||||
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
|
||||
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
|
||||
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
|
||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||
self.note = try container.decode(String.self, forKey: .note)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
|
@ -95,8 +94,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
|||
return request
|
||||
}
|
||||
|
||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
"only_media" => onlyMedia,
|
||||
"pinned" => pinned,
|
||||
"exclude_replies" => excludeReplies,
|
||||
|
|
|
@ -1,99 +0,0 @@
|
|||
//
|
||||
// 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,17 +25,6 @@ public struct Attachment: Codable, Sendable {
|
|||
], 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 {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
|
|
|
@ -26,38 +26,6 @@ public struct Card: Codable, Sendable {
|
|||
/// Only present when returned from the trending links endpoint
|
||||
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 {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
|
|
|
@ -43,13 +43,8 @@ extension Emoji: CustomDebugStringConvertible {
|
|||
}
|
||||
}
|
||||
|
||||
extension Emoji: Equatable, Hashable {
|
||||
extension Emoji: Equatable {
|
||||
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
|
||||
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 let url: String
|
||||
public let blurhash: String?
|
||||
public let versions: ThumbnailVersions?
|
||||
public let versions: ThumbnailVersions
|
||||
}
|
||||
|
||||
public struct ThumbnailVersions: Decodable, Sendable {
|
||||
|
@ -120,6 +120,6 @@ extension InstanceV2 {
|
|||
extension InstanceV2 {
|
||||
public struct Contact: Decodable, Sendable {
|
||||
public let email: String
|
||||
public let account: Account?
|
||||
public let account: Account
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Notification: Decodable, Sendable {
|
||||
public let id: String
|
||||
|
@ -15,10 +14,6 @@ public struct Notification: Decodable, Sendable {
|
|||
public let createdAt: Date
|
||||
public let account: Account
|
||||
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 {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
@ -32,8 +27,6 @@ public struct Notification: Decodable, Sendable {
|
|||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.account = try container.decode(Account.self, forKey: .account)
|
||||
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||
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> {
|
||||
|
@ -46,8 +39,6 @@ public struct Notification: Decodable, Sendable {
|
|||
case createdAt = "created_at"
|
||||
case account
|
||||
case status
|
||||
case emoji
|
||||
case emojiURL = "emoji_url"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,7 +52,6 @@ extension Notification {
|
|||
case poll
|
||||
case update
|
||||
case status
|
||||
case emojiReaction = "pleroma:emoji_reaction"
|
||||
case unknown
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ public struct Poll: Codable, Sendable {
|
|||
public let votesCount: Int
|
||||
public let votersCount: Int?
|
||||
public let voted: Bool?
|
||||
public let ownVotes: [Int?]?
|
||||
public let ownVotes: [Int]?
|
||||
public let options: [Option]
|
||||
public let emojis: [Emoji]
|
||||
|
||||
|
@ -33,7 +33,7 @@ public struct Poll: Codable, Sendable {
|
|||
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
|
||||
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
|
||||
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.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||
}
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
//
|
||||
// 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,144 +9,16 @@
|
|||
import Foundation
|
||||
|
||||
public struct PushSubscription: Decodable, Sendable {
|
||||
public var id: String
|
||||
public var endpoint: URL
|
||||
public var serverKey: String
|
||||
public var alerts: 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")
|
||||
}
|
||||
public let id: String
|
||||
public let endpoint: URL
|
||||
public let serverKey: String
|
||||
// TODO: WTF is this?
|
||||
// public let alerts
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case endpoint
|
||||
case serverKey = "server_key"
|
||||
case alerts
|
||||
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
|
||||
// case alerts
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,13 +27,10 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
|
|||
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
||||
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
||||
self.muting = try container.decode(Bool.self, forKey: .muting)
|
||||
// not supported on pixelfed
|
||||
self.mutingNotifications = try container.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false
|
||||
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
|
||||
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
||||
// not supported on pixelfed
|
||||
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.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
|
||||
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
|
||||
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@ public enum Scope: String, Sendable {
|
|||
case read
|
||||
case write
|
||||
case follow
|
||||
case push
|
||||
}
|
||||
|
||||
extension Array where Element == Scope {
|
||||
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
|
||||
public struct SearchResults: Decodable, Sendable {
|
||||
public let accounts: [Account]
|
||||
public let statuses: [TryDecode<Status>]
|
||||
public let statuses: [Status]
|
||||
public let hashtags: [Hashtag]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
|
|
@ -46,8 +46,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
public let localOnly: Bool?
|
||||
public let editedAt: Date?
|
||||
|
||||
public let pleromaExtras: PleromaExtras?
|
||||
|
||||
public var applicationName: String? { application?.name }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
|
@ -100,8 +98,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
||||
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
||||
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> {
|
||||
|
@ -124,12 +120,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
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> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
|
||||
}
|
||||
|
@ -222,15 +212,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
case poll
|
||||
case localOnly = "local_only"
|
||||
case editedAt = "edited_at"
|
||||
|
||||
case pleromaExtras = "pleroma"
|
||||
}
|
||||
}
|
||||
|
||||
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<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
|
||||
func request(range: RequestRange) -> Request<[Status]> {
|
||||
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
|
||||
if case .public(true) = self {
|
||||
request.queryParameters.append("local" => true)
|
||||
}
|
||||
|
|
|
@ -7,83 +7,34 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct TimelineMarkers {
|
||||
private init() {}
|
||||
public struct TimelineMarkers: Decodable, Sendable {
|
||||
public let home: Marker?
|
||||
public let notifications: Marker?
|
||||
|
||||
public static func request<T: TimelineMarkerType>(timeline: T) -> Request<TimelineMarker<T.Payload>> {
|
||||
Request(method: .get, path: "/api/v1/markers", queryParameters: ["timeline[]" => T.name])
|
||||
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> {
|
||||
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue))
|
||||
}
|
||||
|
||||
public static func update<T: TimelineMarkerType>(timeline: T, lastReadID: String) -> Request<Empty> {
|
||||
Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
||||
"\(T.name)[last_read_id]" => lastReadID
|
||||
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> {
|
||||
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
||||
"\(timeline.rawValue)[last_read_id]" => lastReadID,
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
public struct TimelineMarker<Payload: TimelineMarkerTypePayload>: Decodable, Sendable {
|
||||
let payload: Payload
|
||||
|
||||
public var lastReadID: String {
|
||||
payload.payload.lastReadID
|
||||
public enum Timeline: String {
|
||||
case home
|
||||
case notifications
|
||||
}
|
||||
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 version: Int
|
||||
public let updatedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case lastReadID = "last_read_id"
|
||||
case version
|
||||
case updatedAt = "updated_at"
|
||||
public struct Marker: Decodable, Sendable {
|
||||
public let lastReadID: String
|
||||
public let version: Int
|
||||
public let updatedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case lastReadID = "last_read_id"
|
||||
case version
|
||||
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,18 +7,17 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||
public private(set) var notifications: [Notification]
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let kind: Notification.Kind
|
||||
|
||||
public init?(notifications: [Notification], kind: Kind) {
|
||||
public init?(notifications: [Notification]) {
|
||||
guard !notifications.isEmpty else { return nil }
|
||||
self.notifications = notifications
|
||||
self.id = notifications.first!.id
|
||||
self.kind = kind
|
||||
self.kind = notifications.first!.kind
|
||||
}
|
||||
|
||||
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
||||
|
@ -44,62 +43,31 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
private mutating func append(group: NotificationGroup) {
|
||||
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
|
||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
var groups = [NotificationGroup]()
|
||||
for notification in notifications {
|
||||
let groupKind = groupKind(for: notification)
|
||||
|
||||
if allowedTypes.contains(notification.kind) {
|
||||
if let lastGroup = groups.last, canMerge(notification: notification, kind: groupKind, into: lastGroup) {
|
||||
if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) {
|
||||
groups[groups.count - 1].append(notification)
|
||||
continue
|
||||
} else if groups.count >= 2 {
|
||||
let secondToLastGroup = groups[groups.count - 2]
|
||||
if allowedTypes.contains(notification.kind), canMerge(notification: notification, kind: groupKind, into: secondToLastGroup) {
|
||||
if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) {
|
||||
groups[groups.count - 2].append(notification)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
|
||||
groups.append(NotificationGroup(notifications: [notification])!)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
|
||||
return kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
||||
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
|
||||
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
||||
}
|
||||
|
||||
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
|
@ -114,21 +82,21 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
var second = second
|
||||
merged.reserveCapacity(second.count)
|
||||
while let firstGroupFromSecond = second.first,
|
||||
allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
|
||||
allowedTypes.contains(firstGroupFromSecond.kind) {
|
||||
|
||||
second.removeFirst()
|
||||
|
||||
guard let lastGroup = merged.last,
|
||||
allowedTypes.contains(lastGroup.kind.notificationKind) else {
|
||||
allowedTypes.contains(lastGroup.kind) else {
|
||||
merged.append(firstGroupFromSecond)
|
||||
break
|
||||
}
|
||||
|
||||
if canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: lastGroup) {
|
||||
if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) {
|
||||
merged[merged.count - 1].append(group: firstGroupFromSecond)
|
||||
} else if merged.count >= 2 {
|
||||
let secondToLastGroup = merged[merged.count - 2]
|
||||
if allowedTypes.contains(secondToLastGroup.kind.notificationKind), canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: secondToLastGroup) {
|
||||
if allowedTypes.contains(secondToLastGroup.kind), canMerge(notification: firstGroupFromSecond.notifications.first!, into: secondToLastGroup) {
|
||||
merged[merged.count - 2].append(group: firstGroupFromSecond)
|
||||
} else {
|
||||
merged.append(firstGroupFromSecond)
|
||||
|
@ -141,42 +109,4 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
//
|
||||
// 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 {
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -1,67 +0,0 @@
|
|||
<?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>
|
|
@ -1,39 +0,0 @@
|
|||
// 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)
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
|
@ -1,47 +0,0 @@
|
|||
//
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
|
@ -1,203 +0,0 @@
|
|||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
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: 6.0
|
||||
// swift-tools-version: 5.7
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "TTTKit",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -23,15 +23,9 @@ let package = Package(
|
|||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "TTTKit",
|
||||
dependencies: [],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: []),
|
||||
.testTarget(
|
||||
name: "TTTKitTests",
|
||||
dependencies: ["TTTKit"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: ["TTTKit"]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version: 6.0
|
||||
// swift-tools-version: 5.7
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "TuskerComponents",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -23,10 +23,7 @@ let package = Package(
|
|||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "TuskerComponents",
|
||||
dependencies: [],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
dependencies: []),
|
||||
// .testTarget(
|
||||
// name: "TuskerComponentsTests",
|
||||
// dependencies: ["TuskerComponents"]),
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
|
@ -47,7 +47,9 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
|||
|
||||
private func makeConfiguration() -> UIButton.Configuration {
|
||||
var config = UIButton.Configuration.borderless()
|
||||
config.indicator = .popup
|
||||
if #available(iOS 16.0, *) {
|
||||
config.indicator = .popup
|
||||
}
|
||||
if buttonStyle.hasIcon {
|
||||
config.image = selectedOption.image
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"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: 6.0
|
||||
// swift-tools-version: 5.8
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "TuskerPreferences",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
|
@ -22,17 +22,7 @@ let package = Package(
|
|||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "TuskerPreferences",
|
||||
dependencies: ["Pachyderm"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]
|
||||
dependencies: ["Pachyderm"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "TuskerPreferencesTests",
|
||||
dependencies: ["TuskerPreferences"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
@ -1,282 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
//
|
||||
// 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:)))
|
||||
}
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
//
|
||||
// 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 }
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
//
|
||||
// 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 }
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
//
|
||||
// DigitalWellnessKeys.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NotificationsModeKey: MigratablePreferenceKey {
|
||||
static var defaultValue: NotificationsMode { .allNotifications }
|
||||
}
|
|
@ -1,205 +0,0 @@
|
|||
//
|
||||
// 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 {}
|
|
@ -1,106 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ public enum PostVisibility: Codable, Hashable, CaseIterable, Sendable {
|
|||
case serverDefault
|
||||
case visibility(Visibility)
|
||||
|
||||
public private(set) static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
|
||||
public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
|
||||
|
||||
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
|
||||
switch self {
|
||||
|
@ -57,7 +57,7 @@ public enum ReplyVisibility: Codable, Hashable, CaseIterable {
|
|||
case sameAsPost
|
||||
case visibility(Visibility)
|
||||
|
||||
public private(set) static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
|
||||
public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
|
||||
|
||||
@MainActor
|
||||
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
|
|
@ -1,101 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
//
|
||||
// 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?
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
//
|
||||
// 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,42 +2,426 @@
|
|||
// Preferences.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/12/24.
|
||||
// Created by Shadowfacts on 8/28/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
public final class Preferences: Codable, ObservableObject {
|
||||
|
||||
public struct Preferences {
|
||||
@MainActor
|
||||
public static let shared: PreferenceStore = load()
|
||||
public static var shared: Preferences = load()
|
||||
|
||||
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 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() {}
|
||||
private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||
|
||||
@MainActor
|
||||
public static func save() {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(PreferenceCoding(wrapped: shared))
|
||||
try? data?.write(to: preferencesURL, options: .noFileProtection)
|
||||
let data = try? encoder.encode(shared)
|
||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
||||
}
|
||||
|
||||
private static func load() -> PreferenceStore {
|
||||
public static func load() -> Preferences {
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: preferencesURL),
|
||||
let store = try? decoder.decode(PreferenceCoding<PreferenceStore>.self, from: data) {
|
||||
return store.wrapped
|
||||
} else if let legacyData = (try? Data(contentsOf: legacyURL)) ?? (try? Data(contentsOf: nonAppGroupURL)),
|
||||
let legacy = try? decoder.decode(LegacyPreferences.self, from: legacyData) {
|
||||
let store = PreferenceStore()
|
||||
store.migrate(from: legacy)
|
||||
return store
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let preferences = try? decoder.decode(Preferences.self, from: data) {
|
||||
return preferences
|
||||
}
|
||||
return Preferences()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
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 {
|
||||
return PreferenceStore()
|
||||
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.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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,86 +0,0 @@
|
|||
//
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
//
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
//
|
||||
// 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"
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
//
|
||||
// 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