Compare commits
No commits in common. "develop" and "2023.5-97" have entirely different histories.
|
@ -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,224 +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.
|
|
||||||
|
|
||||||
Features/Improvements:
|
|
||||||
- Show search operators on Mastodon 4.2
|
|
||||||
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
|
|
||||||
- Allow changing list reply policy and exclusivity options on Edit List screen
|
|
||||||
- Add Translate action to conversations (on supported Mastodon instances)
|
|
||||||
- Style block quotes correclty in rich-text posts
|
|
||||||
- Improve the appearance of lists in rich-text posts
|
|
||||||
- Add preference to underline links
|
|
||||||
- Compress uploaded video attachments to fit within instance limits
|
|
||||||
- Add preference to hide attachments in timelines
|
|
||||||
- Update visible timestamps after refresh notifications/timelines
|
|
||||||
- iPadOS: Allow switching between split screen and fullscreen navigation modes
|
|
||||||
- Pixelfed: Improve error message when uploading attachment fails
|
|
||||||
- Akkoma: Enable composing local-only posts
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix older notifications not loading if all initiially-loaded ones are grouped together
|
|
||||||
- Fix List timelines failing to refresh if they were initially empty
|
|
||||||
- Fix replies to posts with CWs always showing confirmation dialog when cancelling
|
|
||||||
- Fix Compose screen permitting setting the language to multiple/undefined
|
|
||||||
- Fix crash when uploading attachments without file extensions
|
|
||||||
- Fix Live Text button reappearing with swiping between attachment gallery pages
|
|
||||||
- Fix avatars on certain notifications flickering when refreshing
|
|
||||||
- Fix avatars on follow request notifications not being rounded
|
|
||||||
- Fix timeline jump button appearing incorrectly when Button Shapes acccessibility setting is on
|
|
||||||
- Fix public instance timeline screen not handling post deletion correctly
|
|
||||||
- Fix post that's reblogged and contains a followed hashtag not showing the reblogger
|
|
||||||
- Fix crash on launch when reblogged posts are visible
|
|
||||||
- Fix crash when showing display names with custom emoji in certain places
|
|
||||||
- Fix crash when showing trending hashtags without history data
|
|
||||||
- Fix potential crash on instance selector screen
|
|
||||||
- Fix potential crash if the app is dismissed while fast account switcher is animating
|
|
||||||
- Fix potential crash after deleting List on the Eplore screen
|
|
||||||
- Pixelfed: Fix error decoding certain posts
|
|
||||||
- VoiceOver: Fix history entries on Edit History screen not having descriptions
|
|
||||||
- iPadOS: Fix delay on app launch before "My Profile" sidebar item appears
|
|
||||||
- iPadOS: Fix language picker button not highlighting when hovered with the cursor
|
|
||||||
- macOS: Fix "New Post" window title appearing twice
|
|
||||||
- macOS: Fix Cmd+W sometimes closing non-foreground windows
|
|
||||||
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
|
|
||||||
- macOS: Fix images copied from Safari not pasting on Compose screen
|
|
||||||
|
|
||||||
## 2023.7
|
|
||||||
This update adds support for iOS 17 and includes some minor changes.
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
- Support iOS 17
|
|
||||||
- Indicate that edit history may be incomplete for remote posts
|
|
||||||
- Fix crash when collapsing to tab-bar mode in certain circumstances
|
|
||||||
- Fix potential crashes when using autocomplete on the Compose screen
|
|
||||||
- Fix Iceshrimp instances not being detected
|
|
||||||
|
|
||||||
## 2023.6
|
|
||||||
This update fixes a number of bugs and improves stability throughout the app. See below for a list of fixes.
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix issues displaying main post in the Conversation screen
|
|
||||||
- Fix crash when opening the Compose screen in certain locales
|
|
||||||
- Fix issues when collapsing from sidebar to tab bar mode
|
|
||||||
- Fix incorrect UI being displayed when accessing certain parts of the app immediately after launch
|
|
||||||
- Fix link card images not being blurred on posts marked sensitive
|
|
||||||
- Fix links appearing with incorrect accent color intermittently
|
|
||||||
- Fix being unable to remove followed hashtags from the Explore screen
|
|
||||||
- Akkoma: Fix not being able to follow hashtags
|
|
||||||
- Pleroma: Fix refreshing Mentions failing
|
|
||||||
- iPhone: Fix ducked Compose screen disappearing when rotating on large phones
|
|
||||||
|
|
||||||
## 2023.5
|
|
||||||
This update adds new several Compose-related features, including the ability to edit posts, a share sheet extension, and a post language picker. See below for the full list of improvements and bugfixes.
|
|
||||||
|
|
||||||
Features/Improvements:
|
|
||||||
- Edit posts
|
|
||||||
- Indicate edited posts in timestamp
|
|
||||||
- Show post edit history from Conversation screen
|
|
||||||
- Add Share Sheet extension
|
|
||||||
- Add expanded attachment view on Compose screen
|
|
||||||
- Add an attachment, select the description text field, then tap the expand button
|
|
||||||
- Expanded view allows you to see the attachment while writing the description
|
|
||||||
- Allows playing back videos while writing description
|
|
||||||
- iOS 16: Allows zooming in to the attachment
|
|
||||||
- Add language picker to the Compose screen
|
|
||||||
- Improve Compose screen ducking behavior
|
|
||||||
- Show reblogger's avatar on reblogged posts
|
|
||||||
- Use system photo picker instead of custom interface
|
|
||||||
- Improve hashtag search UI in Customize Timelines
|
|
||||||
- Improve status collapse/expand animation on Notifications screen
|
|
||||||
- Apply filters to Notifications screen
|
|
||||||
- Improve performance when scrolling through timeline
|
|
||||||
- Improve error messages when editing filters
|
|
||||||
- Change favorite/reblog button order to match Mastodon UI
|
|
||||||
- Gracefully handle unknown attachment types
|
|
||||||
- iPadOS: Persist sidebar visibility across
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix scroll-to-top not working in in-app Safari
|
|
||||||
- Fix inaccruate titles in certain error popups
|
|
||||||
- Fix error decoding post HTML
|
|
||||||
- Fix replied-to account not being the first @-mention
|
|
||||||
- Fix "No Content" message on profiles using wrong background color
|
|
||||||
- Fix reblogged posts appearing in Bookmarks
|
|
||||||
- Fix spurious errors when loading timeline
|
|
||||||
- Fix crash when displaying certain profiles
|
|
||||||
- Fix crash when the server returns invalid notifications
|
|
||||||
- Fix link previews not appearing in Notifications
|
|
||||||
- Fix Notifications screen taking a long time to load
|
|
||||||
- Fix deleted posts not being removed from Notifications screen
|
|
||||||
- Fix crashes when switching between sidebar/tab-bar modes
|
|
||||||
- Fix instance features not being detected on IDNA domains
|
|
||||||
- Fix list/hashtag timelines missing controls when opened in new window
|
|
||||||
- Fix reblog button being enabled on the user's own direct posts
|
|
||||||
- Fix main post in Conversation flickering
|
|
||||||
- Fix link card images not loading on Mastodon
|
|
||||||
- Fix crash when editing filter with the Hide action
|
|
||||||
- Fix certain remote status links not being resolved
|
|
||||||
- Fix Handoff to iPad/Mac presenting new screen modally
|
|
||||||
- GoToSocial: Fix decoding certain posts
|
|
||||||
- Calckey: Fix decoding certain posts
|
|
||||||
- iPadOS: Fix Compose window lacking a title
|
|
||||||
- iPadOS: Fix keyboard focus highlight not showing
|
|
||||||
- macOS: Fix sidebar keyboard shortcuts not working
|
|
||||||
|
|
||||||
## 2023.4
|
## 2023.4
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Add preference for non-pure-black dark mode
|
- Add preference for non-pure-black dark mode
|
||||||
|
|
328
CHANGELOG.md
328
CHANGELOG.md
|
@ -1,333 +1,5 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2024.4 (136)
|
|
||||||
Features/Improvements:
|
|
||||||
- Import image description when adding attachments from Photos if possible
|
|
||||||
- Reorganize toolbar buttons when adding saved hashtag
|
|
||||||
- Show errors when loading video in attachment gallery fails
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix crash when viewing profiles in certain circumstances
|
|
||||||
- Fix profile tab switching animation getting stuck
|
|
||||||
- Fix video controls in attachment gallery not auto-hiding
|
|
||||||
- Pleroma: Fix error when loading polls in some circumstances
|
|
||||||
- iPadOS 18: Fix incorrect two-column layout when closing sidebar
|
|
||||||
- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
|
|
||||||
- macOS: Fix reselecting current item not navigating back
|
|
||||||
|
|
||||||
## 2024.4 (135)
|
|
||||||
Features/Improvements:
|
|
||||||
- iOS 18: New floating sidebar/tab bar
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix crash when hashtag search results include duplicates
|
|
||||||
- Fix "no content" text not being removed from list timeline after refreshing
|
|
||||||
|
|
||||||
## 2024.3 (133)
|
|
||||||
- Add additional info to Tip Jar
|
|
||||||
|
|
||||||
## 2024.3 (132)
|
|
||||||
- Add ToS nag before signing in
|
|
||||||
|
|
||||||
## 2024.3 (131)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix Cmd+3 not correctly switching to Explore tab
|
|
||||||
|
|
||||||
## 2024.3 (130)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
|
|
||||||
- Fix crash when dragging between buttons in reblog confirmation alert
|
|
||||||
- Fix potential crash when displaying search results
|
|
||||||
- Mac: Fix Post button not displaying on Compose screen
|
|
||||||
|
|
||||||
## 2024.3 (129)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix excessive network traffic on profile pages
|
|
||||||
- Fix attachment gallery controls visibility not being synced between pages
|
|
||||||
- Fix video attachments not restarting when play pressed while at ends
|
|
||||||
- Fix profile field text being misaligned
|
|
||||||
- Fix at sign in timeline statuses usernames sometimes clipping
|
|
||||||
- Fix add hashtag/instance to Pinned Timelines sheets dismissing immediately when opened
|
|
||||||
- Fix for display name being replaced with incorrect user in certain circumstances
|
|
||||||
- Fix profile moved overlay view appearing behind avatar/header
|
|
||||||
- Fix profile moved view accessibility with VoiceOver
|
|
||||||
- Fix mention/status push notifications not showing content warning
|
|
||||||
- Fix sensitive attachment thumbnails being shown in push notifications
|
|
||||||
- Fix Dynamic Type not applying to status content
|
|
||||||
- Fix expand all option in Conversation not transferring when opening ancestors
|
|
||||||
- Fix not being able to resolve remote Mastodon status links in Conversation screen
|
|
||||||
- Fix status indicator icons overlapping thread links when Dynamic Type is enabled
|
|
||||||
|
|
||||||
## 2024.3 (128)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix selecting poll option playing too much haptic feedback
|
|
||||||
- Fix crash when displaying HTML in certain posts
|
|
||||||
- Fix gifv playback pausing audio from other apps
|
|
||||||
- Fix gifv playback not resuming after returning from background
|
|
||||||
- Fix attachment badges not appearing on gifvs
|
|
||||||
- iPadOS: Fix poll options not having pointer hover effects
|
|
||||||
- iPadOS: Fix haptic feedback not working on new Magic Keyboard
|
|
||||||
- iPadOS: Fix scrubbing video with pointer not letting you click to select position
|
|
||||||
- iPadOS: Fix multi-column navigation not animating when replacing multiple columns
|
|
||||||
|
|
||||||
## 2024.3 (127)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen
|
|
||||||
- Fix profile header images being blurry
|
|
||||||
- Fix dismissing gallery when presented from sheet
|
|
||||||
- Fix potential crash in multi-column interface
|
|
||||||
- Fix crash when opening push notification while sheet presented
|
|
||||||
- Fix being able to block your own domain
|
|
||||||
- Fix links in profile fields with other text not being interactable
|
|
||||||
- Fix excessive CPU use immediately after app launch
|
|
||||||
- Fix timeline failing to load when one status is malformed
|
|
||||||
- iPadOS: Fix pointer interactions on conversation main status action buttons
|
|
||||||
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
|
||||||
- iPadOS: Fix Cmd+1/etc. resetting navigation state when returning to previous column
|
|
||||||
- iPadOS: Fix previous sidebar selection losing navigation state in some circumstances
|
|
||||||
- iPadOS: Fix profile followers/following buttons not having pointer effect
|
|
||||||
- iPadOS: Fix search token suggestions not having pointer effect
|
|
||||||
- iPadOS: Fix conversation thread links appearing above avatar during pointer effect
|
|
||||||
- iPadOS: Fix multi-column interface not animating scroll when replacing subsequent columns
|
|
||||||
- iPadOS: Fix not being able to select text on conversation main status by double-clicking with cursor
|
|
||||||
- iPadOS: Fix selecting search result always pushing new column rather than replacing
|
|
||||||
- Pixelfed/Firefish: Fix error loading accounts in some circumstances
|
|
||||||
- Pixelfed: Fix loading relationships and follow/block/etc. actions not working
|
|
||||||
|
|
||||||
## 2024.3 (126)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix an issue displaying post HTML in certain edge cases
|
|
||||||
- Fix crash when video attachment playback ends
|
|
||||||
- Fix excessive CPU usage when scrubbing video attachment
|
|
||||||
- Fix video attachment thubmnails being flipped on Compose screen
|
|
||||||
- Pleroma: Fix editing attachment descriptions not working
|
|
||||||
|
|
||||||
## 2024.2 (124)
|
|
||||||
Features/Improvements:
|
|
||||||
- Add subscription option to Tip Jar
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix attachment captions not displaying while loading in gallery
|
|
||||||
- Fix tapping follow request push notification not working
|
|
||||||
- Pleroma: Handle posts with missing creation dates
|
|
||||||
|
|
||||||
## 2024.2 (122)
|
|
||||||
Features/Improvements:
|
|
||||||
- Show instance announcements in Notifications
|
|
||||||
- Pleroma/Akkoma: Display emoji reactions in Notifications
|
|
||||||
- Pleroma/Akkoma: Add push notifications for emoji reactions
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix issue fetching server info on some instances
|
|
||||||
- Fix Preferences background color not updating after changing Pure Black Dark Mode
|
|
||||||
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
|
|
||||||
|
|
||||||
## 2024.2 (121)
|
|
||||||
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.
|
|
||||||
|
|
||||||
Features/Improvements:
|
|
||||||
- iPadOS: Enable multi-column navigation
|
|
||||||
- Add post preview to Appearance preferences
|
|
||||||
- Consolidate Media preferences section with Appearance
|
|
||||||
- Add icons to Preferences sections
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix push notifications not working on Pleroma/Akkoma and older Mastodon versions
|
|
||||||
- Fix push notifications not working with certain accounts
|
|
||||||
- Fix links on About screen not being aligned
|
|
||||||
- macOS: Remove non-functional in-app Safari preferences
|
|
||||||
|
|
||||||
## 2024.2 (120)
|
|
||||||
This build adds push notifications, which can be enabled in Preferences -> Notifications.
|
|
||||||
|
|
||||||
## 2024.1 (119)
|
|
||||||
Features/Improvements:
|
|
||||||
- Add Account Settings button to Preferences
|
|
||||||
|
|
||||||
## 2024.1 (118)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix music not pausing/resuming when video playback starts
|
|
||||||
|
|
||||||
## 2024.1 (117)
|
|
||||||
Features/Improvements:
|
|
||||||
- Add See Results button to polls
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix race condition when presenting gallery for 4th of more than 4 attachments
|
|
||||||
- Fix gallery interactive dismissal not working for 4th or later attachments on posts with more than 4 attachments
|
|
||||||
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
|
|
||||||
- macOS: Fix gallery being positioned incorrectly when Reduce Motion is on
|
|
||||||
|
|
||||||
## 2024.1 (116)
|
|
||||||
Features/Improvements:
|
|
||||||
- Display message on empty list timelines
|
|
||||||
- Add preference to display badge for attachments that lack alt text
|
|
||||||
- Mark notifications as read on the Mastodon web frontend once displayed
|
|
||||||
- iPadOS: Support tapping the selected sidebar item to scroll to top
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix playing back GIFVs preventing the device sleeping
|
|
||||||
- Fix incorrect cell separator insets followers/following lists
|
|
||||||
- Fix memory leak in attachments gallery
|
|
||||||
- Fix notifications tab not scrolling to top when tab bar item tapped
|
|
||||||
- Fix Trending Hashtags screen not clearing selection
|
|
||||||
- Fix fast account switcher overlapping sensor housing on landscape iPhones
|
|
||||||
- Fix Edit List screen not updating when accounts are added/removed
|
|
||||||
- Fix changing List reply policy not refreshing list timeline
|
|
||||||
- macOS: Fix certain gallery attachments being incorrectly sized/positioned
|
|
||||||
|
|
||||||
## 2024.1 (115)
|
|
||||||
Features/Improvements:
|
|
||||||
- Rewrite attachment gallery
|
|
||||||
- Fixes a number of long-standing issues
|
|
||||||
- Adds a custom video player that shows controls and caption
|
|
||||||
- Supports sharing/saving videos
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix hang when sharing video/gifv attachments
|
|
||||||
- Fix stretched icon for Save to Photos action when sharing attachment
|
|
||||||
- Fix crash when Compose screen is dismissed while adding attachments
|
|
||||||
- Fix crash when sharing attachment from context menu on iPad
|
|
||||||
|
|
||||||
## 2024.1 (113)
|
|
||||||
Features/Improvements:
|
|
||||||
- Add Share and Save to Photos context menu actions to attachments
|
|
||||||
- Show verified link in account lists
|
|
||||||
- Change cell separator appearance on posts
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix tapping Followers button on profiles opening Following screen
|
|
||||||
- Fix crash when removing poll option on Compose screen
|
|
||||||
- Fix leading indentation in post text being ignored
|
|
||||||
- Fix crash when viewing posts containing HTML numeric character references
|
|
||||||
- Fix paragraphs starting with links being combined with previous paragraph
|
|
||||||
|
|
||||||
## 2024.1 (112)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix profile field links not displaying
|
|
||||||
- Fix various issues displaying rich text in posts
|
|
||||||
- Fix issue changing scope after searching
|
|
||||||
- Fix crash when searching for "from:me"
|
|
||||||
|
|
||||||
## 2024.1 (111)
|
|
||||||
This build contains a complete rewrite of the HTML parsing pipeline for displaying posts. If you notice any issues with how post text appears—especially when it differs from on the web—please report it!
|
|
||||||
|
|
||||||
## 2023.8 (110)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix potential crash after deleting List on Explore screen
|
|
||||||
|
|
||||||
## 2023.8 (109)
|
|
||||||
Features/Improvements:
|
|
||||||
- Add Translate action to conversations (on supported Mastodon instances)
|
|
||||||
- Improve share extension launch speed
|
|
||||||
- Add preference for hiding attachments in timelines
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix crash during state restoration when reblogged statuses are present
|
|
||||||
- Fix timeline state restoration using incorrect scroll position in certain circumstances
|
|
||||||
- Fix status that is reblogged and contains a followed hashtag not showing reblogger label
|
|
||||||
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
|
|
||||||
- macOS: Fix images copied from Safari not pasting on Compose screen
|
|
||||||
|
|
||||||
## 2023.8 (107)
|
|
||||||
Features/Improvements:
|
|
||||||
- Style blockquotes in statuses
|
|
||||||
- Use server language preference for search operator suggestions
|
|
||||||
- Render IDN domains in the account switcher
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix crash when showing trending hashtags with improper history data
|
|
||||||
- Fix crash when uploading attachment w/o file extension
|
|
||||||
- Fix status deletions not being handled properly in logged out views
|
|
||||||
- Fix status history entries not having VoiceOver descriptions
|
|
||||||
- Fix avatars in follow request notifications not being rounded
|
|
||||||
- Fix potential crash if the app is dismissed while fast account switcher is animating
|
|
||||||
- Fix error decoding certain statuses on Pixelfed
|
|
||||||
|
|
||||||
## 2023.8 (106)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix being able to set post language to multiple/undefined
|
|
||||||
- iPadOS: Fix language picker button not having a pointer effect
|
|
||||||
- macOS: Fix Cmd+W sometimes closing the non-foreground window
|
|
||||||
|
|
||||||
## 2023.8 (105)
|
|
||||||
Features/Improvements:
|
|
||||||
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
|
|
||||||
- Add preference to underline links
|
|
||||||
- Allow changing list reply policy and exclusivity from menu on Edit List screen
|
|
||||||
- Attribute network requests to user, rather than developer, when appropriate
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix older notifications not loading if all initially-loaded are grouped together
|
|
||||||
- Fix list timelines failing to refresh if there were no statuses initially
|
|
||||||
- Fix timeline jump button having a background when Button Shapes accessibility setting is on
|
|
||||||
- Fix crash when relaunching app after not being launched in more than a week
|
|
||||||
- Fix potential crash on instance selector screen
|
|
||||||
- Fix crash when showing display names with custom emojis in certain places
|
|
||||||
|
|
||||||
## 2023.8 (104)
|
|
||||||
Features/Improvements:
|
|
||||||
- Show search operators on Mastodon 4.2
|
|
||||||
- Enable composing local-only posts on Akkoma
|
|
||||||
- Update timestamps after refreshing notifications/timelines
|
|
||||||
- Improve list appearance in rich text posts
|
|
||||||
- Improve error message when uploading attachment to Pixelfed fails
|
|
||||||
- Compress uploaded videos to fit within instance limits
|
|
||||||
- iPad: Allow switching between split screen and full screen navigation
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix replies to posts with content warnings always showing confirmation dialog before closing
|
|
||||||
- Fix Live Text control reappearing when swiping between attachment gallery pages
|
|
||||||
- Fix avatars on certain notifications flickering when refreshing
|
|
||||||
- iPad: Fix delay on app launch before "My Profile" sidebar item appears
|
|
||||||
- macOS: Fix "New Post" window title appearing twice
|
|
||||||
|
|
||||||
## 2023.7 (103)
|
|
||||||
Features/Improvements:
|
|
||||||
- Add support for iOS 17
|
|
||||||
- Indicate that edit history may be incomplete for remote posts
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix crash when collapsing to tab-bar mode in certain circumstances
|
|
||||||
- Fix potential crashes when using autocomplete on the Compose screen
|
|
||||||
- Fix Iceshrimp instances not being detected
|
|
||||||
|
|
||||||
## 2023.6 (100)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix Conversation main post flashing incorrect background color when touched
|
|
||||||
- Fix reblogs count button in Conversation main post not being left-aligned
|
|
||||||
- Fix Conversation main post flickering when context loaded
|
|
||||||
- Fix context menu not appearing when long pressing finished/voted poll
|
|
||||||
- Fix Tip Jar button width changing while purchasing
|
|
||||||
- Fix crash when opening Compose screen in certain locales
|
|
||||||
- Fix potential issue with Recognize Text context menu action on attachments
|
|
||||||
- Fix attachment deletion context menu action not working
|
|
||||||
- Fix crash when collapsing from sidebar to tab bar mode
|
|
||||||
- Fix crash when post deleted before Notifications screen is loaded
|
|
||||||
- Fix race conditions when accessing certain parts of the app immediately upon launch
|
|
||||||
- Fix crash when viewing invalid user post notifications
|
|
||||||
- Fix non-square avatars not displaying correctly in various places
|
|
||||||
- Fix incorrect context menu preview being shown on filtered posts
|
|
||||||
- Fix link card images not being blurred on sensitive posts
|
|
||||||
- Fix reblog confirmation alert showing incorrect visibilities for non-public posts
|
|
||||||
- Fix Home/Notifications tab switchers being cut off with smaller than default Dynamic Type sizes
|
|
||||||
- Fix posts using incorrect accent color for links in certain circumstances
|
|
||||||
- Fix not being able to remove followed hashtags from Explore screen
|
|
||||||
- Fix not being able to attach images from Markup share sheet or Shortcuts share action
|
|
||||||
- Fix very wide attachments being untappably short
|
|
||||||
- Fix double posting in poor network conditions
|
|
||||||
- Fix crash when autocompleting emoji on instances with a large number of custom emoji
|
|
||||||
- Akkoma: Fix not being able to follow hashtags
|
|
||||||
- Pleroma: Fix refreshing Mentions failing
|
|
||||||
- iPhone: Fix ducked Compose screen breaking when rotating on Plus/Max iPhone models
|
|
||||||
- iPhone: Fix Compose toolbar not extending to the full width of the screen in landscape on iPhone
|
|
||||||
- iPadOS: Fix closing app dismissing in-app Safari
|
|
||||||
- iPadOS: Fix reblog confirmation alert not being centered in split view
|
|
||||||
|
|
||||||
## 2023.5 (98)
|
|
||||||
Bugfixes:
|
|
||||||
- Fix broken animation when opening/closing expanded attachment view on Compose screen
|
|
||||||
|
|
||||||
## 2023.5 (97)
|
## 2023.5 (97)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Change favorite/reblog button order to match Mastodon
|
- Change favorite/reblog button order to match Mastodon
|
||||||
|
|
|
@ -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>
|
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import MobileCoreServices
|
import MobileCoreServices
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
class ActionViewController: UIViewController {
|
class ActionViewController: UIViewController {
|
||||||
|
|
||||||
|
@ -18,29 +17,25 @@ class ActionViewController: UIViewController {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
findURLFromWebPage { (components) in
|
findURLFromWebPage { (components) in
|
||||||
DispatchQueue.main.async {
|
if let components = components {
|
||||||
if let components {
|
|
||||||
self.searchForURLInApp(components)
|
self.searchForURLInApp(components)
|
||||||
} else {
|
} else {
|
||||||
self.findURLItem { (components) in
|
self.findURLItem { (components) in
|
||||||
if let components {
|
if let components = components {
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.searchForURLInApp(components)
|
self.searchForURLInApp(components)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findURLFromWebPage(completion: @escaping @Sendable (URLComponents?) -> Void) {
|
private func findURLFromWebPage(completion: @escaping (URLComponents?) -> Void) {
|
||||||
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||||
for provider in item.attachments! {
|
for provider in item.attachments! {
|
||||||
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
|
guard provider.hasItemConformingToTypeIdentifier(kUTTypePropertyList as String) else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { (result, error) in
|
provider.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil) { (result, error) in
|
||||||
guard let result = result as? [String: Any],
|
guard let result = result as? [String: Any],
|
||||||
let jsResult = result[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
|
let jsResult = result[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
|
||||||
let urlString = jsResult["activityPubURL"] as? String ?? jsResult["url"] as? String,
|
let urlString = jsResult["activityPubURL"] as? String ?? jsResult["url"] as? String,
|
||||||
|
@ -58,13 +53,13 @@ class ActionViewController: UIViewController {
|
||||||
completion(nil)
|
completion(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func findURLItem(completion: @escaping @Sendable (URLComponents?) -> Void) {
|
private func findURLItem(completion: @escaping (URLComponents?) -> Void) {
|
||||||
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||||
for provider in item.attachments! {
|
for provider in item.attachments! {
|
||||||
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {
|
guard provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (result, error) in
|
provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { (result, error) in
|
||||||
guard let result = result as? URL,
|
guard let result = result as? URL,
|
||||||
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
|
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
|
||||||
completion(nil)
|
completion(nil)
|
||||||
|
|
|
@ -24,8 +24,6 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionAttributes</key>
|
<key>NSExtensionAttributes</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionServiceRoleType</key>
|
|
||||||
<string>NSExtensionServiceRoleTypeViewer</string>
|
|
||||||
<key>NSExtensionActivationRule</key>
|
<key>NSExtensionActivationRule</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
||||||
|
|
7
Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
|
@ -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.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ComposeUI",
|
name: "ComposeUI",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -26,15 +26,9 @@ let package = Package(
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "ComposeUI",
|
name: "ComposeUI",
|
||||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"],
|
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]),
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ComposeUITests",
|
name: "ComposeUITests",
|
||||||
dependencies: ["ComposeUI"],
|
dependencies: ["ComposeUI"]),
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -72,13 +72,12 @@ class PostService: ObservableObject {
|
||||||
mediaIDs: uploadedAttachments,
|
mediaIDs: uploadedAttachments,
|
||||||
sensitive: sensitive,
|
sensitive: sensitive,
|
||||||
spoilerText: contentWarning,
|
spoilerText: contentWarning,
|
||||||
visibility: draft.localOnly && mastodonController.instanceFeatures.localOnlyPostsVisibility ? Status.localPostVisibility : draft.visibility.rawValue,
|
visibility: draft.visibility,
|
||||||
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
|
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
|
||||||
pollOptions: draft.poll?.pollOptions.map(\.text),
|
pollOptions: draft.poll?.pollOptions.map(\.text),
|
||||||
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
||||||
pollMultiple: draft.poll?.multiple,
|
pollMultiple: draft.poll?.multiple,
|
||||||
localOnly: mastodonController.instanceFeatures.localOnlyPosts && !mastodonController.instanceFeatures.localOnlyPostsVisibility ? draft.localOnly : nil,
|
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
|
||||||
idempotencyKey: draft.id.uuidString
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,12 +110,16 @@ class PostService: ObservableObject {
|
||||||
do {
|
do {
|
||||||
(data, utType) = try await getData(for: attachment)
|
(data, utType) = try await getData(for: attachment)
|
||||||
currentStep += 1
|
currentStep += 1
|
||||||
} catch let error as DraftAttachment.ExportError {
|
} catch let error as AttachmentData.Error {
|
||||||
throw Error.attachmentData(index: index, cause: error)
|
throw Error.attachmentData(index: index, cause: error)
|
||||||
}
|
}
|
||||||
let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription)
|
do {
|
||||||
|
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
|
||||||
attachments.append(uploaded.id)
|
attachments.append(uploaded.id)
|
||||||
currentStep += 1
|
currentStep += 1
|
||||||
|
} catch let error as Client.Error {
|
||||||
|
throw Error.attachmentUpload(index: index, cause: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return attachments
|
return attachments
|
||||||
}
|
}
|
||||||
|
@ -134,21 +137,10 @@ class PostService: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment {
|
private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment {
|
||||||
guard let mimeType = utType.preferredMIMEType else {
|
let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
|
||||||
throw Error.attachmentMissingMimeType(index: index, type: utType)
|
|
||||||
}
|
|
||||||
var filename = "file"
|
|
||||||
if let ext = utType.preferredFilenameExtension {
|
|
||||||
filename.append(".\(ext)")
|
|
||||||
}
|
|
||||||
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: filename)
|
|
||||||
let req = Client.upload(attachment: formAttachment, description: description)
|
let req = Client.upload(attachment: formAttachment, description: description)
|
||||||
do {
|
|
||||||
return try await mastodonController.run(req).0
|
return try await mastodonController.run(req).0
|
||||||
} catch let error as Client.Error {
|
|
||||||
throw Error.attachmentUpload(index: index, cause: error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func textForPosting() -> String {
|
private func textForPosting() -> String {
|
||||||
|
@ -176,8 +168,7 @@ class PostService: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Error: Swift.Error, LocalizedError {
|
enum Error: Swift.Error, LocalizedError {
|
||||||
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
|
case attachmentData(index: Int, cause: AttachmentData.Error)
|
||||||
case attachmentMissingMimeType(index: Int, type: UTType)
|
|
||||||
case attachmentUpload(index: Int, cause: Client.Error)
|
case attachmentUpload(index: Int, cause: Client.Error)
|
||||||
case posting(Client.Error)
|
case posting(Client.Error)
|
||||||
|
|
||||||
|
@ -185,8 +176,6 @@ class PostService: ObservableObject {
|
||||||
switch self {
|
switch self {
|
||||||
case let .attachmentData(index: index, cause: cause):
|
case let .attachmentData(index: index, cause: cause):
|
||||||
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||||
case let .attachmentMissingMimeType(index: index, type: type):
|
|
||||||
return "Attachment \(index + 1): unknown MIME type for \(type.identifier)"
|
|
||||||
case let .attachmentUpload(index: index, cause: cause):
|
case let .attachmentUpload(index: index, cause: cause):
|
||||||
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||||
case let .posting(error):
|
case let .posting(error):
|
||||||
|
|
|
@ -15,8 +15,7 @@ public protocol ComposeMastodonContext {
|
||||||
var instanceFeatures: InstanceFeatures { get }
|
var instanceFeatures: InstanceFeatures { get }
|
||||||
|
|
||||||
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
|
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
|
||||||
|
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void)
|
||||||
func getCustomEmojis() async -> [Emoji]
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func searchCachedAccounts(query: String) -> [AccountProtocol]
|
func searchCachedAccounts(query: String) -> [AccountProtocol]
|
||||||
|
|
|
@ -49,9 +49,7 @@ class AttachmentRowController: ViewController {
|
||||||
|
|
||||||
private func removeAttachment() {
|
private func removeAttachment() {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
var newAttachments = parent.draft.draftAttachments
|
parent.draft.attachments.remove(attachment)
|
||||||
newAttachments.removeAll(where: { $0.id == attachment.id })
|
|
||||||
parent.draft.attachments = NSMutableOrderedSet(array: newAttachments)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,8 +70,8 @@ class AttachmentRowController: ViewController {
|
||||||
private func recognizeText() {
|
private func recognizeText() {
|
||||||
descriptionMode = .recognizingText
|
descriptionMode = .recognizingText
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
||||||
DispatchQueue.main.async {
|
|
||||||
let data: Data
|
let data: Data
|
||||||
switch result {
|
switch result {
|
||||||
case .success((let d, _)):
|
case .success((let d, _)):
|
||||||
|
@ -136,7 +134,7 @@ class AttachmentRowController: ViewController {
|
||||||
.overlay {
|
.overlay {
|
||||||
thumbnailFocusedOverlay
|
thumbnailFocusedOverlay
|
||||||
}
|
}
|
||||||
.frame(width: thumbnailSize, height: thumbnailSize)
|
.frame(width: 80, height: 80)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
textEditorFocused = false
|
textEditorFocused = false
|
||||||
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
|
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
|
||||||
|
@ -156,13 +154,13 @@ class AttachmentRowController: ViewController {
|
||||||
Button(role: .destructive, action: controller.removeAttachment) {
|
Button(role: .destructive, action: controller.removeAttachment) {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
} preview: {
|
} previewIfAvailable: {
|
||||||
ControllerView(controller: { controller.thumbnailController })
|
ControllerView(controller: { controller.thumbnailController })
|
||||||
}
|
}
|
||||||
|
|
||||||
switch controller.descriptionMode {
|
switch controller.descriptionMode {
|
||||||
case .allowEntry:
|
case .allowEntry:
|
||||||
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
|
InlineAttachmentDescriptionView(attachment: attachment, minHeight: 80)
|
||||||
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
|
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
|
||||||
.focused($textEditorFocused)
|
.focused($textEditorFocused)
|
||||||
|
|
||||||
|
@ -177,27 +175,11 @@ class AttachmentRowController: ViewController {
|
||||||
Text(error.localizedDescription)
|
Text(error.localizedDescription)
|
||||||
}
|
}
|
||||||
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
||||||
#if os(visionOS)
|
|
||||||
.onChange(of: textEditorFocused) {
|
|
||||||
if !textEditorFocused && controller.focusAttachmentOnTextEditorUnfocus {
|
|
||||||
controller.focusAttachment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
.onChange(of: textEditorFocused) { newValue in
|
.onChange(of: textEditorFocused) { newValue in
|
||||||
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
|
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
|
||||||
controller.focusAttachment()
|
controller.focusAttachment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var thumbnailSize: CGFloat {
|
|
||||||
#if os(visionOS)
|
|
||||||
120
|
|
||||||
#else
|
|
||||||
80
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -221,3 +203,15 @@ extension AttachmentRowController {
|
||||||
case allowEntry, recognizingText
|
case allowEntry, recognizingText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@available(iOS, obsoleted: 16.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,14 +40,9 @@ class AttachmentThumbnailController: ViewController {
|
||||||
case .video, .gifv:
|
case .video, .gifv:
|
||||||
let asset = AVURLAsset(url: url)
|
let asset = AVURLAsset(url: url)
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
imageGenerator.appliesPreferredTrackTransform = true
|
|
||||||
#if os(visionOS)
|
|
||||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
|
||||||
#else
|
|
||||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||||
self.image = UIImage(cgImage: cgImage)
|
self.image = UIImage(cgImage: cgImage)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
case .audio, .unknown:
|
case .audio, .unknown:
|
||||||
break
|
break
|
||||||
|
@ -92,14 +87,9 @@ class AttachmentThumbnailController: ViewController {
|
||||||
if type.conforms(to: .movie) {
|
if type.conforms(to: .movie) {
|
||||||
let asset = AVURLAsset(url: url)
|
let asset = AVURLAsset(url: url)
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
imageGenerator.appliesPreferredTrackTransform = true
|
|
||||||
#if os(visionOS)
|
|
||||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
|
||||||
#else
|
|
||||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||||
self.image = UIImage(cgImage: cgImage)
|
self.image = UIImage(cgImage: cgImage)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
} else if let data = try? Data(contentsOf: url) {
|
} else if let data = try? Data(contentsOf: url) {
|
||||||
if type == .gif {
|
if type == .gif {
|
||||||
self.gifController = GIFController(gifData: data)
|
self.gifController = GIFController(gifData: data)
|
||||||
|
|
|
@ -85,11 +85,8 @@ class AttachmentsListController: ViewController {
|
||||||
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
||||||
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
||||||
guard let attachment = object as? DraftAttachment else { return }
|
guard let attachment = object as? DraftAttachment else { return }
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async {
|
||||||
guard let self,
|
guard self.canAddAttachment else { return }
|
||||||
self.canAddAttachment else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||||
attachment.draft = self.draft
|
attachment.draft = self.draft
|
||||||
self.draft.attachments.add(attachment)
|
self.draft.attachments.add(attachment)
|
||||||
|
@ -134,9 +131,9 @@ class AttachmentsListController: ViewController {
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Group {
|
||||||
attachmentsList
|
attachmentsList
|
||||||
|
|
||||||
Group {
|
|
||||||
if controller.parent.config.presentAssetPicker != nil {
|
if controller.parent.config.presentAssetPicker != nil {
|
||||||
addImageButton
|
addImageButton
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
|
@ -150,10 +147,6 @@ class AttachmentsListController: ViewController {
|
||||||
togglePollButton
|
togglePollButton
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
}
|
}
|
||||||
#if os(visionOS)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.labelStyle(AttachmentButtonLabelStyle())
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var attachmentsList: some View {
|
private var attachmentsList: some View {
|
||||||
|
@ -214,12 +207,42 @@ fileprivate extension View {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@available(visionOS 1.0, *)
|
@available(iOS, obsoleted: 16.0)
|
||||||
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
|
@ViewBuilder
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
|
||||||
DefaultLabelStyle().makeBody(configuration: configuration)
|
if #available(iOS 16.0, *) {
|
||||||
.foregroundStyle(.white)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
class AutocompleteEmojisController: ViewController {
|
class AutocompleteEmojisController: ViewController {
|
||||||
unowned let composeController: ComposeController
|
unowned let composeController: ComposeController
|
||||||
|
@ -19,7 +18,19 @@ class AutocompleteEmojisController: ViewController {
|
||||||
|
|
||||||
@Published var expanded = false
|
@Published var expanded = false
|
||||||
@Published var emojis: [Emoji] = []
|
@Published var emojis: [Emoji] = []
|
||||||
@Published var emojisBySection: [String: [Emoji]] = [:]
|
|
||||||
|
var emojisBySection: [String: [Emoji]] {
|
||||||
|
var values: [String: [Emoji]] = [:]
|
||||||
|
for emoji in emojis {
|
||||||
|
let key = emoji.category ?? ""
|
||||||
|
if !values.keys.contains(key) {
|
||||||
|
values[key] = [emoji]
|
||||||
|
} else {
|
||||||
|
values[key]!.append(emoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
init(composeController: ComposeController) {
|
init(composeController: ComposeController) {
|
||||||
self.composeController = composeController
|
self.composeController = composeController
|
||||||
|
@ -37,15 +48,19 @@ class AutocompleteEmojisController: ViewController {
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.sink { [unowned self] query in
|
.sink { [unowned self] query in
|
||||||
self.searchTask?.cancel()
|
self.searchTask?.cancel()
|
||||||
self.searchTask = Task { [weak self] in
|
self.searchTask = Task {
|
||||||
await self?.queryChanged(query)
|
await self.queryChanged(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func queryChanged(_ query: String) async {
|
private func queryChanged(_ query: String) async {
|
||||||
var emojis = await composeController.mastodonController.getCustomEmojis()
|
var emojis = await withCheckedContinuation { continuation in
|
||||||
|
composeController.mastodonController.getCustomEmojis {
|
||||||
|
continuation.resume(returning: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
guard !Task.isCancelled else {
|
guard !Task.isCancelled else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -62,20 +77,11 @@ class AutocompleteEmojisController: ViewController {
|
||||||
|
|
||||||
var shortcodes = Set<String>()
|
var shortcodes = Set<String>()
|
||||||
var newEmojis = [Emoji]()
|
var newEmojis = [Emoji]()
|
||||||
var newEmojisBySection = [String: [Emoji]]()
|
|
||||||
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
||||||
newEmojis.append(emoji)
|
newEmojis.append(emoji)
|
||||||
shortcodes.insert(emoji.shortcode)
|
shortcodes.insert(emoji.shortcode)
|
||||||
|
|
||||||
let category = emoji.category ?? ""
|
|
||||||
if newEmojisBySection.keys.contains(category) {
|
|
||||||
newEmojisBySection[category]!.append(emoji)
|
|
||||||
} else {
|
|
||||||
newEmojisBySection[category] = [emoji]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.emojis = newEmojis
|
self.emojis = newEmojis
|
||||||
self.emojisBySection = newEmojisBySection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleExpanded() {
|
private func toggleExpanded() {
|
||||||
|
@ -154,7 +160,7 @@ class AutocompleteEmojisController: ViewController {
|
||||||
|
|
||||||
private var horizontalScrollView: some View {
|
private var horizontalScrollView: some View {
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
LazyHStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(controller.emojis, id: \.shortcode) { emoji in
|
ForEach(controller.emojis, id: \.shortcode) { emoji in
|
||||||
Button(action: { controller.autocomplete(with: emoji) }) {
|
Button(action: { controller.autocomplete(with: emoji) }) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
@ -168,6 +174,8 @@ class AutocompleteEmojisController: ViewController {
|
||||||
.frame(height: emojiSize)
|
.frame(height: emojiSize)
|
||||||
}
|
}
|
||||||
.animation(.linear(duration: 0.2), value: controller.emojis)
|
.animation(.linear(duration: 0.2), value: controller.emojis)
|
||||||
|
|
||||||
|
Spacer(minLength: emojiSize)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.frame(height: emojiSize + 16)
|
.frame(height: emojiSize + 16)
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
class AutocompleteHashtagsController: ViewController {
|
class AutocompleteHashtagsController: ViewController {
|
||||||
unowned let composeController: ComposeController
|
unowned let composeController: ComposeController
|
||||||
|
@ -35,8 +34,8 @@ class AutocompleteHashtagsController: ViewController {
|
||||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
||||||
.sink { [unowned self] query in
|
.sink { [unowned self] query in
|
||||||
self.searchTask?.cancel()
|
self.searchTask?.cancel()
|
||||||
self.searchTask = Task { [weak self] in
|
self.searchTask = Task {
|
||||||
await self?.queryChanged(query)
|
await self.queryChanged(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,9 +36,8 @@ class AutocompleteMentionsController: ViewController {
|
||||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
||||||
.sink { [unowned self] query in
|
.sink { [unowned self] query in
|
||||||
self.searchTask?.cancel()
|
self.searchTask?.cancel()
|
||||||
// weak in case the autocomplete controller is dealloc'd racing with the task starting
|
self.searchTask = Task {
|
||||||
self.searchTask = Task { [weak self] in
|
await self.queryChanged(query)
|
||||||
await self?.queryChanged(query)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,11 +20,7 @@ public final class ComposeController: ViewController {
|
||||||
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
|
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
|
||||||
public typealias EmojiImageView = (Emoji) -> AnyView
|
public typealias EmojiImageView = (Emoji) -> AnyView
|
||||||
|
|
||||||
@Published public private(set) var draft: Draft {
|
@Published public private(set) var draft: Draft
|
||||||
didSet {
|
|
||||||
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Published public var config: ComposeUIConfig
|
@Published public var config: ComposeUIConfig
|
||||||
@Published public var mastodonController: ComposeMastodonContext
|
@Published public var mastodonController: ComposeMastodonContext
|
||||||
let fetchAvatar: AvatarImageView.FetchAvatar
|
let fetchAvatar: AvatarImageView.FetchAvatar
|
||||||
|
@ -62,7 +58,7 @@ public final class ComposeController: ViewController {
|
||||||
private var isDisappearing = false
|
private var isDisappearing = false
|
||||||
private var userConfirmedDelete = false
|
private var userConfirmedDelete = false
|
||||||
|
|
||||||
public var isPosting: Bool {
|
var isPosting: Bool {
|
||||||
poster != nil
|
poster != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,7 +106,6 @@ public final class ComposeController: ViewController {
|
||||||
emojiImageView: @escaping EmojiImageView
|
emojiImageView: @escaping EmojiImageView
|
||||||
) {
|
) {
|
||||||
self.draft = draft
|
self.draft = draft
|
||||||
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
|
|
||||||
self.config = config
|
self.config = config
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.fetchAvatar = fetchAvatar
|
self.fetchAvatar = fetchAvatar
|
||||||
|
@ -125,7 +120,9 @@ public final class ComposeController: ViewController {
|
||||||
self.toolbarController = ToolbarController(parent: self)
|
self.toolbarController = ToolbarController(parent: self)
|
||||||
self.attachmentsListController = AttachmentsListController(parent: self)
|
self.attachmentsListController = AttachmentsListController(parent: self)
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
||||||
|
}
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
|
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -273,9 +270,7 @@ public final class ComposeController: ViewController {
|
||||||
@OptionalObservedObject var poster: PostService?
|
@OptionalObservedObject var poster: PostService?
|
||||||
@EnvironmentObject var controller: ComposeController
|
@EnvironmentObject var controller: ComposeController
|
||||||
@EnvironmentObject var draft: Draft
|
@EnvironmentObject var draft: Draft
|
||||||
#if !os(visionOS)
|
|
||||||
@StateObject private var keyboardReader = KeyboardReader()
|
@StateObject private var keyboardReader = KeyboardReader()
|
||||||
#endif
|
|
||||||
@State private var globalFrameOutsideList = CGRect.zero
|
@State private var globalFrameOutsideList = CGRect.zero
|
||||||
|
|
||||||
init(poster: PostService?) {
|
init(poster: PostService?) {
|
||||||
|
@ -318,26 +313,16 @@ public final class ComposeController: ViewController {
|
||||||
.transition(.move(edge: .bottom))
|
.transition(.move(edge: .bottom))
|
||||||
.animation(.default, value: controller.currentInput?.autocompleteState)
|
.animation(.default, value: controller.currentInput?.autocompleteState)
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
ControllerView(controller: { controller.toolbarController })
|
ControllerView(controller: { controller.toolbarController })
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
||||||
|
.padding(.bottom, keyboardInset)
|
||||||
.transition(.move(edge: .bottom))
|
.transition(.move(edge: .bottom))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
ToolbarItem(placement: .topBarTrailing) { draftsButton }
|
|
||||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||||
#else
|
|
||||||
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
|
|
||||||
#endif
|
|
||||||
#if os(visionOS)
|
|
||||||
ToolbarItem(placement: .bottomOrnament) {
|
|
||||||
ControllerView(controller: { controller.toolbarController })
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.background(GeometryReader { proxy in
|
.background(GeometryReader { proxy in
|
||||||
Color.clear
|
Color.clear
|
||||||
|
@ -354,10 +339,8 @@ public final class ComposeController: ViewController {
|
||||||
}, message: { error in
|
}, message: { error in
|
||||||
Text(error.localizedDescription)
|
Text(error.localizedDescription)
|
||||||
})
|
})
|
||||||
.matchedGeometryPresentation(id: Binding(get: { () -> UUID?? in
|
.matchedGeometryPresentation(id: Binding(get: {
|
||||||
let id = controller.focusedAttachment?.0.id
|
controller.focusedAttachment?.0.id
|
||||||
// this needs to be a double optional, since the type used for for the presentationID in the geom source is a UUID?
|
|
||||||
return id.map { Optional.some($0) }
|
|
||||||
}, set: {
|
}, set: {
|
||||||
if $0 == nil {
|
if $0 == nil {
|
||||||
controller.focusedAttachment = nil
|
controller.focusedAttachment = nil
|
||||||
|
@ -429,9 +412,7 @@ public final class ComposeController: ViewController {
|
||||||
.listRowBackground(config.backgroundColor)
|
.listRowBackground(config.backgroundColor)
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
#if !os(visionOS)
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
.scrollDismissesKeyboard(.interactively)
|
|
||||||
#endif
|
|
||||||
.disabled(controller.isPosting)
|
.disabled(controller.isPosting)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -441,7 +422,6 @@ public final class ComposeController: ViewController {
|
||||||
// otherwise all Buttons in the nav bar are made semibold
|
// otherwise all Buttons in the nav bar are made semibold
|
||||||
.font(.system(size: 17, weight: .regular))
|
.font(.system(size: 17, weight: .regular))
|
||||||
}
|
}
|
||||||
.disabled(controller.isPosting)
|
|
||||||
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
||||||
// edit drafts can't be saved
|
// edit drafts can't be saved
|
||||||
if draft.editedStatusID == nil {
|
if draft.editedStatusID == nil {
|
||||||
|
@ -460,26 +440,41 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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 {
|
private var postButton: some View {
|
||||||
|
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||||
Button(action: controller.postStatus) {
|
Button(action: controller.postStatus) {
|
||||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
.disabled(!controller.postButtonEnabled)
|
.disabled(!controller.postButtonEnabled)
|
||||||
|
} else {
|
||||||
|
Button(action: controller.showDrafts) {
|
||||||
|
Text("Drafts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
.onAppear {
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
} else {
|
} else if #available(iOS 16.0, *) {
|
||||||
ZoomableScrollView {
|
ZoomableScrollView {
|
||||||
attachmentView
|
attachmentView
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
.matchedGeometryDestination(id: attachment.id)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
attachmentView
|
||||||
|
.matchedGeometryDestination(id: attachment.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
|
@ -34,16 +34,11 @@ class PollController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moveOptions(indices: IndexSet, newIndex: Int) {
|
private func moveOptions(indices: IndexSet, newIndex: Int) {
|
||||||
// see AttachmentsListController.moveAttachments
|
poll.options.moveObjects(at: indices, to: newIndex)
|
||||||
var array = poll.pollOptions
|
|
||||||
array.move(fromOffsets: indices, toOffset: newIndex)
|
|
||||||
poll.options = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeOption(_ option: PollOption) {
|
private func removeOption(_ option: PollOption) {
|
||||||
var array = poll.pollOptions
|
poll.options.remove(option)
|
||||||
array.remove(at: poll.options.index(of: option))
|
|
||||||
poll.options = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canAddOption: Bool {
|
private var canAddOption: Bool {
|
||||||
|
@ -96,7 +91,7 @@ class PollController: ViewController {
|
||||||
.onMove(perform: controller.moveOptions)
|
.onMove(perform: controller.moveOptions)
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollDisabled(true)
|
.scrollDisabledIfAvailable(true)
|
||||||
.frame(height: 44 * CGFloat(poll.options.count))
|
.frame(height: 44 * CGFloat(poll.options.count))
|
||||||
|
|
||||||
Button(action: controller.addOption) {
|
Button(action: controller.addOption) {
|
||||||
|
@ -128,15 +123,9 @@ class PollController: ViewController {
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.foregroundColor(backgroundColor)
|
.foregroundColor(backgroundColor)
|
||||||
)
|
)
|
||||||
#if os(visionOS)
|
|
||||||
.onChange(of: controller.duration) {
|
|
||||||
poll.duration = controller.duration.timeInterval
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
.onChange(of: controller.duration) { newValue in
|
.onChange(of: controller.duration) { newValue in
|
||||||
poll.duration = newValue.timeInterval
|
poll.duration = newValue.timeInterval
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color {
|
||||||
|
|
|
@ -11,6 +11,9 @@ import TuskerComponents
|
||||||
|
|
||||||
class ToolbarController: ViewController {
|
class ToolbarController: ViewController {
|
||||||
static let height: CGFloat = 44
|
static let height: CGFloat = 44
|
||||||
|
private static let visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] = Pachyderm.Visibility.allCases.map { vis in
|
||||||
|
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
||||||
|
}
|
||||||
|
|
||||||
unowned let parent: ComposeController
|
unowned let parent: ComposeController
|
||||||
|
|
||||||
|
@ -45,65 +48,22 @@ class ToolbarController: ViewController {
|
||||||
@EnvironmentObject private var composeController: ComposeController
|
@EnvironmentObject private var composeController: ComposeController
|
||||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
@State private var minWidth: CGFloat?
|
@State private var minWidth: CGFloat?
|
||||||
@State private var realWidth: CGFloat?
|
@State private var realWidth: CGFloat?
|
||||||
#endif
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(visionOS)
|
|
||||||
buttons
|
|
||||||
#else
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
buttons
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.frame(minWidth: minWidth)
|
|
||||||
.background(GeometryReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
|
||||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
|
||||||
realWidth = width
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
|
|
||||||
.frame(height: ToolbarController.height)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
Divider()
|
|
||||||
.edgesIgnoringSafeArea([.leading, .trailing])
|
|
||||||
}
|
|
||||||
.background(GeometryReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
|
||||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
|
||||||
minWidth = width
|
|
||||||
}
|
|
||||||
})
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var buttons: some View {
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
cwButton
|
cwButton
|
||||||
|
|
||||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly)
|
||||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
|
||||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||||
.padding(.horizontal, -8)
|
.padding(.horizontal, -8)
|
||||||
#endif
|
|
||||||
.disabled(draft.editedStatusID != nil)
|
.disabled(draft.editedStatusID != nil)
|
||||||
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
|
||||||
|
|
||||||
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
||||||
localOnlyPicker
|
localOnlyPicker
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
.padding(.leading, 4)
|
|
||||||
#elseif !os(visionOS)
|
|
||||||
.padding(.horizontal, -8)
|
.padding(.horizontal, -8)
|
||||||
#endif
|
|
||||||
.disabled(draft.editedStatusID != nil)
|
.disabled(draft.editedStatusID != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -122,10 +82,35 @@ class ToolbarController: ViewController {
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
if #available(iOS 16.0, *),
|
||||||
|
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(minWidth: minWidth)
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||||
|
realWidth = width
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
||||||
|
.frame(height: ToolbarController.height)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||||
|
minWidth = width
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cwButton: some View {
|
private var cwButton: some View {
|
||||||
|
@ -135,29 +120,6 @@ class ToolbarController: ViewController {
|
||||||
.hoverEffect()
|
.hoverEffect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var visibilityBinding: Binding<Pachyderm.Visibility> {
|
|
||||||
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
|
|
||||||
// changing the visibility when local-only.
|
|
||||||
if draft.localOnly,
|
|
||||||
composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility {
|
|
||||||
return .constant(.public)
|
|
||||||
} else {
|
|
||||||
return $draft.visibility
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
|
|
||||||
let visibilities: [Pachyderm.Visibility]
|
|
||||||
if !composeController.mastodonController.instanceFeatures.composeDirectStatuses {
|
|
||||||
visibilities = [.public, .unlisted, .private]
|
|
||||||
} else {
|
|
||||||
visibilities = Pachyderm.Visibility.allCases
|
|
||||||
}
|
|
||||||
return visibilities.map { vis in
|
|
||||||
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var localOnlyPicker: some View {
|
private var localOnlyPicker: some View {
|
||||||
let domain = composeController.mastodonController.accountInfo!.instanceURL.host!
|
let domain = composeController.mastodonController.accountInfo!.instanceURL.host!
|
||||||
return MenuPicker(selection: $draft.localOnly, options: [
|
return MenuPicker(selection: $draft.localOnly, options: [
|
||||||
|
@ -180,8 +142,13 @@ class ToolbarController: ViewController {
|
||||||
private var formatButtons: some View {
|
private var formatButtons: some View {
|
||||||
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
||||||
Button(action: controller.formatAction(format)) {
|
Button(action: controller.formatAction(format)) {
|
||||||
Image(systemName: format.imageName)
|
if let imageName = format.imageName {
|
||||||
|
Image(systemName: imageName)
|
||||||
.font(.system(size: imageSize))
|
.font(.system(size: imageSize))
|
||||||
|
} else if let (str, attrs) = format.title {
|
||||||
|
let container = try! AttributeContainer(attrs, including: \.uiKit)
|
||||||
|
Text(AttributedString(str, attributes: container))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.accessibilityLabel(format.accessibilityLabel)
|
.accessibilityLabel(format.accessibilityLabel)
|
||||||
.padding(5)
|
.padding(5)
|
||||||
|
|
|
@ -25,7 +25,6 @@ public class Draft: NSManagedObject, Identifiable {
|
||||||
@NSManaged public var contentWarningEnabled: Bool
|
@NSManaged public var contentWarningEnabled: Bool
|
||||||
@NSManaged public var editedStatusID: String?
|
@NSManaged public var editedStatusID: String?
|
||||||
@NSManaged public var id: UUID
|
@NSManaged public var id: UUID
|
||||||
@NSManaged public var initialContentWarning: String?
|
|
||||||
@NSManaged public var initialText: String
|
@NSManaged public var initialText: String
|
||||||
@NSManaged public var inReplyToID: String?
|
@NSManaged public var inReplyToID: String?
|
||||||
@NSManaged public var language: String? // ISO 639 language code
|
@NSManaged public var language: String? // ISO 639 language code
|
||||||
|
@ -66,7 +65,7 @@ public class Draft: NSManagedObject, Identifiable {
|
||||||
extension Draft {
|
extension Draft {
|
||||||
public var hasContent: Bool {
|
public var hasContent: Bool {
|
||||||
(!text.isEmpty && text != initialText) ||
|
(!text.isEmpty && text != initialText) ||
|
||||||
(contentWarningEnabled && !contentWarning.isEmpty && contentWarning != initialContentWarning) ||
|
(contentWarningEnabled && !contentWarning.isEmpty) ||
|
||||||
attachments.count > 0 ||
|
attachments.count > 0 ||
|
||||||
poll?.hasContent == true
|
poll?.hasContent == true
|
||||||
}
|
}
|
||||||
|
|
|
@ -136,9 +136,6 @@ extension DraftAttachment {
|
||||||
|
|
||||||
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
|
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
|
||||||
|
|
||||||
private let imageType = UTType.image.identifier
|
|
||||||
private let heifType = UTType.heif.identifier
|
|
||||||
private let heicType = UTType.heic.identifier
|
|
||||||
private let jpegType = UTType.jpeg.identifier
|
private let jpegType = UTType.jpeg.identifier
|
||||||
private let pngType = UTType.png.identifier
|
private let pngType = UTType.png.identifier
|
||||||
private let mp4Type = UTType.mpeg4Movie.identifier
|
private let mp4Type = UTType.mpeg4Movie.identifier
|
||||||
|
@ -150,40 +147,15 @@ extension DraftAttachment: NSItemProviderReading {
|
||||||
// todo: is there a better way of handling movies than manually adding all possible UTI types?
|
// todo: is there a better way of handling movies than manually adding all possible UTI types?
|
||||||
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
|
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
|
||||||
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
|
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
|
||||||
[/*typeIdentifier, */ gifType, heifType, heicType, jpegType, pngType, imageType, mp4Type, quickTimeType]
|
[/*typeIdentifier, */gifType, jpegType, pngType, mp4Type, quickTimeType]
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
|
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
|
||||||
var data = data
|
|
||||||
var type = UTType(typeIdentifier)!
|
|
||||||
|
|
||||||
// the type is .image in certain circumstances:
|
|
||||||
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works
|
|
||||||
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
|
|
||||||
if type == .image,
|
|
||||||
let image = UIImage(data: data) ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)),
|
|
||||||
let pngData = image.pngData() {
|
|
||||||
data = pngData
|
|
||||||
type = .png
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the caption from the image itself, if there is one.
|
|
||||||
let caption: String
|
|
||||||
if let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceTypeIdentifierHint: typeIdentifier as CFString] as CFDictionary),
|
|
||||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any],
|
|
||||||
// This is the dictionary for TIFF properties, but it's present for other image types too
|
|
||||||
let tiffProperties = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any],
|
|
||||||
let imageDescription = tiffProperties[kCGImagePropertyTIFFImageDescription as String] as? String {
|
|
||||||
caption = imageDescription
|
|
||||||
} else {
|
|
||||||
caption = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
|
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
|
||||||
attachment.id = UUID()
|
attachment.id = UUID()
|
||||||
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
|
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!)
|
||||||
attachment.fileType = type.identifier
|
attachment.fileType = typeIdentifier
|
||||||
attachment.attachmentDescription = caption
|
attachment.attachmentDescription = ""
|
||||||
return attachment
|
return attachment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,7 +203,7 @@ extension DraftAttachment {
|
||||||
options.isNetworkAccessAllowed = true
|
options.isNetworkAccessAllowed = true
|
||||||
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
|
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
|
||||||
if let exportSession {
|
if let exportSession {
|
||||||
Self.exportVideoData(session: exportSession, features: features, completion: completion)
|
Self.exportVideoData(session: exportSession, completion: completion)
|
||||||
} else if let error = info?[PHImageErrorKey] as? Error {
|
} else if let error = info?[PHImageErrorKey] as? Error {
|
||||||
completion(.failure(.videoExport(error)))
|
completion(.failure(.videoExport(error)))
|
||||||
} else {
|
} else {
|
||||||
|
@ -257,7 +229,7 @@ extension DraftAttachment {
|
||||||
completion(.failure(.noVideoExportSession))
|
completion(.failure(.noVideoExportSession))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Self.exportVideoData(session: session, features: features, completion: completion)
|
Self.exportVideoData(session: session, completion: completion)
|
||||||
} else {
|
} else {
|
||||||
let fileData: Data
|
let fileData: Data
|
||||||
do {
|
do {
|
||||||
|
@ -288,13 +260,20 @@ extension DraftAttachment {
|
||||||
var data = data
|
var data = data
|
||||||
var type = type
|
var type = type
|
||||||
|
|
||||||
|
if type != .png && type != .jpeg,
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
|
||||||
|
data = image.jpegData(compressionQuality: 0.8)!
|
||||||
|
type = .jpeg
|
||||||
|
}
|
||||||
|
|
||||||
let image = CIImage(data: data)!
|
let image = CIImage(data: data)!
|
||||||
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
||||||
|
|
||||||
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
||||||
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
|
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
|
||||||
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
||||||
if needsColorSpaceConversion || type == .heic || type == .heif {
|
if needsColorSpaceConversion || type == .heic {
|
||||||
let context = CIContext()
|
let context = CIContext()
|
||||||
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
||||||
if type == .png {
|
if type == .png {
|
||||||
|
@ -308,12 +287,9 @@ extension DraftAttachment {
|
||||||
return (data, type)
|
return (data, type)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func exportVideoData(session: AVAssetExportSession, features: InstanceFeatures, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
|
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
|
||||||
session.outputFileType = .mp4
|
session.outputFileType = .mp4
|
||||||
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
|
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
|
||||||
if let configuration = features.mediaAttachmentsConfiguration {
|
|
||||||
session.fileLengthLimit = Int64(configuration.videoSizeLimit)
|
|
||||||
}
|
|
||||||
session.exportAsynchronously {
|
session.exportAsynchronously {
|
||||||
guard session.status == .completed else {
|
guard session.status == .completed else {
|
||||||
completion(.failure(.videoExport(session.error!)))
|
completion(.failure(.videoExport(session.error!)))
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
|
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
|
||||||
<attribute name="accountID" attributeType="String"/>
|
<attribute name="accountID" attributeType="String"/>
|
||||||
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
|
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
|
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="initialContentWarning" optional="YES" attributeType="String"/>
|
|
||||||
<attribute name="initialText" attributeType="String"/>
|
<attribute name="initialText" attributeType="String"/>
|
||||||
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="language" optional="YES" attributeType="String"/>
|
<attribute name="language" optional="YES" attributeType="String"/>
|
||||||
|
@ -39,4 +38,7 @@
|
||||||
<attribute name="text" attributeType="String" defaultValueString=""/>
|
<attribute name="text" attributeType="String" defaultValueString=""/>
|
||||||
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
|
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="TestEntity" representedClassName="TestEntity" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
</entity>
|
||||||
</model>
|
</model>
|
|
@ -16,8 +16,6 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
public static let shared = DraftsPersistentContainer()
|
public static let shared = DraftsPersistentContainer()
|
||||||
|
|
||||||
public static var captureError: ((any Error) -> Void)?
|
|
||||||
|
|
||||||
private static let managedObjectModel: NSManagedObjectModel = {
|
private static let managedObjectModel: NSManagedObjectModel = {
|
||||||
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
|
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
|
||||||
return NSManagedObjectModel(contentsOf: url)!
|
return NSManagedObjectModel(contentsOf: url)!
|
||||||
|
@ -41,7 +39,6 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
loadPersistentStores { _, error in
|
loadPersistentStores { _, error in
|
||||||
if let error {
|
if let error {
|
||||||
DraftsPersistentContainer.captureError?(error)
|
|
||||||
fatalError("Loading persistent store: \(error)")
|
fatalError("Loading persistent store: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,7 +81,6 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
contentWarning: String,
|
contentWarning: String,
|
||||||
inReplyToID: String?,
|
inReplyToID: String?,
|
||||||
visibility: Visibility,
|
visibility: Visibility,
|
||||||
language: String?,
|
|
||||||
localOnly: Bool
|
localOnly: Bool
|
||||||
) -> Draft {
|
) -> Draft {
|
||||||
let draft = Draft(context: viewContext)
|
let draft = Draft(context: viewContext)
|
||||||
|
@ -92,11 +88,9 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
draft.text = text
|
draft.text = text
|
||||||
draft.initialText = text
|
draft.initialText = text
|
||||||
draft.contentWarning = contentWarning
|
draft.contentWarning = contentWarning
|
||||||
draft.initialContentWarning = contentWarning
|
|
||||||
draft.contentWarningEnabled = !contentWarning.isEmpty
|
draft.contentWarningEnabled = !contentWarning.isEmpty
|
||||||
draft.inReplyToID = inReplyToID
|
draft.inReplyToID = inReplyToID
|
||||||
draft.visibility = visibility
|
draft.visibility = visibility
|
||||||
draft.language = language
|
|
||||||
draft.localOnly = localOnly
|
draft.localOnly = localOnly
|
||||||
save()
|
save()
|
||||||
return draft
|
return draft
|
||||||
|
@ -118,7 +112,6 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
draft.initialText = source.text
|
draft.initialText = source.text
|
||||||
draft.contentWarning = source.spoilerText
|
draft.contentWarning = source.spoilerText
|
||||||
draft.contentWarningEnabled = !source.spoilerText.isEmpty
|
draft.contentWarningEnabled = !source.spoilerText.isEmpty
|
||||||
draft.initialContentWarning = source.spoilerText
|
|
||||||
draft.inReplyToID = inReplyToID
|
draft.inReplyToID = inReplyToID
|
||||||
draft.visibility = visibility
|
draft.visibility = visibility
|
||||||
draft.localOnly = localOnly
|
draft.localOnly = localOnly
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
//
|
//
|
||||||
// FuzzyMatcher.swift
|
// FuzzyMatcher.swift
|
||||||
// TuskerComponents
|
// ComposeUI
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 10/10/20.
|
// Created by Shadowfacts on 10/10/20.
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct FuzzyMatcher {
|
struct FuzzyMatcher {
|
||||||
|
|
||||||
private init() {}
|
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 occurs in `str` sequentially
|
||||||
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
|
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
|
||||||
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
|
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
|
||||||
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 pattern = pattern.lowercased()
|
||||||
let str = str.lowercased()
|
let str = str.lowercased()
|
||||||
|
|
|
@ -5,11 +5,10 @@
|
||||||
// Created by Shadowfacts on 3/7/23.
|
// Created by Shadowfacts on 3/7/23.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
class KeyboardReader: ObservableObject {
|
class KeyboardReader: ObservableObject {
|
||||||
// @Published var isVisible = false
|
// @Published var isVisible = false
|
||||||
@Published var keyboardHeight: CGFloat = 0
|
@Published var keyboardHeight: CGFloat = 0
|
||||||
|
@ -38,5 +37,3 @@ class KeyboardReader: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
|
@ -0,0 +1,278 @@
|
||||||
|
//
|
||||||
|
// AttachmentData.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/1/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Photos
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import PencilKit
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
|
enum AttachmentData {
|
||||||
|
case asset(PHAsset)
|
||||||
|
case image(Data, originalType: UTType)
|
||||||
|
case video(URL)
|
||||||
|
case drawing(PKDrawing)
|
||||||
|
case gif(Data)
|
||||||
|
|
||||||
|
var type: AttachmentType {
|
||||||
|
switch self {
|
||||||
|
case let .asset(asset):
|
||||||
|
return asset.attachmentType!
|
||||||
|
case .image(_, originalType: _):
|
||||||
|
return .image
|
||||||
|
case .video(_):
|
||||||
|
return .video
|
||||||
|
case .drawing(_):
|
||||||
|
return .image
|
||||||
|
case .gif(_):
|
||||||
|
return .image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAsset: Bool {
|
||||||
|
switch self {
|
||||||
|
case .asset(_):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var canSaveToDraft: Bool {
|
||||||
|
switch self {
|
||||||
|
case .video(_):
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
||||||
|
switch self {
|
||||||
|
case let .image(originalData, originalType):
|
||||||
|
let data: Data
|
||||||
|
let type: UTType
|
||||||
|
switch originalType {
|
||||||
|
case .png, .jpeg:
|
||||||
|
data = originalData
|
||||||
|
type = originalType
|
||||||
|
default:
|
||||||
|
let image = UIImage(data: originalData)!
|
||||||
|
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
|
||||||
|
data = image.jpegData(compressionQuality: 0.8)!
|
||||||
|
type = .jpeg
|
||||||
|
}
|
||||||
|
let processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion)
|
||||||
|
completion(.success(processed))
|
||||||
|
case let .asset(asset):
|
||||||
|
if asset.mediaType == .image {
|
||||||
|
let options = PHImageRequestOptions()
|
||||||
|
options.version = .current
|
||||||
|
options.deliveryMode = .highQualityFormat
|
||||||
|
options.resizeMode = .none
|
||||||
|
options.isNetworkAccessAllowed = true
|
||||||
|
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in
|
||||||
|
guard let data = data, let dataUTI = dataUTI else {
|
||||||
|
completion(.failure(.missingData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
|
||||||
|
completion(.success(processed))
|
||||||
|
}
|
||||||
|
} else if asset.mediaType == .video {
|
||||||
|
let options = PHVideoRequestOptions()
|
||||||
|
options.deliveryMode = .automatic
|
||||||
|
options.isNetworkAccessAllowed = true
|
||||||
|
options.version = .current
|
||||||
|
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
|
||||||
|
if let exportSession = exportSession {
|
||||||
|
AttachmentData.exportVideoData(session: exportSession, completion: completion)
|
||||||
|
} else if let error = info?[PHImageErrorKey] as? Error {
|
||||||
|
completion(.failure(.videoExport(error)))
|
||||||
|
} else {
|
||||||
|
completion(.failure(.noVideoExportSession))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fatalError("assetType must be either image or video")
|
||||||
|
}
|
||||||
|
case let .video(url):
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||||
|
completion(.failure(.noVideoExportSession))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AttachmentData.exportVideoData(session: session, completion: completion)
|
||||||
|
|
||||||
|
case let .drawing(drawing):
|
||||||
|
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
|
||||||
|
completion(.success((image.pngData()!, .png)))
|
||||||
|
case let .gif(data):
|
||||||
|
completion(.success((data, .gif)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
|
||||||
|
guard !skipAllConversion else {
|
||||||
|
return (data, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = data
|
||||||
|
var type = type
|
||||||
|
let image = CIImage(data: data)!
|
||||||
|
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
||||||
|
|
||||||
|
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
||||||
|
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
|
||||||
|
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
||||||
|
if needsColorSpaceConversion || type == .heic {
|
||||||
|
let context = CIContext()
|
||||||
|
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
||||||
|
if type == .png {
|
||||||
|
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
|
||||||
|
} else {
|
||||||
|
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
|
||||||
|
type = .jpeg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
||||||
|
session.outputFileType = .mp4
|
||||||
|
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
|
||||||
|
session.exportAsynchronously {
|
||||||
|
guard session.status == .completed else {
|
||||||
|
completion(.failure(.videoExport(session.error!)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: session.outputURL!)
|
||||||
|
completion(.success((data, .mpeg4Movie)))
|
||||||
|
} catch {
|
||||||
|
completion(.failure(.videoExport(error)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AttachmentType {
|
||||||
|
case image, video
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Error: Swift.Error, LocalizedError {
|
||||||
|
case missingData
|
||||||
|
case videoExport(Swift.Error)
|
||||||
|
case noVideoExportSession
|
||||||
|
|
||||||
|
var localizedDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .missingData:
|
||||||
|
return "Missing Data"
|
||||||
|
case .videoExport(let error):
|
||||||
|
return "Exporting video: \(error)"
|
||||||
|
case .noVideoExportSession:
|
||||||
|
return "Couldn't create video export session"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PHAsset {
|
||||||
|
var attachmentType: AttachmentData.AttachmentType? {
|
||||||
|
switch self.mediaType {
|
||||||
|
case .image:
|
||||||
|
return .image
|
||||||
|
case .video:
|
||||||
|
return .video
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AttachmentData: Codable {
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
switch self {
|
||||||
|
case let .asset(asset):
|
||||||
|
try container.encode("asset", forKey: .type)
|
||||||
|
try container.encode(asset.localIdentifier, forKey: .assetIdentifier)
|
||||||
|
case let .image(originalData, originalType):
|
||||||
|
try container.encode("image", forKey: .type)
|
||||||
|
try container.encode(originalType, forKey: .imageType)
|
||||||
|
try container.encode(originalData, forKey: .imageData)
|
||||||
|
case .video(_):
|
||||||
|
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded"))
|
||||||
|
case let .drawing(drawing):
|
||||||
|
try container.encode("drawing", forKey: .type)
|
||||||
|
let drawingData = drawing.dataRepresentation()
|
||||||
|
try container.encode(drawingData, forKey: .drawing)
|
||||||
|
case .gif(_):
|
||||||
|
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
switch try container.decode(String.self, forKey: .type) {
|
||||||
|
case "asset":
|
||||||
|
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier")
|
||||||
|
}
|
||||||
|
self = .asset(asset)
|
||||||
|
case "image":
|
||||||
|
let data = try container.decode(Data.self, forKey: .imageData)
|
||||||
|
if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) {
|
||||||
|
self = .image(data, originalType: type)
|
||||||
|
} else {
|
||||||
|
guard let image = UIImage(data: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage")
|
||||||
|
}
|
||||||
|
let jpegData = image.jpegData(compressionQuality: 1)!
|
||||||
|
self = .image(jpegData, originalType: .jpeg)
|
||||||
|
}
|
||||||
|
case "drawing":
|
||||||
|
let drawingData = try container.decode(Data.self, forKey: .drawing)
|
||||||
|
let drawing = try PKDrawing(data: drawingData)
|
||||||
|
self = .drawing(drawing)
|
||||||
|
default:
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: CodingKey {
|
||||||
|
case type
|
||||||
|
case imageData
|
||||||
|
case imageType
|
||||||
|
/// The local identifier of the PHAsset for this attachment
|
||||||
|
case assetIdentifier
|
||||||
|
/// The PKDrawing object for this attachment.
|
||||||
|
case drawing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AttachmentData: Equatable {
|
||||||
|
static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case let (.asset(a), .asset(b)):
|
||||||
|
return a.localIdentifier == b.localIdentifier
|
||||||
|
case let (.image(a, originalType: aType), .image(b, originalType: bType)):
|
||||||
|
return a == b && aType == bType
|
||||||
|
case let (.video(a), .video(b)):
|
||||||
|
return a == b
|
||||||
|
case let (.drawing(a), .drawing(b)):
|
||||||
|
return a == b
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,7 +23,7 @@ enum StatusFormat: Int, CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageName: String {
|
var imageName: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .italics:
|
case .italics:
|
||||||
return "italic"
|
return "italic"
|
||||||
|
@ -31,8 +31,16 @@ enum StatusFormat: Int, CaseIterable {
|
||||||
return "bold"
|
return "bold"
|
||||||
case .strikethrough:
|
case .strikethrough:
|
||||||
return "strikethrough"
|
return "strikethrough"
|
||||||
case .code:
|
default:
|
||||||
return "chevron.left.forwardslash.chevron.right"
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: (String, [NSAttributedString.Key: Any])? {
|
||||||
|
if self == .code {
|
||||||
|
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import PencilKit
|
||||||
|
|
||||||
extension PKDrawing {
|
extension PKDrawing {
|
||||||
|
|
||||||
func imageInLightMode(from rect: CGRect, scale: CGFloat = 1) -> UIImage {
|
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
|
||||||
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
|
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
|
||||||
var drawingImage: UIImage!
|
var drawingImage: UIImage!
|
||||||
lightTraitCollection.performAsCurrent {
|
lightTraitCollection.performAsCurrent {
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/22/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TestView: View {
|
||||||
|
@State var manager = DraftsPersistentContainer()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Button("Add") {
|
||||||
|
let entity = TestEntity(context: manager.viewContext)
|
||||||
|
entity.id = UUID()
|
||||||
|
try! manager.viewContext.save()
|
||||||
|
}
|
||||||
|
InnerView()
|
||||||
|
.environment(\.managedObjectContext, manager.viewContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InnerView: View {
|
||||||
|
@FetchRequest(sortDescriptors: []) var results: FetchedResults<TestEntity>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(results) { result in
|
||||||
|
Text(result.id?.uuidString ?? "<nil>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -39,7 +39,6 @@ extension TextViewCaretScrolling {
|
||||||
|
|
||||||
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
||||||
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
||||||
scrollView.layoutIfNeeded()
|
|
||||||
}
|
}
|
||||||
self.caretScrollPositionAnimator = animator
|
self.caretScrollPositionAnimator = animator
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// View+ForwardsCompat.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.scrollDisabled(disabled)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,21 +22,13 @@ struct InlineAttachmentDescriptionView: View {
|
||||||
self.minHeight = minHeight
|
self.minHeight = minHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
private var placeholderOffset: CGSize {
|
|
||||||
#if os(visionOS)
|
|
||||||
CGSize(width: 8, height: 8)
|
|
||||||
#else
|
|
||||||
CGSize(width: 4, height: 8)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if attachment.attachmentDescription.isEmpty {
|
if attachment.attachmentDescription.isEmpty {
|
||||||
placeholder
|
placeholder
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.offset(placeholderOffset)
|
.offset(x: 4, y: 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
WrappedTextView(
|
WrappedTextView(
|
||||||
|
@ -92,10 +84,6 @@ private struct WrappedTextView: UIViewRepresentable {
|
||||||
view.font = .preferredFont(forTextStyle: .body)
|
view.font = .preferredFont(forTextStyle: .body)
|
||||||
view.adjustsFontForContentSizeCategory = true
|
view.adjustsFontForContentSizeCategory = true
|
||||||
view.textContainer.lineBreakMode = .byWordWrapping
|
view.textContainer.lineBreakMode = .byWordWrapping
|
||||||
#if os(visionOS)
|
|
||||||
view.borderStyle = .roundedRect
|
|
||||||
view.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
|
|
||||||
#endif
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,19 +52,12 @@ struct EmojiTextField: UIViewRepresentable {
|
||||||
if text != uiView.text {
|
if text != uiView.text {
|
||||||
uiView.text = text
|
uiView.text = text
|
||||||
}
|
}
|
||||||
if placeholder != uiView.attributedPlaceholder?.string {
|
|
||||||
uiView.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
|
|
||||||
.foregroundColor: UIColor.secondaryLabel,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
context.coordinator.text = $text
|
context.coordinator.text = $text
|
||||||
context.coordinator.maxLength = maxLength
|
context.coordinator.maxLength = maxLength
|
||||||
context.coordinator.focusNextView = focusNextView
|
context.coordinator.focusNextView = focusNextView
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
|
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
|
||||||
#endif
|
|
||||||
|
|
||||||
if becomeFirstResponder?.wrappedValue == true {
|
if becomeFirstResponder?.wrappedValue == true {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|
|
@ -22,21 +22,14 @@ struct LanguagePicker: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
|
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
|
||||||
guard let bcp47Lang = mode.primaryLanguage,
|
guard let bcp47Lang = mode.primaryLanguage else {
|
||||||
!bcp47Lang.isEmpty else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: min(3, bcp47Lang.count))]
|
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: 3)]
|
||||||
if maybeIso639Code.last == "-" {
|
if maybeIso639Code.last == "-" {
|
||||||
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
|
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
|
||||||
}
|
}
|
||||||
let identifier = String(maybeIso639Code)
|
let code = Locale.LanguageCode(String(maybeIso639Code))
|
||||||
// mul (for multiple languages) and unk (unknown) are ISO codes, but not ones that akkoma permits, so we ignore them on all platforms
|
|
||||||
guard identifier != "mul",
|
|
||||||
identifier != "und" else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let code = Locale.LanguageCode(identifier)
|
|
||||||
if code.isISOLanguage {
|
if code.isISOLanguage {
|
||||||
return code
|
return code
|
||||||
} else {
|
} else {
|
||||||
|
@ -45,13 +38,16 @@ struct LanguagePicker: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var codeFromPreferredLanguages: Locale.LanguageCode? {
|
private var codeFromPreferredLanguages: Locale.LanguageCode? {
|
||||||
if let identifier = Locale.preferredLanguages.first,
|
if let identifier = Locale.preferredLanguages.first {
|
||||||
case let code = Locale.LanguageCode(identifier),
|
let code = Locale.LanguageCode(identifier)
|
||||||
code.isISOLanguage {
|
if code.isISOLanguage {
|
||||||
return code
|
return code
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var languageCode: Binding<Locale.LanguageCode> {
|
private var languageCode: Binding<Locale.LanguageCode> {
|
||||||
|
@ -69,8 +65,6 @@ struct LanguagePicker: View {
|
||||||
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
|
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Post Language")
|
.accessibilityLabel("Post Language")
|
||||||
.padding(5)
|
|
||||||
.hoverEffect()
|
|
||||||
.sheet(isPresented: $isShowingSheet) {
|
.sheet(isPresented: $isShowingSheet) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
|
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
|
||||||
|
@ -129,9 +123,7 @@ private struct LanguagePickerList: View {
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
|
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
|
||||||
.searchable(text: $query)
|
.searchable(text: $query)
|
||||||
#if !os(visionOS)
|
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
#endif
|
|
||||||
.navigationTitle("Post Language")
|
.navigationTitle("Post Language")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
@ -145,32 +137,20 @@ private struct LanguagePickerList: View {
|
||||||
// make sure recents always contains the currently selected lang
|
// make sure recents always contains the currently selected lang
|
||||||
let recents = addRecentLang(languageCode)
|
let recents = addRecentLang(languageCode)
|
||||||
recentLangs = recents
|
recentLangs = recents
|
||||||
.filter { $0 != "mul" && $0 != "und" }
|
|
||||||
.map { Lang(code: .init($0)) }
|
.map { Lang(code: .init($0)) }
|
||||||
.sorted { $0.name < $1.name }
|
.sorted { $0.name < $1.name }
|
||||||
|
|
||||||
langs = Locale.LanguageCode.isoLanguageCodes
|
langs = Locale.LanguageCode.isoLanguageCodes
|
||||||
.filter { $0.identifier != "mul" && $0.identifier != "und" }
|
|
||||||
.map { Lang(code: $0) }
|
.map { Lang(code: $0) }
|
||||||
.sorted { $0.name < $1.name }
|
.sorted { $0.name < $1.name }
|
||||||
}
|
}
|
||||||
#if os(visionOS)
|
|
||||||
.onChange(of: query, initial: true) {
|
|
||||||
filteredLangsChanged(query: query)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
.onChange(of: query) { newValue in
|
.onChange(of: query) { newValue in
|
||||||
filteredLangsChanged(query: newValue)
|
if newValue.isEmpty {
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func filteredLangsChanged(query: String) {
|
|
||||||
if query.isEmpty {
|
|
||||||
filteredLangs = nil
|
filteredLangs = nil
|
||||||
} else {
|
} else {
|
||||||
filteredLangs = langs.filter {
|
filteredLangs = langs.filter {
|
||||||
$0.name.localizedCaseInsensitiveContains(query) || $0.code.identifier.localizedCaseInsensitiveContains(query)
|
$0.name.localizedCaseInsensitiveContains(newValue) || $0.code.identifier.localizedCaseInsensitiveContains(newValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,41 +23,19 @@ struct MainTextView: View {
|
||||||
controller.config
|
controller.config
|
||||||
}
|
}
|
||||||
|
|
||||||
private var placeholderOffset: CGSize {
|
|
||||||
#if os(visionOS)
|
|
||||||
CGSize(width: 8, height: 8)
|
|
||||||
#else
|
|
||||||
CGSize(width: 4, height: 8)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textViewBackgroundColor: UIColor? {
|
|
||||||
#if os(visionOS)
|
|
||||||
nil
|
|
||||||
#else
|
|
||||||
colorScheme == .dark ? UIColor(config.fillColor) : .secondarySystemBackground
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
MainWrappedTextViewRepresentable(
|
colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground)
|
||||||
text: $draft.text,
|
|
||||||
backgroundColor: textViewBackgroundColor,
|
|
||||||
becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder,
|
|
||||||
updateSelection: $updateSelection,
|
|
||||||
textDidChange: textDidChange
|
|
||||||
)
|
|
||||||
|
|
||||||
if draft.text.isEmpty {
|
if draft.text.isEmpty {
|
||||||
ControllerView(controller: { PlaceholderController() })
|
ControllerView(controller: { PlaceholderController() })
|
||||||
.font(.system(size: fontSize))
|
.font(.system(size: fontSize))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.offset(placeholderOffset)
|
.offset(x: 4, y: 8)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, updateSelection: $updateSelection, textDidChange: textDidChange)
|
||||||
}
|
}
|
||||||
.frame(height: effectiveHeight)
|
.frame(height: effectiveHeight)
|
||||||
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
|
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
|
||||||
|
@ -84,7 +62,6 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
||||||
typealias UIViewType = UITextView
|
typealias UIViewType = UITextView
|
||||||
|
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let backgroundColor: UIColor?
|
|
||||||
@Binding var becomeFirstResponder: Bool
|
@Binding var becomeFirstResponder: Bool
|
||||||
@Binding var updateSelection: ((UITextView) -> Void)?
|
@Binding var updateSelection: ((UITextView) -> Void)?
|
||||||
let textDidChange: (UITextView) -> Void
|
let textDidChange: (UITextView) -> Void
|
||||||
|
@ -97,16 +74,10 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
||||||
context.coordinator.textView = textView
|
context.coordinator.textView = textView
|
||||||
textView.delegate = context.coordinator
|
textView.delegate = context.coordinator
|
||||||
textView.isEditable = true
|
textView.isEditable = true
|
||||||
|
textView.backgroundColor = .clear
|
||||||
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
|
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
|
||||||
textView.adjustsFontForContentSizeCategory = true
|
textView.adjustsFontForContentSizeCategory = true
|
||||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||||
|
|
||||||
#if os(visionOS)
|
|
||||||
textView.borderStyle = .roundedRect
|
|
||||||
// yes, the X inset is 4 less than the placeholder offset
|
|
||||||
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return textView
|
return textView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,8 +90,6 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
||||||
uiView.isEditable = isEnabled
|
uiView.isEditable = isEnabled
|
||||||
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
|
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
|
||||||
|
|
||||||
uiView.backgroundColor = backgroundColor
|
|
||||||
|
|
||||||
context.coordinator.text = $text
|
context.coordinator.text = $text
|
||||||
|
|
||||||
if let updateSelection {
|
if let updateSelection {
|
||||||
|
@ -259,7 +228,11 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
||||||
if range.length > 0 {
|
if range.length > 0 {
|
||||||
let formatMenu = suggestedActions[index] as! UIMenu
|
let formatMenu = suggestedActions[index] as! UIMenu
|
||||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||||
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)
|
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
|
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
|
||||||
offset = min(offset, maxOffset)
|
offset = min(offset, maxOffset)
|
||||||
|
|
||||||
return AvatarContainerRepresentable(offset: offset) {
|
return AvatarImageView(
|
||||||
AvatarImageView(
|
|
||||||
url: status.account.avatar,
|
url: status.account.avatar,
|
||||||
size: 50,
|
size: 50,
|
||||||
style: controller.config.avatarStyle,
|
style: controller.config.avatarStyle,
|
||||||
fetchAvatar: controller.fetchAvatar
|
fetchAvatar: controller.fetchAvatar
|
||||||
)
|
)
|
||||||
}
|
.offset(x: 0, y: offset)
|
||||||
.frame(width: 50, height: 50)
|
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,39 +94,3 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
|
||||||
value = nextValue()
|
value = nextValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This whole dance is necessary so that the offset can be animatable from
|
|
||||||
// UIKit animations, like TextViewCaretScrolling.
|
|
||||||
private struct AvatarContainerRepresentable<Content: View>: UIViewControllerRepresentable {
|
|
||||||
let offset: CGFloat
|
|
||||||
@ViewBuilder let content: Content
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> Controller {
|
|
||||||
Controller(host: UIHostingController(rootView: content))
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: Controller, context: Context) {
|
|
||||||
uiViewController.host.rootView = content
|
|
||||||
uiViewController.host.view.transform = CGAffineTransform(translationX: 0, y: offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This extra layer is necessary because applying a transform to the
|
|
||||||
// representable's VC's view doesn't seem to have an effect.
|
|
||||||
class Controller: UIViewController {
|
|
||||||
let host: UIHostingController<Content>
|
|
||||||
|
|
||||||
init(host: UIHostingController<Content>) {
|
|
||||||
self.host = host
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
addChild(host)
|
|
||||||
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
||||||
view.addSubview(host.view)
|
|
||||||
host.view.frame = view.bounds
|
|
||||||
host.didMove(toParent: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version: 6.0
|
// swift-tools-version: 5.7
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Duckable",
|
name: "Duckable",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -23,10 +23,7 @@ let package = Package(
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "Duckable",
|
name: "Duckable",
|
||||||
dependencies: [],
|
dependencies: []),
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
|
||||||
// .testTarget(
|
// .testTarget(
|
||||||
// name: "DuckableTests",
|
// name: "DuckableTests",
|
||||||
// dependencies: ["Duckable"]),
|
// dependencies: ["Duckable"]),
|
||||||
|
|
|
@ -7,8 +7,9 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public protocol DuckableViewController: UIViewController {
|
public protocol DuckableViewController: UIViewController {
|
||||||
|
var duckableDelegate: DuckableViewControllerDelegate? { get set }
|
||||||
|
|
||||||
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
||||||
|
|
||||||
func duckableViewControllerMayAttemptToDuck()
|
func duckableViewControllerMayAttemptToDuck()
|
||||||
|
@ -25,6 +26,10 @@ extension DuckableViewController {
|
||||||
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public protocol DuckableViewControllerDelegate: AnyObject {
|
||||||
|
func duckableViewControllerWillDismiss(animated: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
public enum DuckAttemptAction {
|
public enum DuckAttemptAction {
|
||||||
case duck
|
case duck
|
||||||
case dismiss
|
case dismiss
|
||||||
|
@ -33,11 +38,11 @@ public enum DuckAttemptAction {
|
||||||
|
|
||||||
extension UIViewController {
|
extension UIViewController {
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false, completion: (() -> Void)? = nil) -> Bool {
|
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
||||||
var cur: UIViewController? = self
|
var cur: UIViewController? = self
|
||||||
while let vc = cur {
|
while let vc = cur {
|
||||||
if let container = vc as? DuckableContainerViewController {
|
if let container = vc as? DuckableContainerViewController {
|
||||||
container._presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: completion)
|
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
cur = vc.parent
|
cur = vc.parent
|
||||||
|
|
|
@ -11,7 +11,7 @@ let duckedCornerRadius: CGFloat = 10
|
||||||
let detentHeight: CGFloat = 44
|
let detentHeight: CGFloat = 44
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
public class DuckableContainerViewController: UIViewController {
|
public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate {
|
||||||
|
|
||||||
public let child: UIViewController
|
public let child: UIViewController
|
||||||
private var bottomConstraint: NSLayoutConstraint!
|
private var bottomConstraint: NSLayoutConstraint!
|
||||||
|
@ -58,13 +58,11 @@ public class DuckableContainerViewController: UIViewController {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func _presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
|
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
|
||||||
guard case .idle = state else {
|
guard case .idle = state else {
|
||||||
if animated,
|
if animated,
|
||||||
case .ducked(_, placeholder: let placeholder) = state {
|
case .ducked(_, placeholder: let placeholder) = state {
|
||||||
#if !os(visionOS)
|
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
#endif
|
|
||||||
let origConstant = placeholder.topConstraint.constant
|
let origConstant = placeholder.topConstraint.constant
|
||||||
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
|
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
|
||||||
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||||
|
@ -89,6 +87,7 @@ public class DuckableContainerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||||
|
viewController.duckableDelegate = self
|
||||||
viewController.modalPresentationStyle = .custom
|
viewController.modalPresentationStyle = .custom
|
||||||
viewController.transitioningDelegate = self
|
viewController.transitioningDelegate = self
|
||||||
present(viewController, animated: animated) {
|
present(viewController, animated: animated) {
|
||||||
|
@ -97,10 +96,7 @@ public class DuckableContainerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dismissalTransitionWillBegin() {
|
public func duckableViewControllerWillDismiss(animated: Bool) {
|
||||||
guard case .presentingDucked(_, _) = state else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state = .idle
|
state = .idle
|
||||||
bottomConstraint.isActive = false
|
bottomConstraint.isActive = false
|
||||||
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
|
@ -148,7 +144,7 @@ public class DuckableContainerViewController: UIViewController {
|
||||||
case .block:
|
case .block:
|
||||||
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
|
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
|
||||||
case .dismiss:
|
case .dismiss:
|
||||||
// duckableViewControllerWillDismiss()
|
duckableViewControllerWillDismiss(animated: true)
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -193,7 +189,7 @@ public class DuckableContainerViewController: UIViewController {
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
|
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
|
||||||
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
||||||
let controller = DuckableSheetPresentationController(presentedViewController: presented, presenting: presenting)
|
let controller = UISheetPresentationController(presentedViewController: presented, presenting: presenting)
|
||||||
controller.delegate = self
|
controller.delegate = self
|
||||||
controller.prefersGrabberVisible = true
|
controller.prefersGrabberVisible = true
|
||||||
controller.selectedDetentIdentifier = .large
|
controller.selectedDetentIdentifier = .large
|
||||||
|
@ -219,14 +215,6 @@ extension DuckableContainerViewController: UIViewControllerTransitioningDelegate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
|
||||||
class DuckableSheetPresentationController: UISheetPresentationController {
|
|
||||||
override func dismissalTransitionWillBegin() {
|
|
||||||
super.dismissalTransitionWillBegin()
|
|
||||||
(self.delegate as! DuckableContainerViewController).dismissalTransitionWillBegin()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
|
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
|
||||||
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
|
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
.DS_Store
|
|
||||||
/.build
|
|
||||||
/Packages
|
|
||||||
xcuserdata/
|
|
||||||
DerivedData/
|
|
||||||
.swiftpm/configuration/registries.json
|
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
|
||||||
.netrc
|
|
|
@ -1,32 +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: "GalleryVC",
|
|
||||||
platforms: [
|
|
||||||
.iOS(.v16),
|
|
||||||
],
|
|
||||||
products: [
|
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
|
||||||
.library(
|
|
||||||
name: "GalleryVC",
|
|
||||||
targets: ["GalleryVC"]),
|
|
||||||
],
|
|
||||||
targets: [
|
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
|
||||||
.target(
|
|
||||||
name: "GalleryVC",
|
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
|
||||||
.testTarget(
|
|
||||||
name: "GalleryVCTests",
|
|
||||||
dependencies: ["GalleryVC"],
|
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
)
|
|
|
@ -1,46 +0,0 @@
|
||||||
//
|
|
||||||
// GalleryContentViewController.swift
|
|
||||||
// GalleryVC
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/17/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public protocol GalleryContentViewController: UIViewController {
|
|
||||||
var container: GalleryContentViewControllerContainer? { get set }
|
|
||||||
var contentSize: CGSize { get }
|
|
||||||
var activityItemsForSharing: [Any] { get }
|
|
||||||
var caption: String? { get }
|
|
||||||
var contentOverlayAccessoryViewController: UIViewController? { get }
|
|
||||||
var bottomControlsAccessoryViewController: UIViewController? { get }
|
|
||||||
var canAnimateFromSourceView: Bool { get }
|
|
||||||
|
|
||||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
|
|
||||||
func galleryContentDidAppear()
|
|
||||||
func galleryContentWillDisappear()
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension GalleryContentViewController {
|
|
||||||
var contentOverlayAccessoryViewController: UIViewController? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var bottomControlsAccessoryViewController: UIViewController? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var canAnimateFromSourceView: Bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func galleryContentDidAppear() {
|
|
||||||
}
|
|
||||||
|
|
||||||
func galleryContentWillDisappear() {
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
//
|
|
||||||
// GalleryContentViewControllerContainer.swift
|
|
||||||
// GalleryVC
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/28/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public protocol GalleryContentViewControllerContainer: AnyObject {
|
|
||||||
var galleryControlsVisible: Bool { get }
|
|
||||||
|
|
||||||
func setGalleryContentLoading(_ loading: Bool)
|
|
||||||
func galleryContentChanged()
|
|
||||||
func disableGalleryScrollAndZoom()
|
|
||||||
func setGalleryControlsVisible(_ visible: Bool, animated: Bool)
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
//
|
|
||||||
// GalleryDataSource.swift
|
|
||||||
// GalleryVC
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/28/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public protocol GalleryDataSource {
|
|
||||||
func galleryItemsCount() -> Int
|
|
||||||
func galleryContentViewController(forItemAt index: Int) -> GalleryContentViewController
|
|
||||||
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView?
|
|
||||||
func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]?
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension GalleryDataSource {
|
|
||||||
func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,140 +0,0 @@
|
||||||
//
|
|
||||||
// GalleryDismissAnimationController.swift
|
|
||||||
// GalleryVC
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/1/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
|
||||||
private let sourceView: UIView
|
|
||||||
private let interactiveTranslation: CGPoint?
|
|
||||||
private let interactiveVelocity: CGPoint?
|
|
||||||
|
|
||||||
init(sourceView: UIView, interactiveTranslation: CGPoint?, interactiveVelocity: CGPoint?) {
|
|
||||||
self.sourceView = sourceView
|
|
||||||
self.interactiveTranslation = interactiveTranslation
|
|
||||||
self.interactiveVelocity = interactiveVelocity
|
|
||||||
}
|
|
||||||
|
|
||||||
func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval {
|
|
||||||
return 0.3
|
|
||||||
}
|
|
||||||
|
|
||||||
func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
|
|
||||||
guard let to = transitionContext.viewController(forKey: .to),
|
|
||||||
let from = transitionContext.viewController(forKey: .from) as? GalleryViewController else {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
let itemViewController = from.currentItemViewController
|
|
||||||
|
|
||||||
if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
|
|
||||||
animateCrossFadeTransition(using: transitionContext)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let container = transitionContext.containerView
|
|
||||||
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
|
|
||||||
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
|
|
||||||
|
|
||||||
let origSourceTransform = sourceView.transform
|
|
||||||
let appliedSourceToDestTransform: Bool
|
|
||||||
if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 {
|
|
||||||
appliedSourceToDestTransform = true
|
|
||||||
let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height)
|
|
||||||
let sourceToDestTransform = origSourceTransform
|
|
||||||
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
|
|
||||||
.scaledBy(x: scale, y: scale)
|
|
||||||
sourceView.transform = sourceToDestTransform
|
|
||||||
} else {
|
|
||||||
appliedSourceToDestTransform = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
|
||||||
// is in the window's root presentation.
|
|
||||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
|
||||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
|
||||||
// container causees it to be removed when the transition completes.
|
|
||||||
if to.view.superview == nil {
|
|
||||||
to.view.frame = container.bounds
|
|
||||||
container.addSubview(to.view)
|
|
||||||
}
|
|
||||||
|
|
||||||
from.view.frame = container.bounds
|
|
||||||
container.addSubview(from.view)
|
|
||||||
|
|
||||||
let content = itemViewController.takeContent()
|
|
||||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
|
||||||
content.view.layer.masksToBounds = true
|
|
||||||
container.addSubview(content.view)
|
|
||||||
|
|
||||||
content.view.frame = destFrameInContainer
|
|
||||||
content.view.layer.opacity = 1
|
|
||||||
|
|
||||||
container.layoutIfNeeded()
|
|
||||||
|
|
||||||
let duration = self.transitionDuration(using: transitionContext)
|
|
||||||
var initialVelocity: CGVector
|
|
||||||
if let interactiveVelocity,
|
|
||||||
let interactiveTranslation,
|
|
||||||
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot
|
|
||||||
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
|
|
||||||
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
|
|
||||||
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
|
|
||||||
let yDistance = sourceFrameInContainer.midY - destFrameInContainer.midY
|
|
||||||
initialVelocity = CGVector(
|
|
||||||
dx: xDistance == 0 ? 0 : interactiveVelocity.x / xDistance,
|
|
||||||
dy: yDistance == 0 ? 0 : interactiveVelocity.y / yDistance
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
initialVelocity = .zero
|
|
||||||
}
|
|
||||||
initialVelocity.dx = max(-10, min(10, initialVelocity.dx))
|
|
||||||
initialVelocity.dy = max(-10, min(10, initialVelocity.dy))
|
|
||||||
// no bounce for the dismiss animation
|
|
||||||
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: initialVelocity)
|
|
||||||
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
|
|
||||||
|
|
||||||
animator.addAnimations {
|
|
||||||
from.view.layer.opacity = 0
|
|
||||||
|
|
||||||
if appliedSourceToDestTransform {
|
|
||||||
self.sourceView.transform = origSourceTransform
|
|
||||||
}
|
|
||||||
content.view.frame = sourceFrameInContainer
|
|
||||||
content.view.layer.opacity = 0
|
|
||||||
|
|
||||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
animator.addCompletion { _ in
|
|
||||||
transitionContext.completeTransition(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
animator.startAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
|
||||||
guard let fromVC = transitionContext.viewController(forKey: .from),
|
|
||||||
let toVC = transitionContext.viewController(forKey: .to) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toVC.view.frame = transitionContext.containerView.bounds
|
|
||||||
fromVC.view.frame = transitionContext.containerView.bounds
|
|
||||||
transitionContext.containerView.addSubview(toVC.view)
|
|
||||||
transitionContext.containerView.addSubview(fromVC.view)
|
|
||||||
|
|
||||||
let duration = transitionDuration(using: transitionContext)
|
|
||||||
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
|
|
||||||
animator.addAnimations {
|
|
||||||
fromVC.view.alpha = 0
|
|
||||||
}
|
|
||||||
animator.addCompletion { _ in
|
|
||||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
|
||||||
}
|
|
||||||
animator.startAnimation()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,83 +0,0 @@
|
||||||
//
|
|
||||||
// GalleryDismissInteraction.swift
|
|
||||||
// GalleryVC
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/1/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class GalleryDismissInteraction: NSObject {
|
|
||||||
|
|
||||||
private unowned let viewController: GalleryViewController
|
|
||||||
|
|
||||||
private var content: GalleryContentViewController?
|
|
||||||
private var origContentFrameInGallery: CGRect?
|
|
||||||
private var origControlsVisible: Bool?
|
|
||||||
|
|
||||||
private(set) var isActive = false
|
|
||||||
private(set) var dismissVelocity: CGPoint?
|
|
||||||
private(set) var dismissTranslation: CGPoint?
|
|
||||||
|
|
||||||
init(viewController: GalleryViewController) {
|
|
||||||
self.viewController = viewController
|
|
||||||
super.init()
|
|
||||||
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
|
|
||||||
panRecognizer.delegate = self
|
|
||||||
panRecognizer.allowedScrollTypesMask = .continuous
|
|
||||||
viewController.view.addGestureRecognizer(panRecognizer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
||||||
switch recognizer.state {
|
|
||||||
case .began:
|
|
||||||
isActive = true
|
|
||||||
|
|
||||||
origContentFrameInGallery = viewController.view.convert(viewController.currentItemViewController.content.view.bounds, from: viewController.currentItemViewController.content.view)
|
|
||||||
content = viewController.currentItemViewController.takeContent()
|
|
||||||
content!.view.translatesAutoresizingMaskIntoConstraints = true
|
|
||||||
content!.view.frame = origContentFrameInGallery!
|
|
||||||
viewController.view.addSubview(content!.view)
|
|
||||||
|
|
||||||
origControlsVisible = viewController.currentItemViewController.controlsVisible
|
|
||||||
if origControlsVisible! {
|
|
||||||
viewController.currentItemViewController.setControlsVisible(false, animated: true, dueToUserInteraction: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .changed:
|
|
||||||
let translation = recognizer.translation(in: viewController.view)
|
|
||||||
content!.view.frame = origContentFrameInGallery!.offsetBy(dx: translation.x, dy: translation.y)
|
|
||||||
|
|
||||||
case .ended:
|
|
||||||
let translation = recognizer.translation(in: viewController.view)
|
|
||||||
let velocity = recognizer.velocity(in: viewController.view)
|
|
||||||
|
|
||||||
dismissVelocity = velocity
|
|
||||||
dismissTranslation = translation
|
|
||||||
viewController.dismiss(animated: true)
|
|
||||||
|
|
||||||
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
|
|
||||||
isActive = false
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GalleryDismissInteraction: UIGestureRecognizerDelegate {
|
|
||||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
||||||
let itemVC = viewController.currentItemViewController
|
|
||||||
if viewController.galleryDataSource.galleryContentTransitionSourceView(forItemAt: itemVC.itemIndex) == nil {
|
|
||||||
return false
|
|
||||||
} else if itemVC.scrollView.zoomScale > itemVC.scrollView.minimumZoomScale {
|
|
||||||
return false
|
|
||||||
} else if !itemVC.scrollAndZoomEnabled {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,570 +0,0 @@
|
||||||
//
|
|
||||||
// GalleryItemViewController.swift
|
|
||||||
// GalleryVC
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/28/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import AVFoundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
protocol GalleryItemViewControllerDelegate: AnyObject {
|
|
||||||
func isGalleryBeingPresented() -> Bool
|
|
||||||
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
|
|
||||||
func galleryItemClose(_ item: GalleryItemViewController)
|
|
||||||
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
|
|
||||||
}
|
|
||||||
|
|
||||||
class GalleryItemViewController: UIViewController {
|
|
||||||
private weak var delegate: GalleryItemViewControllerDelegate?
|
|
||||||
|
|
||||||
let itemIndex: Int
|
|
||||||
let content: GalleryContentViewController
|
|
||||||
private var overlayVC: UIViewController?
|
|
||||||
|
|
||||||
private var activityIndicator: UIActivityIndicatorView?
|
|
||||||
private(set) var scrollView: UIScrollView!
|
|
||||||
private var topControlsView: UIView!
|
|
||||||
private var shareButton: UIButton!
|
|
||||||
private var shareButtonLeadingConstraint: NSLayoutConstraint!
|
|
||||||
private var shareButtonTopConstraint: NSLayoutConstraint!
|
|
||||||
private var closeButtonTrailingConstraint: NSLayoutConstraint!
|
|
||||||
private var closeButtonTopConstraint: NSLayoutConstraint!
|
|
||||||
private var bottomControlsView: UIStackView!
|
|
||||||
private(set) var captionTextView: UITextView!
|
|
||||||
|
|
||||||
private var singleTap: UITapGestureRecognizer!
|
|
||||||
private var doubleTap: UITapGestureRecognizer!
|
|
||||||
|
|
||||||
private var contentViewLeadingConstraint: NSLayoutConstraint?
|
|
||||||
private var contentViewTopConstraint: NSLayoutConstraint?
|
|
||||||
|
|
||||||
private(set) var controlsVisible: Bool = true
|
|
||||||
private(set) var scrollAndZoomEnabled = true
|
|
||||||
|
|
||||||
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
|
||||||
|
|
||||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
|
||||||
return !controlsVisible
|
|
||||||
}
|
|
||||||
|
|
||||||
init(delegate: GalleryItemViewControllerDelegate, itemIndex: Int, content: GalleryContentViewController) {
|
|
||||||
self.delegate = delegate
|
|
||||||
self.itemIndex = itemIndex
|
|
||||||
self.content = content
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
|
|
||||||
content.container = self
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
scrollView = UIScrollView()
|
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
scrollView.delegate = self
|
|
||||||
|
|
||||||
view.addSubview(scrollView)
|
|
||||||
|
|
||||||
addContent()
|
|
||||||
centerContent()
|
|
||||||
|
|
||||||
overlayVC = content.contentOverlayAccessoryViewController
|
|
||||||
if let overlayVC {
|
|
||||||
overlayVC.view.isHidden = activityIndicator != nil
|
|
||||||
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(overlayVC.view)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
topControlsView = UIView()
|
|
||||||
topControlsView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(topControlsView)
|
|
||||||
|
|
||||||
var shareConfig = UIButton.Configuration.gray()
|
|
||||||
shareConfig.cornerStyle = .dynamic
|
|
||||||
shareConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
|
|
||||||
shareConfig.baseForegroundColor = .white
|
|
||||||
shareConfig.image = UIImage(systemName: "square.and.arrow.up")
|
|
||||||
shareConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
|
|
||||||
shareButton = UIButton(configuration: shareConfig)
|
|
||||||
shareButton.addTarget(self, action: #selector(shareButtonPressed), for: .touchUpInside)
|
|
||||||
shareButton.isPointerInteractionEnabled = true
|
|
||||||
shareButton.pointerStyleProvider = { button, effect, shape in
|
|
||||||
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
|
|
||||||
}
|
|
||||||
shareButton.preferredBehavioralStyle = .pad
|
|
||||||
shareButton.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
updateShareButton()
|
|
||||||
topControlsView.addSubview(shareButton)
|
|
||||||
|
|
||||||
var closeConfig = UIButton.Configuration.gray()
|
|
||||||
closeConfig.cornerStyle = .dynamic
|
|
||||||
closeConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
|
|
||||||
closeConfig.baseForegroundColor = .white
|
|
||||||
closeConfig.image = UIImage(systemName: "xmark")
|
|
||||||
closeConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
|
|
||||||
let closeButton = UIButton(configuration: closeConfig)
|
|
||||||
closeButton.addTarget(self, action: #selector(closeButtonPressed), for: .touchUpInside)
|
|
||||||
closeButton.isPointerInteractionEnabled = true
|
|
||||||
closeButton.pointerStyleProvider = { button, effect, shape in
|
|
||||||
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
|
|
||||||
}
|
|
||||||
closeButton.preferredBehavioralStyle = .pad
|
|
||||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
topControlsView.addSubview(closeButton)
|
|
||||||
|
|
||||||
bottomControlsView = UIStackView()
|
|
||||||
bottomControlsView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
bottomControlsView.axis = .vertical
|
|
||||||
bottomControlsView.alignment = .fill
|
|
||||||
bottomControlsView.backgroundColor = .black.withAlphaComponent(0.5)
|
|
||||||
view.addSubview(bottomControlsView)
|
|
||||||
|
|
||||||
if let controlsAccessory = content.bottomControlsAccessoryViewController {
|
|
||||||
addChild(controlsAccessory)
|
|
||||||
bottomControlsView.addArrangedSubview(controlsAccessory.view)
|
|
||||||
controlsAccessory.didMove(toParent: self)
|
|
||||||
|
|
||||||
// Make sure the controls accessory is within the safe area.
|
|
||||||
let spacer = UIView()
|
|
||||||
bottomControlsView.addArrangedSubview(spacer)
|
|
||||||
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
|
|
||||||
spacerTopConstraint.priority = .init(999)
|
|
||||||
spacerTopConstraint.isActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
captionTextView = UITextView()
|
|
||||||
captionTextView.backgroundColor = .clear
|
|
||||||
captionTextView.textColor = .white
|
|
||||||
captionTextView.isEditable = false
|
|
||||||
captionTextView.isSelectable = true
|
|
||||||
captionTextView.font = .preferredFont(forTextStyle: .body)
|
|
||||||
captionTextView.adjustsFontForContentSizeCategory = true
|
|
||||||
captionTextView.alwaysBounceVertical = true
|
|
||||||
updateCaptionTextView()
|
|
||||||
bottomControlsView.addArrangedSubview(captionTextView)
|
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
|
|
||||||
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
|
|
||||||
#else
|
|
||||||
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
|
|
||||||
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
|
|
||||||
#endif
|
|
||||||
closeButtonTrailingConstraint = topControlsView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor)
|
|
||||||
shareButtonLeadingConstraint = shareButton.leadingAnchor.constraint(equalTo: topControlsView.leadingAnchor)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
|
|
||||||
topControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
topControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
topControlsView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
|
|
||||||
shareButtonLeadingConstraint,
|
|
||||||
shareButtonTopConstraint,
|
|
||||||
shareButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
|
|
||||||
shareButton.widthAnchor.constraint(equalTo: shareButton.heightAnchor),
|
|
||||||
|
|
||||||
closeButtonTrailingConstraint,
|
|
||||||
closeButtonTopConstraint,
|
|
||||||
closeButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
|
|
||||||
closeButton.widthAnchor.constraint(equalTo: closeButton.heightAnchor),
|
|
||||||
|
|
||||||
bottomControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
bottomControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
bottomControlsView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
|
|
||||||
captionTextView.heightAnchor.constraint(equalToConstant: 150),
|
|
||||||
])
|
|
||||||
|
|
||||||
updateTopControlsInsets()
|
|
||||||
|
|
||||||
singleTap = UITapGestureRecognizer(target: self, action: #selector(viewPressed))
|
|
||||||
singleTap.delegate = self
|
|
||||||
doubleTap = UITapGestureRecognizer(target: self, action: #selector(viewDoublePressed))
|
|
||||||
doubleTap.delegate = self
|
|
||||||
doubleTap.numberOfTapsRequired = 2
|
|
||||||
// This is needed to prevent a delay between tapping a button on and the action firing on Catalyst and Designed for iPad
|
|
||||||
doubleTap.delaysTouchesEnded = false
|
|
||||||
// this requirement is needed to make sure the double tap is ever recognized
|
|
||||||
singleTap.require(toFail: doubleTap)
|
|
||||||
view.addGestureRecognizer(singleTap)
|
|
||||||
view.addGestureRecognizer(doubleTap)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewSafeAreaInsetsDidChange() {
|
|
||||||
super.viewSafeAreaInsetsDidChange()
|
|
||||||
|
|
||||||
updateZoomScale(resetZoom: false)
|
|
||||||
// Ensure the transform is correct if the controls are hidden
|
|
||||||
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
|
||||||
|
|
||||||
updateTopControlsInsets()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
|
|
||||||
// When the scrollView size changes, make sure the zoom scale is up-to-date since it depends on the scrollView's bounds.
|
|
||||||
// This might also fix an issue on macOS (Designed for iPad) where the content isn't placed correctly. See #446
|
|
||||||
if scrollViewSizeForLastZoomScaleUpdate != scrollView.bounds.size {
|
|
||||||
scrollViewSizeForLastZoomScaleUpdate = scrollView.bounds.size
|
|
||||||
updateZoomScale(resetZoom: true)
|
|
||||||
}
|
|
||||||
centerContent()
|
|
||||||
// Ensure the transform is correct if the controls are hidden and their size changed.
|
|
||||||
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
|
|
||||||
if controlsVisible && !captionTextView.isHidden {
|
|
||||||
captionTextView.flashScrollIndicators()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func takeContent() -> GalleryContentViewController {
|
|
||||||
content.willMove(toParent: nil)
|
|
||||||
content.removeFromParent()
|
|
||||||
content.view.removeFromSuperview()
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
func addContent() {
|
|
||||||
content.loadViewIfNeeded()
|
|
||||||
|
|
||||||
content.setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
|
|
||||||
|
|
||||||
content.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
if content.parent != self {
|
|
||||||
addChild(content)
|
|
||||||
content.didMove(toParent: self)
|
|
||||||
}
|
|
||||||
if scrollAndZoomEnabled {
|
|
||||||
scrollView.addSubview(content.view)
|
|
||||||
contentViewLeadingConstraint = content.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
|
|
||||||
contentViewLeadingConstraint!.isActive = true
|
|
||||||
contentViewTopConstraint = content.view.topAnchor.constraint(equalTo: scrollView.topAnchor)
|
|
||||||
contentViewTopConstraint!.isActive = true
|
|
||||||
updateZoomScale(resetZoom: true)
|
|
||||||
} else {
|
|
||||||
// If the content was previously added, deactivate the old constraints.
|
|
||||||
contentViewLeadingConstraint?.isActive = false
|
|
||||||
contentViewTopConstraint?.isActive = false
|
|
||||||
|
|
||||||
view.addSubview(content.view)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
content.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
content.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
content.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
content.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if let overlayVC {
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
content.view.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
|
||||||
controlsVisible = visible
|
|
||||||
|
|
||||||
guard let topControlsView,
|
|
||||||
let bottomControlsView else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateControlsViews() {
|
|
||||||
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
|
|
||||||
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
|
|
||||||
content.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
|
|
||||||
}
|
|
||||||
if animated {
|
|
||||||
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
|
|
||||||
animator.addAnimations(updateControlsViews)
|
|
||||||
animator.startAnimation()
|
|
||||||
} else {
|
|
||||||
updateControlsViews()
|
|
||||||
}
|
|
||||||
|
|
||||||
setNeedsUpdateOfHomeIndicatorAutoHidden()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateZoomScale(resetZoom: Bool) {
|
|
||||||
scrollView.contentSize = content.contentSize
|
|
||||||
|
|
||||||
guard scrollAndZoomEnabled else {
|
|
||||||
scrollView.maximumZoomScale = 1
|
|
||||||
scrollView.minimumZoomScale = 1
|
|
||||||
scrollView.zoomScale = 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard content.contentSize.width > 0 && content.contentSize.height > 0 else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let heightScale = view.bounds.height / content.contentSize.height
|
|
||||||
let widthScale = view.bounds.width / content.contentSize.width
|
|
||||||
let minScale = min(widthScale, heightScale)
|
|
||||||
let maxScale = minScale >= 1 ? minScale + 2 : 2
|
|
||||||
|
|
||||||
scrollView.minimumZoomScale = minScale
|
|
||||||
scrollView.maximumZoomScale = maxScale
|
|
||||||
if resetZoom {
|
|
||||||
scrollView.zoomScale = minScale
|
|
||||||
} else {
|
|
||||||
scrollView.zoomScale = max(minScale, min(maxScale, scrollView.zoomScale))
|
|
||||||
}
|
|
||||||
|
|
||||||
centerContent()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func centerContent() {
|
|
||||||
guard scrollAndZoomEnabled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
|
|
||||||
// which means it's already been scaled by the zoom factor.
|
|
||||||
let yOffset = max(0, (view.bounds.height - content.view.frame.height) / 2)
|
|
||||||
contentViewTopConstraint!.constant = yOffset
|
|
||||||
|
|
||||||
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
|
|
||||||
contentViewLeadingConstraint!.constant = xOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateShareButton() {
|
|
||||||
shareButton.isEnabled = !content.activityItemsForSharing.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateCaptionTextView() {
|
|
||||||
guard let caption = content.caption,
|
|
||||||
!caption.isEmpty else {
|
|
||||||
captionTextView.isHidden = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
captionTextView.text = caption
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateTopControlsInsets() {
|
|
||||||
let notchedDeviceTopInsets: [CGFloat] = [
|
|
||||||
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
|
|
||||||
48, // iPhone XR, 11
|
|
||||||
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
|
|
||||||
50, // iPhone 12 mini, 13 mini
|
|
||||||
]
|
|
||||||
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
|
||||||
// the notch width is not the same for the iPhones 13,
|
|
||||||
// but what we actually want is the same offset from the edges
|
|
||||||
// since the corner radius didn't change
|
|
||||||
let notchWidth: CGFloat = 210
|
|
||||||
let earWidth = (view.bounds.width - notchWidth) / 2
|
|
||||||
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
|
|
||||||
shareButtonLeadingConstraint.constant = offset
|
|
||||||
closeButtonTrailingConstraint.constant = offset
|
|
||||||
} else if view.safeAreaInsets.top == 0 {
|
|
||||||
// square corner devices
|
|
||||||
shareButtonLeadingConstraint.constant = 8
|
|
||||||
shareButtonTopConstraint.constant = 8
|
|
||||||
closeButtonTrailingConstraint.constant = 8
|
|
||||||
closeButtonTopConstraint.constant = 8
|
|
||||||
} else {
|
|
||||||
// dynamic island devices
|
|
||||||
shareButtonLeadingConstraint.constant = 24
|
|
||||||
shareButtonTopConstraint.constant = 24
|
|
||||||
closeButtonTrailingConstraint.constant = 24
|
|
||||||
closeButtonTopConstraint.constant = 24
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func zoomRectFor(scale: CGFloat, center: CGPoint) -> CGRect {
|
|
||||||
var zoomRect = CGRect.zero
|
|
||||||
zoomRect.size.width = content.view.frame.width / scale
|
|
||||||
zoomRect.size.height = content.view.frame.height / scale
|
|
||||||
let newCenter = scrollView.convert(center, to: content.view)
|
|
||||||
zoomRect.origin.x = newCenter.x - (zoomRect.width / 2)
|
|
||||||
zoomRect.origin.y = newCenter.y - (zoomRect.height / 2)
|
|
||||||
return zoomRect
|
|
||||||
}
|
|
||||||
|
|
||||||
private func animateZoomOut() {
|
|
||||||
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
|
|
||||||
animator.addAnimations {
|
|
||||||
self.scrollView.zoomScale = self.scrollView.minimumZoomScale
|
|
||||||
self.scrollView.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
animator.startAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Interaction
|
|
||||||
|
|
||||||
@objc private func viewPressed() {
|
|
||||||
if scrollAndZoomEnabled,
|
|
||||||
scrollView.zoomScale > scrollView.minimumZoomScale {
|
|
||||||
animateZoomOut()
|
|
||||||
} else {
|
|
||||||
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func viewDoublePressed(_ recognizer: UITapGestureRecognizer) {
|
|
||||||
guard scrollAndZoomEnabled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
|
||||||
let point = recognizer.location(in: recognizer.view)
|
|
||||||
let scale = min(
|
|
||||||
max(
|
|
||||||
scrollView.bounds.width / content.contentSize.width,
|
|
||||||
scrollView.bounds.height / content.contentSize.height,
|
|
||||||
scrollView.zoomScale + 0.75
|
|
||||||
),
|
|
||||||
scrollView.maximumZoomScale
|
|
||||||
)
|
|
||||||
let rect = zoomRectFor(scale: scale, center: point)
|
|
||||||
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
|
|
||||||
animator.addAnimations {
|
|
||||||
self.scrollView.zoom(to: rect, animated: false)
|
|
||||||
self.view.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
animator.startAnimation()
|
|
||||||
} else {
|
|
||||||
animateZoomOut()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func closeButtonPressed() {
|
|
||||||
delegate?.galleryItemClose(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func shareButtonPressed() {
|
|
||||||
let items = content.activityItemsForSharing
|
|
||||||
guard !items.isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: delegate?.galleryItemApplicationActivities(self))
|
|
||||||
activityVC.popoverPresentationController?.sourceView = shareButton
|
|
||||||
present(activityVC, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GalleryItemViewController: GalleryContentViewControllerContainer {
|
|
||||||
var galleryControlsVisible: Bool {
|
|
||||||
controlsVisible
|
|
||||||
}
|
|
||||||
|
|
||||||
func setGalleryContentLoading(_ loading: Bool) {
|
|
||||||
if loading {
|
|
||||||
overlayVC?.view.isHidden = true
|
|
||||||
if activityIndicator == nil {
|
|
||||||
let activityIndicator = UIActivityIndicatorView(style: .large)
|
|
||||||
self.activityIndicator = activityIndicator
|
|
||||||
activityIndicator.startAnimating()
|
|
||||||
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(activityIndicator)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
||||||
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let activityIndicator {
|
|
||||||
// If we're in the middle of the presentation animation,
|
|
||||||
// wait until it finishes to hide the loading indicator.
|
|
||||||
// Since the updated content frame won't affect the animation,
|
|
||||||
// make sure the loading indicator remains visible.
|
|
||||||
if let delegate,
|
|
||||||
delegate.isGalleryBeingPresented() {
|
|
||||||
delegate.addPresentationAnimationCompletion { [unowned self] in
|
|
||||||
self.setGalleryContentLoading(false)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
activityIndicator.removeFromSuperview()
|
|
||||||
self.activityIndicator = nil
|
|
||||||
self.overlayVC?.view.isHidden = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func galleryContentChanged() {
|
|
||||||
updateZoomScale(resetZoom: true)
|
|
||||||
updateShareButton()
|
|
||||||
updateCaptionTextView()
|
|
||||||
}
|
|
||||||
|
|
||||||
func disableGalleryScrollAndZoom() {
|
|
||||||
scrollAndZoomEnabled = false
|
|
||||||
updateZoomScale(resetZoom: true)
|
|
||||||
scrollView.isScrollEnabled = false
|
|
||||||
// Make sure the content is re-added with the correct constraints
|
|
||||||
if content.parent == self {
|
|
||||||
addContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
|
|
||||||
setControlsVisible(visible, animated: animated, dueToUserInteraction: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GalleryItemViewController: UIScrollViewDelegate {
|
|
||||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
|
||||||
if scrollAndZoomEnabled {
|
|
||||||
return content.view
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
|
||||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
|
||||||
setControlsVisible(true, animated: true, dueToUserInteraction: true)
|
|
||||||
} else {
|
|
||||||
setControlsVisible(false, animated: true, dueToUserInteraction: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
centerContent()
|
|
||||||
scrollView.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GalleryItemViewController: UIGestureRecognizerDelegate {
|
|
||||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
||||||
if gestureRecognizer == singleTap {
|
|
||||||
let loc = gestureRecognizer.location(in: view)
|
|
||||||
return !topControlsView.frame.contains(loc) && !bottomControlsView.frame.contains(loc)
|
|
||||||
} else if gestureRecognizer == doubleTap {
|
|
||||||
let loc = gestureRecognizer.location(in: content.view)
|
|
||||||
return content.view.bounds.contains(loc)
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,136 +0,0 @@
|
||||||
//
|
|
||||||
// GalleryPresentationAnimationController.swift
|
|
||||||
// GalleryVC
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/28/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
|
||||||
private let sourceView: UIView
|
|
||||||
|
|
||||||
init(sourceView: UIView) {
|
|
||||||
self.sourceView = sourceView
|
|
||||||
}
|
|
||||||
|
|
||||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
|
||||||
return 0.4
|
|
||||||
}
|
|
||||||
|
|
||||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
|
||||||
guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
let itemViewController = to.currentItemViewController
|
|
||||||
|
|
||||||
if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions {
|
|
||||||
animateCrossFadeTransition(using: transitionContext)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let container = transitionContext.containerView
|
|
||||||
to.view.frame = container.bounds
|
|
||||||
container.addSubview(to.view)
|
|
||||||
|
|
||||||
container.layoutIfNeeded()
|
|
||||||
// Make sure the zoom scale is updated before getting the content view frame, since it needs to take into account the correct transform.
|
|
||||||
itemViewController.updateZoomScale(resetZoom: true)
|
|
||||||
|
|
||||||
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
|
|
||||||
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
|
|
||||||
|
|
||||||
// Use a transformation to make the actual source view appear to move into the destination frame.
|
|
||||||
// Doing this while having the content view fade-in papers over the z-index change when
|
|
||||||
// there was something overlapping the source view.
|
|
||||||
let origSourceTransform = sourceView.transform
|
|
||||||
let sourceToDestTransform: CGAffineTransform?
|
|
||||||
if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 {
|
|
||||||
// Scale evenly in both dimensions, to prevent the source view appearing to stretch/distort during the animation.
|
|
||||||
let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height)
|
|
||||||
sourceToDestTransform = origSourceTransform
|
|
||||||
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
|
|
||||||
.scaledBy(x: scale, y: scale)
|
|
||||||
} else {
|
|
||||||
sourceToDestTransform = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = itemViewController.takeContent()
|
|
||||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
|
||||||
container.insertSubview(content.view, belowSubview: to.view)
|
|
||||||
|
|
||||||
// Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
|
|
||||||
let dimmingView = UIView()
|
|
||||||
dimmingView.backgroundColor = .black
|
|
||||||
dimmingView.frame = container.bounds
|
|
||||||
dimmingView.layer.opacity = 0
|
|
||||||
container.insertSubview(dimmingView, belowSubview: content.view)
|
|
||||||
|
|
||||||
to.view.backgroundColor = nil
|
|
||||||
to.view.layer.opacity = 0
|
|
||||||
content.view.frame = sourceFrameInContainer
|
|
||||||
content.view.layer.opacity = 0
|
|
||||||
|
|
||||||
container.layoutIfNeeded()
|
|
||||||
|
|
||||||
// This needs to take place after the layout, so that the transform is correct.
|
|
||||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
|
||||||
|
|
||||||
let duration = self.transitionDuration(using: transitionContext)
|
|
||||||
// rougly equivalent to duration: 0.35, bounce: 0.3
|
|
||||||
let spring = UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
|
|
||||||
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
|
|
||||||
|
|
||||||
animator.addAnimations {
|
|
||||||
dimmingView.layer.opacity = 1
|
|
||||||
|
|
||||||
to.view.layer.opacity = 1
|
|
||||||
|
|
||||||
content.view.frame = destFrameInContainer
|
|
||||||
content.view.layer.opacity = 1
|
|
||||||
|
|
||||||
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
|
|
||||||
|
|
||||||
if let sourceToDestTransform {
|
|
||||||
self.sourceView.transform = sourceToDestTransform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
animator.addCompletion { _ in
|
|
||||||
dimmingView.removeFromSuperview()
|
|
||||||
|
|
||||||
to.view.backgroundColor = .black
|
|
||||||
|
|
||||||
if sourceToDestTransform != nil {
|
|
||||||
self.sourceView.transform = origSourceTransform
|
|
||||||
}
|
|
||||||
|
|
||||||
itemViewController.addContent()
|
|
||||||
|
|
||||||
transitionContext.completeTransition(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
animator.startAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
|
||||||
guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
to.view.alpha = 0
|
|
||||||
to.view.frame = transitionContext.containerView.bounds
|
|
||||||
transitionContext.containerView.addSubview(to.view)
|
|
||||||
|
|
||||||
let duration = transitionDuration(using: transitionContext)
|
|
||||||
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
|
|
||||||
animator.addAnimations {
|
|
||||||
to.view.alpha = 1
|
|
||||||
}
|
|
||||||
animator.addCompletion { _ in
|
|
||||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
|
||||||
}
|
|
||||||
animator.startAnimation()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,189 +0,0 @@
|
||||||
//
|
|
||||||
// GalleryViewController.swift
|
|
||||||
// GalleryVC
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/28/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class GalleryViewController: UIPageViewController {
|
|
||||||
|
|
||||||
let galleryDataSource: GalleryDataSource
|
|
||||||
let initialItemIndex: Int
|
|
||||||
private let _itemsCount: Int
|
|
||||||
private var itemsCount: Int {
|
|
||||||
get {
|
|
||||||
precondition(_itemsCount == galleryDataSource.galleryItemsCount(), "GalleryDataSource item count cannot change")
|
|
||||||
return _itemsCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentItemViewController: GalleryItemViewController {
|
|
||||||
viewControllers![0] as! GalleryItemViewController
|
|
||||||
}
|
|
||||||
|
|
||||||
private var dismissInteraction: GalleryDismissInteraction!
|
|
||||||
private var presentationAnimationCompletionHandlers: [() -> Void] = []
|
|
||||||
|
|
||||||
override public var prefersStatusBarHidden: Bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
override public var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
|
||||||
.none
|
|
||||||
}
|
|
||||||
override public var childForHomeIndicatorAutoHidden: UIViewController? {
|
|
||||||
currentItemViewController
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(dataSource: GalleryDataSource, initialItemIndex: Int) {
|
|
||||||
self.galleryDataSource = dataSource
|
|
||||||
self.initialItemIndex = initialItemIndex
|
|
||||||
self._itemsCount = dataSource.galleryItemsCount()
|
|
||||||
precondition(initialItemIndex >= 0 && initialItemIndex < _itemsCount, "initialItemIndex is out of bounds")
|
|
||||||
|
|
||||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [
|
|
||||||
.interPageSpacing: 50
|
|
||||||
])
|
|
||||||
|
|
||||||
modalPresentationStyle = .fullScreen
|
|
||||||
transitioningDelegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
public override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
dismissInteraction = GalleryDismissInteraction(viewController: self)
|
|
||||||
|
|
||||||
view.backgroundColor = .black
|
|
||||||
overrideUserInterfaceStyle = .dark
|
|
||||||
|
|
||||||
dataSource = self
|
|
||||||
delegate = self
|
|
||||||
|
|
||||||
setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
public override func viewDidAppear(_ animated: Bool) {
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
|
|
||||||
if animated {
|
|
||||||
// Wait until the transition is no longer in-progress, otherwise things will just get deferred again.
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.presentationAnimationCompleted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
|
||||||
super.viewWillDisappear(animated)
|
|
||||||
|
|
||||||
if isBeingDismissed {
|
|
||||||
currentItemViewController.content.galleryContentWillDisappear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeItemVC(index: Int) -> GalleryItemViewController {
|
|
||||||
let content = galleryDataSource.galleryContentViewController(forItemAt: index)
|
|
||||||
return GalleryItemViewController(delegate: self, itemIndex: index, content: content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func presentationAnimationCompleted() {
|
|
||||||
for block in presentationAnimationCompletionHandlers {
|
|
||||||
block()
|
|
||||||
}
|
|
||||||
currentItemViewController.content.galleryContentDidAppear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GalleryViewController: UIPageViewControllerDataSource {
|
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
|
||||||
guard let viewController = viewController as? GalleryItemViewController else {
|
|
||||||
preconditionFailure("VC must be GalleryItemViewController")
|
|
||||||
}
|
|
||||||
guard viewController.itemIndex > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return makeItemVC(index: viewController.itemIndex - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
|
||||||
guard let viewController = viewController as? GalleryItemViewController else {
|
|
||||||
preconditionFailure("VC must be GalleryItemViewController")
|
|
||||||
}
|
|
||||||
guard viewController.itemIndex < itemsCount - 1 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return makeItemVC(index: viewController.itemIndex + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GalleryViewController: UIPageViewControllerDelegate {
|
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
|
||||||
currentItemViewController.content.galleryContentWillDisappear()
|
|
||||||
let new = pendingViewControllers[0] as! GalleryItemViewController
|
|
||||||
new.setControlsVisible(currentItemViewController.controlsVisible, animated: false, dueToUserInteraction: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
|
||||||
currentItemViewController.content.galleryContentDidAppear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GalleryViewController: GalleryItemViewControllerDelegate {
|
|
||||||
func isGalleryBeingPresented() -> Bool {
|
|
||||||
isBeingPresented
|
|
||||||
}
|
|
||||||
|
|
||||||
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
|
|
||||||
presentationAnimationCompletionHandlers.append(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
func galleryItemClose(_ item: GalleryItemViewController) {
|
|
||||||
dismiss(animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]? {
|
|
||||||
galleryDataSource.galleryApplicationActivities(forItemAt: item.itemIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
|
||||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
||||||
#if os(visionOS)
|
|
||||||
return nil
|
|
||||||
#else
|
|
||||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
|
|
||||||
return GalleryPresentationAnimationController(sourceView: sourceView)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
||||||
#if os(visionOS)
|
|
||||||
return nil
|
|
||||||
#else
|
|
||||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
|
|
||||||
let translation: CGPoint?
|
|
||||||
let velocity: CGPoint?
|
|
||||||
if let dismissInteraction,
|
|
||||||
dismissInteraction.isActive {
|
|
||||||
translation = dismissInteraction.dismissTranslation
|
|
||||||
velocity = dismissInteraction.dismissVelocity
|
|
||||||
} else {
|
|
||||||
translation = nil
|
|
||||||
velocity = nil
|
|
||||||
}
|
|
||||||
return GalleryDismissAnimationController(sourceView: sourceView, interactiveTranslation: translation, interactiveVelocity: velocity)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
import XCTest
|
|
||||||
@testable import GalleryVC
|
|
||||||
|
|
||||||
final class GalleryVCTests: XCTestCase {
|
|
||||||
func testExample() throws {
|
|
||||||
// XCTest Documentation
|
|
||||||
// https://developer.apple.com/documentation/xctest
|
|
||||||
|
|
||||||
// Defining Test Cases and Test Methods
|
|
||||||
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "InstanceFeatures",
|
name: "InstanceFeatures",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -23,15 +23,9 @@ let package = Package(
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "InstanceFeatures",
|
name: "InstanceFeatures",
|
||||||
dependencies: ["Pachyderm"],
|
dependencies: ["Pachyderm"]),
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "InstanceFeaturesTests",
|
name: "InstanceFeaturesTests",
|
||||||
dependencies: ["InstanceFeatures"],
|
dependencies: ["InstanceFeatures"]),
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,8 +10,9 @@ import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
public final class InstanceFeatures: ObservableObject {
|
public class InstanceFeatures: ObservableObject {
|
||||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; (pleroma|akkoma) (.*)\\)", options: .caseInsensitive)
|
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
|
||||||
|
private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive)
|
||||||
|
|
||||||
private let _featuresUpdated = PassthroughSubject<Void, Never>()
|
private let _featuresUpdated = PassthroughSubject<Void, Never>()
|
||||||
public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
|
public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
|
||||||
|
@ -21,29 +22,16 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
@Published public private(set) var charsReservedPerURL = 23
|
@Published public private(set) var charsReservedPerURL = 23
|
||||||
@Published public private(set) var maxPollOptionChars: Int?
|
@Published public private(set) var maxPollOptionChars: Int?
|
||||||
@Published public private(set) var maxPollOptionsCount: Int?
|
@Published public private(set) var maxPollOptionsCount: Int?
|
||||||
@Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration?
|
|
||||||
@Published public private(set) var translation: Bool = false
|
|
||||||
|
|
||||||
public var localOnlyPosts: Bool {
|
public var localOnlyPosts: Bool {
|
||||||
switch instanceType {
|
switch instanceType {
|
||||||
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
|
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
|
||||||
return true
|
return true
|
||||||
case .pleroma(.akkoma(_)):
|
|
||||||
return true
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Instance types that use a separate visibility to indicate local-only posts.
|
|
||||||
public var localOnlyPostsVisibility: Bool {
|
|
||||||
if case .pleroma(.akkoma(_)) = instanceType {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var mastodonAttachmentRestrictions: Bool {
|
public var mastodonAttachmentRestrictions: Bool {
|
||||||
instanceType.isMastodon
|
instanceType.isMastodon
|
||||||
}
|
}
|
||||||
|
@ -84,7 +72,7 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
|
|
||||||
public var probablySupportsMarkdown: Bool {
|
public var probablySupportsMarkdown: Bool {
|
||||||
switch instanceType {
|
switch instanceType {
|
||||||
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .firefish(_):
|
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .calckey(_):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -108,13 +96,7 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public var canFollowHashtags: Bool {
|
public var canFollowHashtags: Bool {
|
||||||
if case .mastodon(_, let version) = instanceType {
|
hasMastodonVersion(4, 0, 0)
|
||||||
return version >= Version(4, 0, 0)
|
|
||||||
} else if case .pleroma(.akkoma(let version)) = instanceType {
|
|
||||||
return version >= Version(3, 4, 0)
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var filtersV2: Bool {
|
public var filtersV2: Bool {
|
||||||
|
@ -146,93 +128,14 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var statusEditNotifications: Bool {
|
|
||||||
// pleroma doesn't seem to support 'update' type notifications, even though it supports edits
|
|
||||||
hasMastodonVersion(3, 5, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var statusNotifications: Bool {
|
|
||||||
// pleroma doesn't support notifications for new posts from an account
|
|
||||||
hasMastodonVersion(3, 3, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var needsEditAttachmentsInSeparateRequest: Bool {
|
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||||
instanceType.isPleroma
|
instanceType.isPleroma(.akkoma(nil))
|
||||||
}
|
|
||||||
|
|
||||||
public var composeDirectStatuses: Bool {
|
|
||||||
if case .pixelfed = instanceType {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var searchOperators: Bool {
|
|
||||||
hasMastodonVersion(4, 2, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var hasServerPreferences: Bool {
|
|
||||||
hasMastodonVersion(2, 8, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var listRepliesPolicy: Bool {
|
|
||||||
hasMastodonVersion(3, 3, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var exclusiveLists: Bool {
|
|
||||||
hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
public var pushNotificationTypeStatus: Bool {
|
|
||||||
hasMastodonVersion(3, 3, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var pushNotificationTypeFollowRequest: Bool {
|
|
||||||
hasMastodonVersion(3, 1, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var pushNotificationTypeUpdate: Bool {
|
|
||||||
hasMastodonVersion(3, 5, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var pushNotificationPolicy: Bool {
|
|
||||||
hasMastodonVersion(3, 5, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var pushNotificationPolicyMissingFromResponse: Bool {
|
|
||||||
switch instanceType {
|
|
||||||
case .mastodon(_, let version):
|
|
||||||
return version >= Version(3, 5, 0) && version < Version(4, 1, 0)
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var instanceAnnouncements: Bool {
|
|
||||||
hasMastodonVersion(3, 1, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var emojiReactionNotifications: Bool {
|
|
||||||
instanceType.isPleroma
|
|
||||||
}
|
|
||||||
|
|
||||||
public var muteNotifications: Bool {
|
|
||||||
!instanceType.isPixelfed
|
|
||||||
}
|
|
||||||
|
|
||||||
public var blockDomains: Bool {
|
|
||||||
!instanceType.isPixelfed
|
|
||||||
}
|
|
||||||
|
|
||||||
public var hideReblogs: Bool {
|
|
||||||
!instanceType.isPixelfed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update(instance: InstanceInfo, nodeInfo: NodeInfo?) {
|
public func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
let ver = instance.version.lowercased()
|
let ver = instance.version.lowercased()
|
||||||
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
|
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
|
||||||
if ver.contains("glitch") {
|
if ver.contains("glitch") {
|
||||||
|
@ -260,21 +163,24 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
mastoVersion = Version(string: ver)
|
mastoVersion = Version(string: ver)
|
||||||
}
|
}
|
||||||
instanceType = .mastodon(.hometown(hometownVersion), mastoVersion)
|
instanceType = .mastodon(.hometown(hometownVersion), mastoVersion)
|
||||||
} else if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
|
} else if ver.contains("pleroma") {
|
||||||
var pleromaVersion: Version?
|
var pleromaVersion: Version?
|
||||||
let type = (ver as NSString).substring(with: match.range(at: 1))
|
if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
|
||||||
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 2)))
|
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
|
||||||
if type == "akkoma" {
|
|
||||||
instanceType = .pleroma(.akkoma(pleromaVersion))
|
|
||||||
} else {
|
|
||||||
instanceType = .pleroma(.vanilla(pleromaVersion))
|
|
||||||
}
|
}
|
||||||
|
instanceType = .pleroma(.vanilla(pleromaVersion))
|
||||||
|
} else if ver.contains("akkoma") {
|
||||||
|
var akkomaVersion: Version?
|
||||||
|
if let match = InstanceFeatures.akkomaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
|
||||||
|
akkomaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
|
||||||
|
}
|
||||||
|
instanceType = .pleroma(.akkoma(akkomaVersion))
|
||||||
} else if ver.contains("pixelfed") {
|
} else if ver.contains("pixelfed") {
|
||||||
instanceType = .pixelfed
|
instanceType = .pixelfed
|
||||||
} else if nodeInfo?.software.name == "gotosocial" {
|
} else if nodeInfo?.software.name == "gotosocial" {
|
||||||
instanceType = .gotosocial
|
instanceType = .gotosocial
|
||||||
} else if ver.contains("firefish") || ver.contains("iceshrimp") || ver.contains("calckey") {
|
} else if ver.contains("calckey") {
|
||||||
instanceType = .firefish(nodeInfo?.software.version)
|
instanceType = .calckey(nodeInfo?.software.version)
|
||||||
} else {
|
} else {
|
||||||
instanceType = .mastodon(.vanilla, Version(string: ver))
|
instanceType = .mastodon(.vanilla, Version(string: ver))
|
||||||
}
|
}
|
||||||
|
@ -285,8 +191,6 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
maxPollOptionChars = pollsConfig.maxCharactersPerOption
|
maxPollOptionChars = pollsConfig.maxCharactersPerOption
|
||||||
maxPollOptionsCount = pollsConfig.maxOptions
|
maxPollOptionsCount = pollsConfig.maxOptions
|
||||||
}
|
}
|
||||||
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
|
|
||||||
translation = instance.translation
|
|
||||||
|
|
||||||
_featuresUpdated.send()
|
_featuresUpdated.send()
|
||||||
}
|
}
|
||||||
|
@ -315,7 +219,7 @@ extension InstanceFeatures {
|
||||||
case pleroma(PleromaType)
|
case pleroma(PleromaType)
|
||||||
case pixelfed
|
case pixelfed
|
||||||
case gotosocial
|
case gotosocial
|
||||||
case firefish(String?)
|
case calckey(String?)
|
||||||
|
|
||||||
var isMastodon: Bool {
|
var isMastodon: Bool {
|
||||||
if case .mastodon(_, _) = self {
|
if case .mastodon(_, _) = self {
|
||||||
|
@ -350,14 +254,6 @@ extension InstanceFeatures {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPixelfed: Bool {
|
|
||||||
if case .pixelfed = self {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@_spi(InstanceType) public enum MastodonType {
|
@_spi(InstanceType) public enum MastodonType {
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
//
|
|
||||||
// InstanceInfo.swift
|
|
||||||
// InstanceFeatures
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 5/28/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
public struct InstanceInfo {
|
|
||||||
public var version: String
|
|
||||||
public var maxStatusCharacters: Int?
|
|
||||||
public var configuration: InstanceV1.Configuration?
|
|
||||||
public var pollsConfiguration: InstanceV1.PollsConfiguration?
|
|
||||||
public var translation: Bool
|
|
||||||
|
|
||||||
public init(
|
|
||||||
version: String,
|
|
||||||
maxStatusCharacters: Int?,
|
|
||||||
configuration: InstanceV1.Configuration?,
|
|
||||||
pollsConfiguration: InstanceV1.PollsConfiguration?,
|
|
||||||
translation: Bool
|
|
||||||
) {
|
|
||||||
self.version = version
|
|
||||||
self.maxStatusCharacters = maxStatusCharacters
|
|
||||||
self.configuration = configuration
|
|
||||||
self.pollsConfiguration = pollsConfiguration
|
|
||||||
self.translation = translation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstanceInfo {
|
|
||||||
public init(v1 instance: InstanceV1) {
|
|
||||||
self.init(
|
|
||||||
version: instance.version,
|
|
||||||
maxStatusCharacters: instance.maxStatusCharacters,
|
|
||||||
configuration: instance.configuration,
|
|
||||||
pollsConfiguration: instance.pollsConfiguration,
|
|
||||||
translation: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public mutating func update(v2: InstanceV2) {
|
|
||||||
translation = v2.configuration.translation.enabled
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "MatchedGeometryPresentation",
|
name: "MatchedGeometryPresentation",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
@ -18,10 +18,7 @@ let package = Package(
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
.target(
|
.target(
|
||||||
name: "MatchedGeometryPresentation",
|
name: "MatchedGeometryPresentation"),
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
|
||||||
// .testTarget(
|
// .testTarget(
|
||||||
// name: "MatchedGeometryPresentationTests",
|
// name: "MatchedGeometryPresentationTests",
|
||||||
// dependencies: ["MatchedGeometryPresentation"]),
|
// dependencies: ["MatchedGeometryPresentation"]),
|
||||||
|
|
|
@ -225,7 +225,6 @@ class MatchedGeometryDismissAnimationController<Content: View>: NSObject, UIView
|
||||||
animator.addCompletion { _ in
|
animator.addCompletion { _ in
|
||||||
transitionContext.completeTransition(true)
|
transitionContext.completeTransition(true)
|
||||||
matchedGeomVC.state.animating = false
|
matchedGeomVC.state.animating = false
|
||||||
matchedGeomVC.state.mode = .idle
|
|
||||||
}
|
}
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1500"
|
LastUpgradeVersion = "1400"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|
|
@ -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.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Pachyderm",
|
name: "Pachyderm",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v14),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -16,7 +16,7 @@ let package = Package(
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
// Dependencies declare other packages that this package depends on.
|
// Dependencies declare other packages that this package depends on.
|
||||||
.package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"),
|
.package(url: "https://github.com/karwa/swift-url.git", branch: "main"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
|
@ -26,15 +26,9 @@ let package = Package(
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "WebURL", package: "swift-url"),
|
.product(name: "WebURL", package: "swift-url"),
|
||||||
.product(name: "WebURLFoundationExtras", package: "swift-url"),
|
.product(name: "WebURLFoundationExtras", package: "swift-url"),
|
||||||
],
|
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "PachydermTests",
|
name: "PachydermTests",
|
||||||
dependencies: ["Pachyderm"],
|
dependencies: ["Pachyderm"]),
|
||||||
swiftSettings: [
|
|
||||||
.swiftLanguageMode(.v5)
|
|
||||||
]),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -12,7 +12,7 @@ import WebURL
|
||||||
/**
|
/**
|
||||||
The base Mastodon API client.
|
The base Mastodon API client.
|
||||||
*/
|
*/
|
||||||
public struct Client: Sendable {
|
public class Client {
|
||||||
|
|
||||||
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
|
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
|
||||||
|
|
||||||
|
@ -20,6 +20,8 @@ public struct Client: Sendable {
|
||||||
let session: URLSession
|
let session: URLSession
|
||||||
|
|
||||||
public var accessToken: String?
|
public var accessToken: String?
|
||||||
|
|
||||||
|
public var appID: String?
|
||||||
public var clientID: String?
|
public var clientID: String?
|
||||||
public var clientSecret: String?
|
public var clientSecret: String?
|
||||||
|
|
||||||
|
@ -42,7 +44,7 @@ public struct Client: Sendable {
|
||||||
} else if let date = iso8601.date(from: str) {
|
} else if let date = iso8601.date(from: str) {
|
||||||
return date
|
return date
|
||||||
} else {
|
} else {
|
||||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -59,11 +61,9 @@ public struct Client: Sendable {
|
||||||
return encoder
|
return encoder
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public init(baseURL: URL, accessToken: String? = nil, clientID: String? = nil, clientSecret: String? = nil, session: URLSession = .shared) {
|
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.accessToken = accessToken
|
self.accessToken = accessToken
|
||||||
self.clientID = clientID
|
|
||||||
self.clientSecret = clientSecret
|
|
||||||
self.session = session
|
self.session = session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,20 +105,6 @@ public struct Client: Sendable {
|
||||||
return task
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
|
||||||
run(request) { response in
|
|
||||||
switch response {
|
|
||||||
case .failure(let error):
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
case .success(let result, let pagination):
|
|
||||||
continuation.resume(returning: (result, pagination))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||||
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
||||||
components.path = request.endpoint.path
|
components.path = request.endpoint.path
|
||||||
|
@ -127,17 +113,11 @@ public struct Client: Sendable {
|
||||||
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
||||||
urlRequest.httpMethod = request.method.name
|
urlRequest.httpMethod = request.method.name
|
||||||
urlRequest.httpBody = request.body.data
|
urlRequest.httpBody = request.body.data
|
||||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
||||||
for (name, value) in request.headers {
|
|
||||||
urlRequest.setValue(value, forHTTPHeaderField: name)
|
|
||||||
}
|
|
||||||
if let mimeType = request.body.mimeType {
|
if let mimeType = request.body.mimeType {
|
||||||
urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||||
}
|
}
|
||||||
if let accessToken = accessToken {
|
if let accessToken = accessToken {
|
||||||
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
// We consider authenticated requests to be user-initiated.
|
|
||||||
urlRequest.attribution = .user
|
|
||||||
}
|
}
|
||||||
return urlRequest
|
return urlRequest
|
||||||
}
|
}
|
||||||
|
@ -150,7 +130,14 @@ public struct Client: Sendable {
|
||||||
"scopes" => scopes.scopeString,
|
"scopes" => scopes.scopeString,
|
||||||
"website" => website?.absoluteString
|
"website" => website?.absoluteString
|
||||||
]))
|
]))
|
||||||
run(request, completion: completion)
|
run(request) { result in
|
||||||
|
defer { completion(result) }
|
||||||
|
guard case let .success(application, _) = result else { return }
|
||||||
|
|
||||||
|
self.appID = application.id
|
||||||
|
self.clientID = application.clientID
|
||||||
|
self.clientSecret = application.clientSecret
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
|
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
|
||||||
|
@ -162,7 +149,12 @@ public struct Client: Sendable {
|
||||||
"redirect_uri" => redirectURI,
|
"redirect_uri" => redirectURI,
|
||||||
"scope" => scopes.scopeString,
|
"scope" => scopes.scopeString,
|
||||||
]))
|
]))
|
||||||
run(request, completion: completion)
|
run(request) { result in
|
||||||
|
defer { completion(result) }
|
||||||
|
guard case let .success(loginSettings, _) = result else { return }
|
||||||
|
|
||||||
|
self.accessToken = loginSettings.accessToken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func revokeAccessToken() async throws {
|
public func revokeAccessToken() async throws {
|
||||||
|
@ -186,16 +178,21 @@ public struct Client: Sendable {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public func nodeInfo() async throws -> NodeInfo {
|
public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
|
||||||
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
||||||
let wellKnownResults = try await run(wellKnown).0
|
run(wellKnown) { result in
|
||||||
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
switch result {
|
||||||
|
case let .failure(error):
|
||||||
|
completion(.failure(error))
|
||||||
|
|
||||||
|
case let .success(wellKnown, _):
|
||||||
|
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||||
let href = WebURL(url.href),
|
let href = WebURL(url.href),
|
||||||
href.host == WebURL(self.baseURL)?.host {
|
href.host == WebURL(self.baseURL)?.host {
|
||||||
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
||||||
return try await run(nodeInfo).0
|
self.run(nodeInfo, completion: completion)
|
||||||
} else {
|
}
|
||||||
throw NodeInfoError.noWellKnownLink
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,8 +201,8 @@ public struct Client: Sendable {
|
||||||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
|
||||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
|
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
@ -214,22 +211,14 @@ public struct Client: Sendable {
|
||||||
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getInstanceV1() -> Request<InstanceV1> {
|
public static func getInstance() -> Request<Instance> {
|
||||||
return Request<InstanceV1>(method: .get, path: "/api/v1/instance")
|
return Request<Instance>(method: .get, path: "/api/v1/instance")
|
||||||
}
|
|
||||||
|
|
||||||
public static func getInstanceV2() -> Request<InstanceV2> {
|
|
||||||
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getCustomEmoji() -> Request<[Emoji]> {
|
public static func getCustomEmoji() -> Request<[Emoji]> {
|
||||||
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getPreferences() -> Request<Preferences> {
|
|
||||||
return Request(method: .get, path: "/api/v1/preferences")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Accounts
|
// MARK: - Accounts
|
||||||
public static func getAccount(id: String) -> Request<Account> {
|
public static func getAccount(id: String) -> Request<Account> {
|
||||||
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
||||||
|
@ -341,10 +330,6 @@ public struct Client: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
public static func getNotification(id: String) -> Request<Notification> {
|
|
||||||
return Request(method: .get, path: "/api/v1/notifications/\(id)")
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||||
"types" => allowedTypes.map { $0.rawValue }
|
"types" => allowedTypes.map { $0.rawValue }
|
||||||
|
@ -407,27 +392,24 @@ public struct Client: Sendable {
|
||||||
mediaIDs: [String]? = nil,
|
mediaIDs: [String]? = nil,
|
||||||
sensitive: Bool? = nil,
|
sensitive: Bool? = nil,
|
||||||
spoilerText: String? = nil,
|
spoilerText: String? = nil,
|
||||||
visibility: String? = nil,
|
visibility: Visibility? = nil,
|
||||||
language: String? = nil, // language supported by mastodon and akkoma
|
language: String? = nil, // language supported by mastodon and akkoma
|
||||||
pollOptions: [String]? = nil,
|
pollOptions: [String]? = nil,
|
||||||
pollExpiresIn: Int? = nil,
|
pollExpiresIn: Int? = nil,
|
||||||
pollMultiple: Bool? = nil,
|
pollMultiple: Bool? = nil,
|
||||||
localOnly: Bool? = nil, /* hometown only, not glitch */
|
localOnly: Bool? = nil /* hometown only, not glitch */) -> Request<Status> {
|
||||||
idempotencyKey: String) -> Request<Status> {
|
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
||||||
var req = Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
|
||||||
"status" => text,
|
"status" => text,
|
||||||
"content_type" => contentType.mimeType,
|
"content_type" => contentType.mimeType,
|
||||||
"in_reply_to_id" => inReplyTo,
|
"in_reply_to_id" => inReplyTo,
|
||||||
"sensitive" => sensitive,
|
"sensitive" => sensitive,
|
||||||
"spoiler_text" => spoilerText,
|
"spoiler_text" => spoilerText,
|
||||||
"visibility" => visibility,
|
"visibility" => visibility?.rawValue,
|
||||||
"language" => language,
|
"language" => language,
|
||||||
"poll[expires_in]" => pollExpiresIn,
|
"poll[expires_in]" => pollExpiresIn,
|
||||||
"poll[multiple]" => pollMultiple,
|
"poll[multiple]" => pollMultiple,
|
||||||
"local_only" => localOnly,
|
"local_only" => localOnly,
|
||||||
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
|
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
|
||||||
req.headers["Idempotency-Key"] = idempotencyKey
|
|
||||||
return req
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func editStatus(
|
public static func editStatus(
|
||||||
|
@ -456,13 +438,14 @@ public struct Client: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timelines
|
// 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)
|
return timeline.request(range: range)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Bookmarks
|
// MARK: - Bookmarks
|
||||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
|
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
@ -490,7 +473,7 @@ public struct Client: Sendable {
|
||||||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
|
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
|
||||||
var parameters: [Parameter] = []
|
var parameters: [Parameter] = []
|
||||||
if let limit {
|
if let limit {
|
||||||
parameters.append("limit" => limit)
|
parameters.append("limit" => limit)
|
||||||
|
@ -586,15 +569,4 @@ extension Client {
|
||||||
case invalidModel(Swift.Error)
|
case invalidModel(Swift.Error)
|
||||||
case mastodonError(Int, String)
|
case mastodonError(Int, String)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NodeInfoError: LocalizedError {
|
|
||||||
case noWellKnownLink
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .noWellKnownLink:
|
|
||||||
return "No well-known link"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,9 +40,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
||||||
self.displayName = try container.decode(String.self, forKey: .displayName)
|
self.displayName = try container.decode(String.self, forKey: .displayName)
|
||||||
self.locked = try container.decode(Bool.self, forKey: .locked)
|
self.locked = try container.decode(Bool.self, forKey: .locked)
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
|
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
|
||||||
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
|
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
|
||||||
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
|
||||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||||
self.note = try container.decode(String.self, forKey: .note)
|
self.note = try container.decode(String.self, forKey: .note)
|
||||||
self.url = try container.decode(URL.self, forKey: .url)
|
self.url = try container.decode(URL.self, forKey: .url)
|
||||||
|
@ -95,8 +94,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
|
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<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||||
"only_media" => onlyMedia,
|
"only_media" => onlyMedia,
|
||||||
"pinned" => pinned,
|
"pinned" => pinned,
|
||||||
"exclude_replies" => excludeReplies,
|
"exclude_replies" => excludeReplies,
|
||||||
|
|
|
@ -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))
|
], nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(id: String, kind: Attachment.Kind, url: URL, remoteURL: URL? = nil, previewURL: URL? = nil, meta: Attachment.Metadata? = nil, description: String? = nil, blurHash: String? = nil) {
|
|
||||||
self.id = id
|
|
||||||
self.kind = kind
|
|
||||||
self.url = url
|
|
||||||
self.remoteURL = remoteURL
|
|
||||||
self.previewURL = previewURL
|
|
||||||
self.meta = meta
|
|
||||||
self.description = description
|
|
||||||
self.blurHash = blurHash
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
|
|
|
@ -26,38 +26,6 @@ public struct Card: Codable, Sendable {
|
||||||
/// Only present when returned from the trending links endpoint
|
/// Only present when returned from the trending links endpoint
|
||||||
public let history: [History]?
|
public let history: [History]?
|
||||||
|
|
||||||
public init(
|
|
||||||
url: WebURL,
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
image: WebURL? = nil,
|
|
||||||
kind: Card.Kind,
|
|
||||||
authorName: String? = nil,
|
|
||||||
authorURL: WebURL? = nil,
|
|
||||||
providerName: String? = nil,
|
|
||||||
providerURL: WebURL? = nil,
|
|
||||||
html: String? = nil,
|
|
||||||
width: Int? = nil,
|
|
||||||
height: Int? = nil,
|
|
||||||
blurhash: String? = nil,
|
|
||||||
history: [History]? = nil
|
|
||||||
) {
|
|
||||||
self.url = url
|
|
||||||
self.title = title
|
|
||||||
self.description = description
|
|
||||||
self.image = image
|
|
||||||
self.kind = kind
|
|
||||||
self.authorName = authorName
|
|
||||||
self.authorURL = authorURL
|
|
||||||
self.providerName = providerName
|
|
||||||
self.providerURL = providerURL
|
|
||||||
self.html = html
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.blurhash = blurhash
|
|
||||||
self.history = history
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
|
|
@ -43,13 +43,8 @@ extension Emoji: CustomDebugStringConvertible {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Emoji: Equatable, Hashable {
|
extension Emoji: Equatable {
|
||||||
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
|
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
|
||||||
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
|
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(shortcode)
|
|
||||||
hasher.combine(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// InstanceV1.swift
|
// Instance.swift
|
||||||
// Pachyderm
|
// Pachyderm
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/9/18.
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct InstanceV1: Decodable, Sendable {
|
public struct Instance: Decodable, Sendable {
|
||||||
public let uri: String
|
public let uri: String
|
||||||
public let title: String
|
public let title: String
|
||||||
public let description: String
|
public let description: String
|
||||||
|
@ -92,7 +92,7 @@ public struct InstanceV1: Decodable, Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceV1 {
|
extension Instance {
|
||||||
public struct Stats: Decodable, Sendable {
|
public struct Stats: Decodable, Sendable {
|
||||||
public let domainCount: Int?
|
public let domainCount: Int?
|
||||||
public let statusCount: Int?
|
public let statusCount: Int?
|
||||||
|
@ -106,8 +106,8 @@ extension InstanceV1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceV1 {
|
extension Instance {
|
||||||
public struct Configuration: Codable, Sendable {
|
public struct Configuration: Decodable, Sendable {
|
||||||
public let statuses: StatusesConfiguration
|
public let statuses: StatusesConfiguration
|
||||||
public let mediaAttachments: MediaAttachmentsConfiguration
|
public let mediaAttachments: MediaAttachmentsConfiguration
|
||||||
/// Use Instance.pollsConfiguration to support older instance that don't have this nested
|
/// Use Instance.pollsConfiguration to support older instance that don't have this nested
|
||||||
|
@ -121,9 +121,8 @@ extension InstanceV1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceV1 {
|
extension Instance {
|
||||||
// note: also used by InstanceV2
|
public struct StatusesConfiguration: Decodable, Sendable {
|
||||||
public struct StatusesConfiguration: Codable, Sendable {
|
|
||||||
public let maxCharacters: Int
|
public let maxCharacters: Int
|
||||||
public let maxMediaAttachments: Int
|
public let maxMediaAttachments: Int
|
||||||
public let charactersReservedPerURL: Int
|
public let charactersReservedPerURL: Int
|
||||||
|
@ -136,9 +135,8 @@ extension InstanceV1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceV1 {
|
extension Instance {
|
||||||
// note: also used by InstanceV2
|
public struct MediaAttachmentsConfiguration: Decodable, Sendable {
|
||||||
public struct MediaAttachmentsConfiguration: Codable, Sendable {
|
|
||||||
public let supportedMIMETypes: [String]
|
public let supportedMIMETypes: [String]
|
||||||
public let imageSizeLimit: Int
|
public let imageSizeLimit: Int
|
||||||
public let imageMatrixLimit: Int
|
public let imageMatrixLimit: Int
|
||||||
|
@ -157,9 +155,8 @@ extension InstanceV1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceV1 {
|
extension Instance {
|
||||||
// note: also used by InstanceV2
|
public struct PollsConfiguration: Decodable, Sendable {
|
||||||
public struct PollsConfiguration: Codable, Sendable {
|
|
||||||
public let maxOptions: Int
|
public let maxOptions: Int
|
||||||
public let maxCharactersPerOption: Int
|
public let maxCharactersPerOption: Int
|
||||||
public let minExpiration: TimeInterval
|
public let minExpiration: TimeInterval
|
||||||
|
@ -174,8 +171,7 @@ extension InstanceV1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceV1 {
|
extension Instance {
|
||||||
// note: also used by InstanceV2
|
|
||||||
public struct Rule: Decodable, Identifiable, Sendable {
|
public struct Rule: Decodable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let text: String
|
public let text: String
|
|
@ -1,125 +0,0 @@
|
||||||
//
|
|
||||||
// InstanceV2.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/4/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public struct InstanceV2: Decodable, Sendable {
|
|
||||||
public let domain: String
|
|
||||||
public let title: String
|
|
||||||
public let version: String
|
|
||||||
public let sourceURL: String
|
|
||||||
public let description: String
|
|
||||||
public let usage: Usage
|
|
||||||
public let thumbnail: Thumbnail
|
|
||||||
public let languages: [String]
|
|
||||||
public let configuration: Configuration
|
|
||||||
public let registrations: Registrations
|
|
||||||
public let contact: Contact
|
|
||||||
public let rules: [InstanceV1.Rule]
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case domain
|
|
||||||
case title
|
|
||||||
case version
|
|
||||||
case sourceURL = "source_url"
|
|
||||||
case description
|
|
||||||
case usage
|
|
||||||
case thumbnail
|
|
||||||
case languages
|
|
||||||
case configuration
|
|
||||||
case registrations
|
|
||||||
case contact
|
|
||||||
case rules
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstanceV2 {
|
|
||||||
public struct Usage: Decodable, Sendable {
|
|
||||||
public let users: Users
|
|
||||||
}
|
|
||||||
public struct Users: Decodable, Sendable {
|
|
||||||
public let activeMonth: Int
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case activeMonth = "active_month"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstanceV2 {
|
|
||||||
public struct Thumbnail: Decodable, Sendable {
|
|
||||||
public let url: String
|
|
||||||
public let blurhash: String?
|
|
||||||
public let versions: ThumbnailVersions?
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ThumbnailVersions: Decodable, Sendable {
|
|
||||||
public let oneX: String?
|
|
||||||
public let twoX: String?
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case oneX = "@1x"
|
|
||||||
case twoX = "@2x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstanceV2 {
|
|
||||||
public struct Configuration: Decodable, Sendable {
|
|
||||||
public let urls: URLs
|
|
||||||
public let accounts: Accounts
|
|
||||||
public let statuses: InstanceV1.StatusesConfiguration
|
|
||||||
public let mediaAttachments: InstanceV1.MediaAttachmentsConfiguration
|
|
||||||
public let polls: InstanceV1.PollsConfiguration
|
|
||||||
public let translation: Translation
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case urls
|
|
||||||
case accounts
|
|
||||||
case statuses
|
|
||||||
case mediaAttachments = "media_attachments"
|
|
||||||
case polls
|
|
||||||
case translation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct URLs: Decodable, Sendable {
|
|
||||||
// the docs incorrectly say the key for this is "streaming_api"
|
|
||||||
public let streaming: String
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Accounts: Decodable, Sendable {
|
|
||||||
public let maxFeaturedTags: Int
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case maxFeaturedTags = "max_featured_tags"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Translation: Decodable, Sendable {
|
|
||||||
public let enabled: Bool
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstanceV2 {
|
|
||||||
public struct Registrations: Decodable, Sendable {
|
|
||||||
public let enabled: Bool
|
|
||||||
public let approvalRequired: Bool
|
|
||||||
public let message: String?
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case enabled
|
|
||||||
case approvalRequired = "approval_required"
|
|
||||||
case message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstanceV2 {
|
|
||||||
public struct Contact: Decodable, Sendable {
|
|
||||||
public let email: String
|
|
||||||
public let account: Account?
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -11,18 +11,14 @@ import Foundation
|
||||||
public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
|
public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let title: String
|
public let title: String
|
||||||
public let replyPolicy: ReplyPolicy?
|
|
||||||
public let exclusive: Bool?
|
|
||||||
|
|
||||||
public var timeline: Timeline {
|
public var timeline: Timeline {
|
||||||
return .list(id: id)
|
return .list(id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(id: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) {
|
public init(id: String, title: String) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.replyPolicy = replyPolicy
|
|
||||||
self.exclusive = exclusive
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||||
|
@ -40,15 +36,8 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ listID: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) -> Request<List> {
|
public static func update(_ listID: String, title: String) -> Request<List> {
|
||||||
var params = ["title" => title]
|
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title]))
|
||||||
if let replyPolicy {
|
|
||||||
params.append("replies_policy" => replyPolicy.rawValue)
|
|
||||||
}
|
|
||||||
if let exclusive {
|
|
||||||
params.append("exclusive" => exclusive)
|
|
||||||
}
|
|
||||||
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(params))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ listID: String) -> Request<Empty> {
|
public static func delete(_ listID: String) -> Request<Empty> {
|
||||||
|
@ -70,13 +59,5 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case title
|
case title
|
||||||
case replyPolicy = "replies_policy"
|
|
||||||
case exclusive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension List {
|
|
||||||
public enum ReplyPolicy: String, Codable, Hashable, CaseIterable, Sendable {
|
|
||||||
case followed, list, none
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,20 +11,7 @@ import Foundation
|
||||||
struct MastodonError: Decodable, CustomStringConvertible {
|
struct MastodonError: Decodable, CustomStringConvertible {
|
||||||
var description: String
|
var description: String
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
if let error = try container.decodeIfPresent(String.self, forKey: .error) {
|
|
||||||
self.description = error
|
|
||||||
} else if let message = try container.decodeIfPresent(String.self, forKey: .message) {
|
|
||||||
self.description = message
|
|
||||||
} else {
|
|
||||||
throw DecodingError.keyNotFound(CodingKeys.error, .init(codingPath: container.codingPath, debugDescription: "Missing error or message key"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case error
|
case description = "error"
|
||||||
// used by pixelfed
|
|
||||||
case message
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,12 +21,7 @@ public struct Mention: Codable, Sendable {
|
||||||
self.username = try container.decode(String.self, forKey: .username)
|
self.username = try container.decode(String.self, forKey: .username)
|
||||||
self.acct = try container.decode(String.self, forKey: .acct)
|
self.acct = try container.decode(String.self, forKey: .acct)
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
do {
|
|
||||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
} catch {
|
|
||||||
let s = try? container.decode(String.self, forKey: .url)
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .url, in: container, debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(url: WebURL, username: String, acct: String, id: String) {
|
public init(url: WebURL, username: String, acct: String, id: String) {
|
||||||
|
|
|
@ -8,11 +8,11 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct NodeInfo: Decodable, Sendable, Equatable {
|
public struct NodeInfo: Decodable, Sendable {
|
||||||
public let version: String
|
public let version: String
|
||||||
public let software: Software
|
public let software: Software
|
||||||
|
|
||||||
public struct Software: Decodable, Sendable, Equatable {
|
public struct Software: Decodable, Sendable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let version: String
|
public let version: String
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import WebURL
|
|
||||||
|
|
||||||
public struct Notification: Decodable, Sendable {
|
public struct Notification: Decodable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
|
@ -15,10 +14,6 @@ public struct Notification: Decodable, Sendable {
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public let account: Account
|
public let account: Account
|
||||||
public let status: Status?
|
public let status: Status?
|
||||||
// Only present for pleroma emoji reactions
|
|
||||||
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
|
|
||||||
public let emoji: String?
|
|
||||||
public let emojiURL: WebURL?
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
@ -32,8 +27,6 @@ public struct Notification: Decodable, Sendable {
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
self.account = try container.decode(Account.self, forKey: .account)
|
self.account = try container.decode(Account.self, forKey: .account)
|
||||||
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||||
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
|
|
||||||
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||||
|
@ -46,8 +39,6 @@ public struct Notification: Decodable, Sendable {
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case account
|
case account
|
||||||
case status
|
case status
|
||||||
case emoji
|
|
||||||
case emojiURL = "emoji_url"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,7 +52,6 @@ extension Notification {
|
||||||
case poll
|
case poll
|
||||||
case update
|
case update
|
||||||
case status
|
case status
|
||||||
case emojiReaction = "pleroma:emoji_reaction"
|
|
||||||
case unknown
|
case unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ public struct Poll: Codable, Sendable {
|
||||||
public let votesCount: Int
|
public let votesCount: Int
|
||||||
public let votersCount: Int?
|
public let votersCount: Int?
|
||||||
public let voted: Bool?
|
public let voted: Bool?
|
||||||
public let ownVotes: [Int?]?
|
public let ownVotes: [Int]?
|
||||||
public let options: [Option]
|
public let options: [Option]
|
||||||
public let emojis: [Emoji]
|
public let emojis: [Emoji]
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ public struct Poll: Codable, Sendable {
|
||||||
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
|
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
|
||||||
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
|
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
|
||||||
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
|
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
|
||||||
self.ownVotes = try container.decodeIfPresent([Int?].self, forKey: .ownVotes)
|
self.ownVotes = try container.decodeIfPresent([Int].self, forKey: .ownVotes)
|
||||||
self.options = try container.decode([Poll.Option].self, forKey: .options)
|
self.options = try container.decode([Poll.Option].self, forKey: .options)
|
||||||
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
//
|
|
||||||
// Preferences.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 10/26/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public struct Preferences: Codable, Sendable {
|
|
||||||
public let postingDefaultVisibility: Visibility
|
|
||||||
public let postingDefaultSensitive: Bool
|
|
||||||
public let postingDefaultLanguage: String
|
|
||||||
// Whether posts federate or not (local-only) on Hometown
|
|
||||||
public let postingDefaultFederation: Bool?
|
|
||||||
public let readingExpandMedia: ExpandMedia
|
|
||||||
public let readingExpandSpoilers: Bool
|
|
||||||
public let readingAutoplayGifs: Bool
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case postingDefaultVisibility = "posting:default:visibility"
|
|
||||||
case postingDefaultSensitive = "posting:default:sensitive"
|
|
||||||
case postingDefaultLanguage = "posting:default:language"
|
|
||||||
case postingDefaultFederation = "posting:default:federation"
|
|
||||||
case readingExpandMedia = "reading:expand:media"
|
|
||||||
case readingExpandSpoilers = "reading:expand:spoilers"
|
|
||||||
case readingAutoplayGifs = "reading:autoplay:gifs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Preferences {
|
|
||||||
public enum ExpandMedia: String, Codable, Sendable {
|
|
||||||
case `default`
|
|
||||||
case always = "show_all"
|
|
||||||
case never = "hide_all"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -10,6 +10,4 @@ import Foundation
|
||||||
public protocol ListProtocol {
|
public protocol ListProtocol {
|
||||||
var id: String { get }
|
var id: String { get }
|
||||||
var title: String { get }
|
var title: String { get }
|
||||||
var replyPolicy: List.ReplyPolicy? { get }
|
|
||||||
var exclusive: Bool? { get }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
import Foundation
|
||||||
|
|
||||||
public struct PushSubscription: Decodable, Sendable {
|
public struct PushSubscription: Decodable, Sendable {
|
||||||
public var id: String
|
public let id: String
|
||||||
public var endpoint: URL
|
public let endpoint: URL
|
||||||
public var serverKey: String
|
public let serverKey: String
|
||||||
public var alerts: Alerts
|
// TODO: WTF is this?
|
||||||
public var policy: Policy
|
// public let alerts
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
// id is documented as being a string, but mastodon returns a json number
|
|
||||||
if let s = try? container.decode(String.self, forKey: .id) {
|
|
||||||
self.id = s
|
|
||||||
} else {
|
|
||||||
let i = try container.decode(Int.self, forKey: .id)
|
|
||||||
self.id = i.description
|
|
||||||
}
|
|
||||||
self.endpoint = try container.decode(URL.self, forKey: .endpoint)
|
|
||||||
self.serverKey = try container.decode(String.self, forKey: .serverKey)
|
|
||||||
self.alerts = try container.decode(PushSubscription.Alerts.self, forKey: .alerts)
|
|
||||||
// added in mastodon 4.1.0
|
|
||||||
self.policy = try container.decodeIfPresent(PushSubscription.Policy.self, forKey: .policy) ?? .all
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func create(endpoint: URL, publicKey: Data, authSecret: Data, alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
|
|
||||||
return Request(method: .post, path: "/api/v1/push/subscription", body: ParametersBody([
|
|
||||||
"subscription[endpoint]" => endpoint.absoluteString,
|
|
||||||
"subscription[keys][p256dh]" => publicKey.base64EncodedString(),
|
|
||||||
"subscription[keys][auth]" => authSecret.base64EncodedString(),
|
|
||||||
"data[alerts][mention]" => alerts.mention,
|
|
||||||
"data[alerts][status]" => alerts.status,
|
|
||||||
"data[alerts][reblog]" => alerts.reblog,
|
|
||||||
"data[alerts][follow]" => alerts.follow,
|
|
||||||
"data[alerts][follow_request]" => alerts.followRequest,
|
|
||||||
"data[alerts][favourite]" => alerts.favourite,
|
|
||||||
"data[alerts][poll]" => alerts.poll,
|
|
||||||
"data[alerts][update]" => alerts.update,
|
|
||||||
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
|
|
||||||
"data[policy]" => policy.rawValue,
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func update(alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
|
|
||||||
return Request(method: .put, path: "/api/v1/push/subscription", body: ParametersBody([
|
|
||||||
"data[alerts][mention]" => alerts.mention,
|
|
||||||
"data[alerts][status]" => alerts.status,
|
|
||||||
"data[alerts][reblog]" => alerts.reblog,
|
|
||||||
"data[alerts][follow]" => alerts.follow,
|
|
||||||
"data[alerts][follow_request]" => alerts.followRequest,
|
|
||||||
"data[alerts][favourite]" => alerts.favourite,
|
|
||||||
"data[alerts][poll]" => alerts.poll,
|
|
||||||
"data[alerts][update]" => alerts.update,
|
|
||||||
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
|
|
||||||
"data[policy]" => policy.rawValue,
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func delete() -> Request<Empty> {
|
|
||||||
return Request(method: .delete, path: "/api/v1/push/subscription")
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case endpoint
|
case endpoint
|
||||||
case serverKey = "server_key"
|
case serverKey = "server_key"
|
||||||
case alerts
|
// case alerts
|
||||||
case policy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PushSubscription {
|
|
||||||
public struct Alerts: Decodable, Sendable {
|
|
||||||
public let mention: Bool
|
|
||||||
public let status: Bool
|
|
||||||
public let reblog: Bool
|
|
||||||
public let follow: Bool
|
|
||||||
public let followRequest: Bool
|
|
||||||
public let favourite: Bool
|
|
||||||
public let poll: Bool
|
|
||||||
public let update: Bool
|
|
||||||
public let emojiReaction: Bool
|
|
||||||
|
|
||||||
public init(
|
|
||||||
mention: Bool,
|
|
||||||
status: Bool,
|
|
||||||
reblog: Bool,
|
|
||||||
follow: Bool,
|
|
||||||
followRequest: Bool,
|
|
||||||
favourite: Bool,
|
|
||||||
poll: Bool,
|
|
||||||
update: Bool,
|
|
||||||
emojiReaction: Bool
|
|
||||||
) {
|
|
||||||
self.mention = mention
|
|
||||||
self.status = status
|
|
||||||
self.reblog = reblog
|
|
||||||
self.follow = follow
|
|
||||||
self.followRequest = followRequest
|
|
||||||
self.favourite = favourite
|
|
||||||
self.poll = poll
|
|
||||||
self.update = update
|
|
||||||
self.emojiReaction = emojiReaction
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
|
||||||
let container: KeyedDecodingContainer<PushSubscription.Alerts.CodingKeys> = try decoder.container(keyedBy: PushSubscription.Alerts.CodingKeys.self)
|
|
||||||
self.mention = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.mention)
|
|
||||||
// status added in mastodon 3.3.0
|
|
||||||
self.status = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.status) ?? false
|
|
||||||
self.reblog = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.reblog)
|
|
||||||
self.follow = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.follow)
|
|
||||||
// follow_request added in 3.1.0
|
|
||||||
self.followRequest = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.followRequest) ?? false
|
|
||||||
self.favourite = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.favourite)
|
|
||||||
self.poll = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.poll)
|
|
||||||
// update added in mastodon 3.5.0
|
|
||||||
self.update = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.update) ?? false
|
|
||||||
// pleroma/akkoma only
|
|
||||||
self.emojiReaction = try container.decodeIfPresent(Bool.self, forKey: .emojiReaction) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case mention
|
|
||||||
case status
|
|
||||||
case reblog
|
|
||||||
case follow
|
|
||||||
case followRequest = "follow_request"
|
|
||||||
case favourite
|
|
||||||
case poll
|
|
||||||
case update
|
|
||||||
case emojiReaction = "pleroma:emoji_reaction"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PushSubscription {
|
|
||||||
public enum Policy: String, Decodable, Sendable {
|
|
||||||
case all
|
|
||||||
case followed
|
|
||||||
case followers
|
|
||||||
case none
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,13 +27,10 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
|
||||||
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
||||||
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
||||||
self.muting = try container.decode(Bool.self, forKey: .muting)
|
self.muting = try container.decode(Bool.self, forKey: .muting)
|
||||||
// not supported on pixelfed
|
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
|
||||||
self.mutingNotifications = try container.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false
|
|
||||||
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
||||||
// not supported on pixelfed
|
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
|
||||||
self.domainBlocking = try container.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false
|
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
|
||||||
// not supported on pixelfed
|
|
||||||
self.showingReblogs = try container.decodeIfPresent(Bool.self, forKey: .showingReblogs) ?? true
|
|
||||||
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,6 @@ public enum Scope: String, Sendable {
|
||||||
case read
|
case read
|
||||||
case write
|
case write
|
||||||
case follow
|
case follow
|
||||||
case push
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == Scope {
|
extension Array where Element == Scope {
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
//
|
|
||||||
// SearchOperatorType.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 10/1/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum SearchOperatorType: String, CaseIterable, Equatable, Sendable {
|
|
||||||
case has
|
|
||||||
case `is`
|
|
||||||
case language
|
|
||||||
case from
|
|
||||||
case before
|
|
||||||
case during
|
|
||||||
case after
|
|
||||||
case `in`
|
|
||||||
}
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
||||||
|
|
||||||
public struct SearchResults: Decodable, Sendable {
|
public struct SearchResults: Decodable, Sendable {
|
||||||
public let accounts: [Account]
|
public let accounts: [Account]
|
||||||
public let statuses: [TryDecode<Status>]
|
public let statuses: [Status]
|
||||||
public let hashtags: [Hashtag]
|
public let hashtags: [Hashtag]
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|
|
@ -10,9 +10,6 @@ import Foundation
|
||||||
import WebURL
|
import WebURL
|
||||||
|
|
||||||
public final class Status: StatusProtocol, Decodable, Sendable {
|
public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
/// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts.
|
|
||||||
public static let localPostVisibility: String = "local"
|
|
||||||
|
|
||||||
public let id: String
|
public let id: String
|
||||||
public let uri: String
|
public let uri: String
|
||||||
public let url: WebURL?
|
public let url: WebURL?
|
||||||
|
@ -46,8 +43,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
public let localOnly: Bool?
|
public let localOnly: Bool?
|
||||||
public let editedAt: Date?
|
public let editedAt: Date?
|
||||||
|
|
||||||
public let pleromaExtras: PleromaExtras?
|
|
||||||
|
|
||||||
public var applicationName: String? { application?.name }
|
public var applicationName: String? { application?.name }
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
|
@ -68,8 +63,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
|
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
|
||||||
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
|
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
|
||||||
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
|
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
|
||||||
// pixelfed statuses may have null content
|
self.content = try container.decode(String.self, forKey: .content)
|
||||||
self.content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
|
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||||
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
|
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
|
||||||
|
@ -83,7 +77,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.visibility = visibility
|
self.visibility = visibility
|
||||||
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
|
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
|
||||||
} else if let s = try? container.decode(String.self, forKey: .visibility),
|
} else if let s = try? container.decode(String.self, forKey: .visibility),
|
||||||
s == Status.localPostVisibility {
|
s == "local" {
|
||||||
// hacky workaround for #332, akkoma describes local posts with a separate visibility
|
// hacky workaround for #332, akkoma describes local posts with a separate visibility
|
||||||
self.visibility = .public
|
self.visibility = .public
|
||||||
self.localOnly = true
|
self.localOnly = true
|
||||||
|
@ -100,8 +94,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
||||||
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
||||||
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
||||||
|
|
||||||
self.pleromaExtras = try container.decodeIfPresent(PleromaExtras.self, forKey: .pleromaExtras)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||||
|
@ -124,12 +116,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getReactions(_ statusID: String, emoji: String, range: RequestRange = .default) -> Request<[Account]> {
|
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reactions/\(emoji)")
|
|
||||||
request.range = range
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func delete(_ statusID: String) -> Request<Empty> {
|
public static func delete(_ statusID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
|
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
|
||||||
}
|
}
|
||||||
|
@ -187,10 +173,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
|
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func translate(_ statusID: String) -> Request<Translation> {
|
|
||||||
return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate")
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case uri
|
case uri
|
||||||
|
@ -222,15 +204,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
case poll
|
case poll
|
||||||
case localOnly = "local_only"
|
case localOnly = "local_only"
|
||||||
case editedAt = "edited_at"
|
case editedAt = "edited_at"
|
||||||
|
|
||||||
case pleromaExtras = "pleroma"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Status: Identifiable {}
|
extension Status: Identifiable {}
|
||||||
|
|
||||||
extension Status {
|
|
||||||
public struct PleromaExtras: Decodable, Sendable {
|
|
||||||
public let context: String?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct StatusEdit: Decodable, Sendable {
|
public struct StatusEdit: Decodable {
|
||||||
public let content: String
|
public let content: String
|
||||||
public let spoilerText: String
|
public let spoilerText: String
|
||||||
public let sensitive: Bool
|
public let sensitive: Bool
|
||||||
|
@ -28,10 +28,10 @@ public struct StatusEdit: Decodable, Sendable {
|
||||||
case emojis
|
case emojis
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Poll: Decodable, Sendable {
|
public struct Poll: Decodable {
|
||||||
public let options: [Option]
|
public let options: [Option]
|
||||||
|
|
||||||
public struct Option: Decodable, Sendable {
|
public struct Option: Decodable {
|
||||||
public let title: String
|
public let title: String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct StatusSource: Decodable, Sendable {
|
public struct StatusSource: Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let text: String
|
public let text: String
|
||||||
public let spoilerText: String
|
public let spoilerText: String
|
||||||
|
|
|
@ -32,8 +32,8 @@ extension Timeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
|
func request(range: RequestRange) -> Request<[Status]> {
|
||||||
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
|
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
|
||||||
if case .public(true) = self {
|
if case .public(true) = self {
|
||||||
request.queryParameters.append("local" => true)
|
request.queryParameters.append("local" => true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,53 +7,26 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct TimelineMarkers {
|
public struct TimelineMarkers: Decodable, Sendable {
|
||||||
private init() {}
|
public let home: Marker?
|
||||||
|
public let notifications: Marker?
|
||||||
|
|
||||||
public static func request<T: TimelineMarkerType>(timeline: T) -> Request<TimelineMarker<T.Payload>> {
|
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> {
|
||||||
Request(method: .get, path: "/api/v1/markers", queryParameters: ["timeline[]" => T.name])
|
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> {
|
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> {
|
||||||
Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
||||||
"\(T.name)[last_read_id]" => lastReadID
|
"\(timeline.rawValue)[last_read_id]" => lastReadID,
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public struct TimelineMarker<Payload: TimelineMarkerTypePayload>: Decodable, Sendable {
|
public enum Timeline: String {
|
||||||
let payload: Payload
|
case home
|
||||||
|
case notifications
|
||||||
public var lastReadID: String {
|
|
||||||
payload.payload.lastReadID
|
|
||||||
}
|
|
||||||
public var version: Int {
|
|
||||||
payload.payload.version
|
|
||||||
}
|
|
||||||
public var updatedAt: Date {
|
|
||||||
payload.payload.updatedAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
public struct Marker: Decodable, Sendable {
|
||||||
self.payload = try Payload(from: decoder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public protocol TimelineMarkerTypePayload: Decodable, Sendable {
|
|
||||||
var payload: MarkerPayload { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct HomeMarkerPayload: TimelineMarkerTypePayload {
|
|
||||||
public var home: MarkerPayload
|
|
||||||
public var payload: MarkerPayload { home }
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct NotificationsMarkerPayload: TimelineMarkerTypePayload {
|
|
||||||
public var notifications: MarkerPayload
|
|
||||||
public var payload: MarkerPayload { notifications }
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct MarkerPayload: Decodable, Sendable {
|
|
||||||
public let lastReadID: String
|
public let lastReadID: String
|
||||||
public let version: Int
|
public let version: Int
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
|
@ -63,27 +36,5 @@ public struct MarkerPayload: Decodable, Sendable {
|
||||||
case version
|
case version
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol TimelineMarkerType {
|
|
||||||
static var name: String { get }
|
|
||||||
associatedtype Payload: TimelineMarkerTypePayload
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TimelineMarkerType where Self == HomeMarker {
|
|
||||||
public static var home: Self { .init() }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TimelineMarkerType where Self == NotificationsMarker {
|
|
||||||
public static var notifications: Self { .init() }
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct HomeMarker: TimelineMarkerType {
|
|
||||||
public typealias Payload = HomeMarkerPayload
|
|
||||||
public static var name: String { "home" }
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct NotificationsMarker: TimelineMarkerType {
|
|
||||||
public typealias Payload = NotificationsMarkerPayload
|
|
||||||
public static var name: String { "notifications" }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
//
|
|
||||||
// Translation.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/4/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public struct Translation: Decodable, Sendable {
|
|
||||||
public let content: String
|
|
||||||
public let spoilerText: String?
|
|
||||||
public let detectedSourceLanguage: String
|
|
||||||
public let provider: String
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case content
|
|
||||||
case spoilerText
|
|
||||||
case detectedSourceLanguage = "detected_source_language"
|
|
||||||
case provider
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -13,7 +13,6 @@ public struct Request<ResultType: Decodable>: Sendable {
|
||||||
let endpoint: Endpoint
|
let endpoint: Endpoint
|
||||||
let body: Body
|
let body: Body
|
||||||
var queryParameters: [Parameter]
|
var queryParameters: [Parameter]
|
||||||
var headers: [String: String] = [:]
|
|
||||||
var additionalAcceptableHTTPCodes: [Int] = []
|
var additionalAcceptableHTTPCodes: [Int] = []
|
||||||
|
|
||||||
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
||||||
|
|
|
@ -49,7 +49,7 @@ public class InstanceSelector {
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension InstanceSelector {
|
public extension InstanceSelector {
|
||||||
struct Instance: Codable, Sendable {
|
struct Instance: Codable {
|
||||||
public let domain: String
|
public let domain: String
|
||||||
public let description: String
|
public let description: String
|
||||||
public let proxiedThumbnailURL: URL
|
public let proxiedThumbnailURL: URL
|
||||||
|
|
|
@ -7,18 +7,17 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import WebURL
|
|
||||||
|
|
||||||
public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||||
public private(set) var notifications: [Notification]
|
public private(set) var notifications: [Notification]
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Kind
|
public let kind: Notification.Kind
|
||||||
|
|
||||||
public init?(notifications: [Notification], kind: Kind) {
|
public init?(notifications: [Notification]) {
|
||||||
guard !notifications.isEmpty else { return nil }
|
guard !notifications.isEmpty else { return nil }
|
||||||
self.notifications = notifications
|
self.notifications = notifications
|
||||||
self.id = notifications.first!.id
|
self.id = notifications.first!.id
|
||||||
self.kind = kind
|
self.kind = notifications.first!.kind
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
||||||
|
@ -45,61 +44,30 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||||
notifications.append(contentsOf: group.notifications)
|
notifications.append(contentsOf: group.notifications)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func groupKind(for notification: Notification) -> Kind {
|
|
||||||
switch notification.kind {
|
|
||||||
case .mention:
|
|
||||||
return .mention
|
|
||||||
case .reblog:
|
|
||||||
return .reblog
|
|
||||||
case .favourite:
|
|
||||||
return .favourite
|
|
||||||
case .follow:
|
|
||||||
return .follow
|
|
||||||
case .followRequest:
|
|
||||||
return .followRequest
|
|
||||||
case .poll:
|
|
||||||
return .poll
|
|
||||||
case .update:
|
|
||||||
return .update
|
|
||||||
case .status:
|
|
||||||
return .status
|
|
||||||
case .emojiReaction:
|
|
||||||
if let emoji = notification.emoji {
|
|
||||||
return .emojiReaction(emoji, notification.emojiURL)
|
|
||||||
} else {
|
|
||||||
return .unknown
|
|
||||||
}
|
|
||||||
case .unknown:
|
|
||||||
return .unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||||
var groups = [NotificationGroup]()
|
var groups = [NotificationGroup]()
|
||||||
for notification in notifications {
|
for notification in notifications {
|
||||||
let groupKind = groupKind(for: notification)
|
|
||||||
|
|
||||||
if allowedTypes.contains(notification.kind) {
|
if allowedTypes.contains(notification.kind) {
|
||||||
if let lastGroup = groups.last, canMerge(notification: notification, kind: groupKind, into: lastGroup) {
|
if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) {
|
||||||
groups[groups.count - 1].append(notification)
|
groups[groups.count - 1].append(notification)
|
||||||
continue
|
continue
|
||||||
} else if groups.count >= 2 {
|
} else if groups.count >= 2 {
|
||||||
let secondToLastGroup = groups[groups.count - 2]
|
let secondToLastGroup = groups[groups.count - 2]
|
||||||
if allowedTypes.contains(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)
|
groups[groups.count - 2].append(notification)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
|
groups.append(NotificationGroup(notifications: [notification])!)
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
|
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
|
||||||
return kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
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] {
|
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
|
var second = second
|
||||||
merged.reserveCapacity(second.count)
|
merged.reserveCapacity(second.count)
|
||||||
while let firstGroupFromSecond = second.first,
|
while let firstGroupFromSecond = second.first,
|
||||||
allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
|
allowedTypes.contains(firstGroupFromSecond.kind) {
|
||||||
|
|
||||||
second.removeFirst()
|
second.removeFirst()
|
||||||
|
|
||||||
guard let lastGroup = merged.last,
|
guard let lastGroup = merged.last,
|
||||||
allowedTypes.contains(lastGroup.kind.notificationKind) else {
|
allowedTypes.contains(lastGroup.kind) else {
|
||||||
merged.append(firstGroupFromSecond)
|
merged.append(firstGroupFromSecond)
|
||||||
break
|
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)
|
merged[merged.count - 1].append(group: firstGroupFromSecond)
|
||||||
} else if merged.count >= 2 {
|
} else if merged.count >= 2 {
|
||||||
let secondToLastGroup = merged[merged.count - 2]
|
let secondToLastGroup = merged[merged.count - 2]
|
||||||
if allowedTypes.contains(secondToLastGroup.kind.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)
|
merged[merged.count - 2].append(group: firstGroupFromSecond)
|
||||||
} else {
|
} else {
|
||||||
merged.append(firstGroupFromSecond)
|
merged.append(firstGroupFromSecond)
|
||||||
|
@ -141,42 +109,4 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Kind: Sendable, Equatable {
|
|
||||||
case mention
|
|
||||||
case reblog
|
|
||||||
case favourite
|
|
||||||
case follow
|
|
||||||
case followRequest
|
|
||||||
case poll
|
|
||||||
case update
|
|
||||||
case status
|
|
||||||
case emojiReaction(String, WebURL?)
|
|
||||||
case unknown
|
|
||||||
|
|
||||||
var notificationKind: Notification.Kind {
|
|
||||||
switch self {
|
|
||||||
case .mention:
|
|
||||||
.mention
|
|
||||||
case .reblog:
|
|
||||||
.reblog
|
|
||||||
case .favourite:
|
|
||||||
.favourite
|
|
||||||
case .follow:
|
|
||||||
.follow
|
|
||||||
case .followRequest:
|
|
||||||
.followRequest
|
|
||||||
case .poll:
|
|
||||||
.poll
|
|
||||||
case .update:
|
|
||||||
.update
|
|
||||||
case .status:
|
|
||||||
.status
|
|
||||||
case .emojiReaction(_, _):
|
|
||||||
.emojiReaction
|
|
||||||
case .unknown:
|
|
||||||
.unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue