Compare commits

..

No commits in common. "develop" and "develop" have entirely different histories.

629 changed files with 18186 additions and 40167 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,278 +0,0 @@
## 2024.5
Features/Improvements:
- Improve gallery animations
Bugfixes:
- Handle right-to-left text in display names
- Fix crash during gifv playback
- iPadOS: Fix app becoming unresponsive when switching accounts
- iPadOS/macOS: Fix Cmd+R shortcuts not working
## 2024.4
This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.
Features/Improvements:
- Import image description when adding attachments from Photos if possible
- iPadOS 18: New floating sidebar/tab bar
Bugfixes:
- Fix crash when viewing profiles in certain circumstances
- Fix video controls in attachment gallery not auto-hiding
- Fix crash if hashtag search results includes duplicates
- Fix "no content" text not being removed from list timeline after refreshing
- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
- macOS: Fix reselecting current item not navigating back
## 2024.3
This update includes a number of bugfixes and performance improvements. See below for a list of fixes.
Bugfixes:
- Fix an issue displaying rich text in certain cases
- Fix crash when video attachment finishes playing
- Fix video attachment thumbnails being flipped on Compose screen
- Fix profile header images being blurry
- Fix crash when opening push notifications in certain circumstances
- Fix certain links in profile fields not being tappable
- Fix gifv playback pausing audio from other apps
- Fix gifv playback being paused when returning from background
- Fix badges on gifv attachments not appearing
- Fix excessive network traffic when opening profile pages
- Fix controls visibility not matching across attachment gallery pages
- Fix add hashtag/instance pinned timeline sheet in Customize Timelines dismissing instantly
- Fix Dynamic Type not applying to status content
- Fix mention/status push notifications not showing CW
- Fix sensitive attachment thumbnails being shown in push notifications
- Fix profile moved overlay visual and VoiceOver issues
- Fix opening Mastodon remote status links
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
- Pleroma/Akkoma: Fix editing attachment descriptions not working
- Pixelfed/Firefish: Fix error loading certain accounts
- Pixelfed: Fix error loading relationships and follow/block/etc. actions
- iPadOS: Fix pointer interactions throughout the app
- iPadOS: Fix multiple close buttons being added in multi-column interface
- iPadOS: Fix Cmd+1/etc. removing columns when returning to previous tab
- iPadOS: Fix multi-column interface not animating for some actions
- iPadOS: Fix selecting search results always adding new column
## 2024.2
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
Features/Improvements:
- Push notifications
- Add post preview to Appearance preferences
- Show instance announcements in Notifications tab
- Add subscription option to Tip Jar
- iPadOS: Multi-column navigation
- Pleroma/Akkoma: Emoji reaction notifications
Bugfixes:
- Fix fetching server info on some instances
- Fix attachment captions not displaying while loading in gallery
- macOS: Remove in-app Safari preferences
- Pleroma: Handle posts with missing creation date
## 2024.1
This update includes a significant improvements for the attachment gallery and displaying rich text posts. See below for a full list of improvements and fixes.
Features/Improvements:
- Improve attachment gallery
- Improve animations
- Display video captions
- Support sharing/saving videos
- Resume music playback after playing videos
- Improve rich text display in posts
- Add See Results button to polls
- Add Share and Save to Photos menu items to post attachments
- Show verified links in account lists
- Display message on empty list timelines
- Add preference to indicate attachments lacking alt text
- Mark notifications as read on Mastodon web frontend once displayed
- iPadOS: Support tapping the selected sidebar item to scroll to top
Bugfixes:
- Fix issue changing scope after searching
- Fix crash when searching "from:me"
- Fix tapping Followers button on profile opening Following screen
- Fix crash when removing poll option on Compose screen
- Fix hang when sharing video/GIFV attachments
- Fix stretched Save to Photos icon when sharing attachments
- Fix GIFV playback preventing device sleep
- Fix Notifications tab not scrolling to top when tab bar item tapped
- Fix selection not clearing on Trending Hashtags
- Fix fast account switcher overlapping iPhone sensor housing in landscape
- Fix Edit List screen not updating when adding/removing accounts
- Fix changing list reply policy not refreshing timeline
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
- macOS: Fix attachment gallery displaying improperly when Reduce Motion is on
## 2023.8
This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes.
Features/Improvements:
- Show search operators on Mastodon 4.2
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
- Allow changing list reply policy and exclusivity options on Edit List screen
- Add Translate action to conversations (on supported Mastodon instances)
- Style block quotes correclty in rich-text posts
- Improve the appearance of lists in rich-text posts
- Add preference to underline links
- Compress uploaded video attachments to fit within instance limits
- Add preference to hide attachments in timelines
- Update visible timestamps after refresh notifications/timelines
- iPadOS: Allow switching between split screen and fullscreen navigation modes
- Pixelfed: Improve error message when uploading attachment fails
- Akkoma: Enable composing local-only posts
Bugfixes:
- Fix older notifications not loading if all initiially-loaded ones are grouped together
- Fix List timelines failing to refresh if they were initially empty
- Fix replies to posts with CWs always showing confirmation dialog when cancelling
- Fix Compose screen permitting setting the language to multiple/undefined
- Fix crash when uploading attachments without file extensions
- Fix Live Text button reappearing with swiping between attachment gallery pages
- Fix avatars on certain notifications flickering when refreshing
- Fix avatars on follow request notifications not being rounded
- Fix timeline jump button appearing incorrectly when Button Shapes acccessibility setting is on
- Fix public instance timeline screen not handling post deletion correctly
- Fix post that's reblogged and contains a followed hashtag not showing the reblogger
- Fix crash on launch when reblogged posts are visible
- Fix crash when showing display names with custom emoji in certain places
- Fix crash when showing trending hashtags without history data
- Fix potential crash on instance selector screen
- Fix potential crash if the app is dismissed while fast account switcher is animating
- Fix potential crash after deleting List on the Eplore screen
- Pixelfed: Fix error decoding certain posts
- VoiceOver: Fix history entries on Edit History screen not having descriptions
- iPadOS: Fix delay on app launch before "My Profile" sidebar item appears
- iPadOS: Fix language picker button not highlighting when hovered with the cursor
- macOS: Fix "New Post" window title appearing twice
- macOS: Fix Cmd+W sometimes closing non-foreground windows
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.7
This update adds support for iOS 17 and includes some minor changes.
Changes:
- Support iOS 17
- Indicate that edit history may be incomplete for remote posts
- Fix crash when collapsing to tab-bar mode in certain circumstances
- Fix potential crashes when using autocomplete on the Compose screen
- Fix Iceshrimp instances not being detected
## 2023.6
This update fixes a number of bugs and improves stability throughout the app. See below for a list of fixes.
Bugfixes:
- Fix issues displaying main post in the Conversation screen
- Fix crash when opening the Compose screen in certain locales
- Fix issues when collapsing from sidebar to tab bar mode
- Fix incorrect UI being displayed when accessing certain parts of the app immediately after launch
- Fix link card images not being blurred on posts marked sensitive
- Fix links appearing with incorrect accent color intermittently
- Fix being unable to remove followed hashtags from the Explore screen
- Akkoma: Fix not being able to follow hashtags
- Pleroma: Fix refreshing Mentions failing
- iPhone: Fix ducked Compose screen disappearing when rotating on large phones
## 2023.5
This update adds new several Compose-related features, including the ability to edit posts, a share sheet extension, and a post language picker. See below for the full list of improvements and bugfixes.
Features/Improvements:
- Edit posts
- Indicate edited posts in timestamp
- Show post edit history from Conversation screen
- Add Share Sheet extension
- Add expanded attachment view on Compose screen
- Add an attachment, select the description text field, then tap the expand button
- Expanded view allows you to see the attachment while writing the description
- Allows playing back videos while writing description
- iOS 16: Allows zooming in to the attachment
- Add language picker to the Compose screen
- Improve Compose screen ducking behavior
- Show reblogger's avatar on reblogged posts
- Use system photo picker instead of custom interface
- Improve hashtag search UI in Customize Timelines
- Improve status collapse/expand animation on Notifications screen
- Apply filters to Notifications screen
- Improve performance when scrolling through timeline
- Improve error messages when editing filters
- Change favorite/reblog button order to match Mastodon UI
- Gracefully handle unknown attachment types
- iPadOS: Persist sidebar visibility across
Bugfixes:
- Fix scroll-to-top not working in in-app Safari
- Fix inaccruate titles in certain error popups
- Fix error decoding post HTML
- Fix replied-to account not being the first @-mention
- Fix "No Content" message on profiles using wrong background color
- Fix reblogged posts appearing in Bookmarks
- Fix spurious errors when loading timeline
- Fix crash when displaying certain profiles
- Fix crash when the server returns invalid notifications
- Fix link previews not appearing in Notifications
- Fix Notifications screen taking a long time to load
- Fix deleted posts not being removed from Notifications screen
- Fix crashes when switching between sidebar/tab-bar modes
- Fix instance features not being detected on IDNA domains
- Fix list/hashtag timelines missing controls when opened in new window
- Fix reblog button being enabled on the user's own direct posts
- Fix main post in Conversation flickering
- Fix link card images not loading on Mastodon
- Fix crash when editing filter with the Hide action
- Fix certain remote status links not being resolved
- Fix Handoff to iPad/Mac presenting new screen modally
- GoToSocial: Fix decoding certain posts
- Calckey: Fix decoding certain posts
- iPadOS: Fix Compose window lacking a title
- iPadOS: Fix keyboard focus highlight not showing
- macOS: Fix sidebar keyboard shortcuts not working
## 2023.4
Features/Improvements:
- Add preference for non-pure-black dark mode
- Add Jump to Present button to timelines on the home tab
- Consolidate Trends into a single screen
- Allow pinning instance public timelines to the Home tab
- Add GIF/ALT badges to attachments (and preference to hide them)
- Add action to show hide/show reblogs from specific accounts
- Add preference to hide link preview cards
- Hide placeholder image in link preview card for previews without images
- Truncate links in posts
- Move Drafts button in Compose screen to nav bar to reduce accidental presses
- Load more posts/notifications on each page
- Update Bookmarks screen when posts are bookmarked/unbookmarked
- Add infinite scrolling to Bookmarks screen
- Add Favorites screen to the Explore tab
- Make attachment description text selectable in gallery
- Add long press to copy username on profile screens
- Optimize conversation loading
- Apply server-configured poll limits in Compose screen
- Add infinite scrolling to trending links/hashtags/posts
- Add state restoration for more screens
- Persist state when switching between accounts
- Add Handoff support for various screens
- Add preference to sync timeline position using Mastodon API, rather than iCloud
- Show percentage of voters for multi-choice polls, rather than percentage of votes
- Display message on remote profiles with no posts
- Indicate moved profiles
- Make Load More button on timelines more prominent
- VoiceOver: Make fast account switcher accessible
- VoiceOver: Improve labels for notifications
- VoiceOver: Fix custom emoji picker not having labels
Bugfixes:
- Workaround for not being able to sign in to certain instances
- Fix timeline position sync not working in certain circumstances
- Fix local-only posts not being decodable when logged in to Akkoma instances
- Fix Trends sometimes appearing in Explore/sidebar on non-Mastodon instances
- Fix favoriters/rebloggers list not resizing on screen rotation
- Fix crash when tapping My Profile tab immediately after app launch
- Handle authentication required errors on instance public timelines
- Fix follow request accept/reject buttons not matching accent color preference
- Fix tapping reblog count in conversation main status showing favorites list
- Fix crash when certain tags are present in post HTML
- Fix crash when opening Report screen in certain circumstances
- iPadOS: Fix crash when resizing window while on the Explore screen
- iOS 15: Fix accent colors not being displayed in Preferences

View File

@ -1,635 +1,5 @@
# Changelog
## 2025.1 (145)
This build introduces the new, redesigned Compose screen. Please report any issues you encounter.
Known issues:
- Autocomplete mentions/emojis/hashtags in Comopose is not available
- Rich text formatting actions in Compose are not available
- Non-pure-black dark mode preference is not respected in Compose
## 2025.1 (142)
Bugfixes:
- Fix signing into API-restricted instances
## 2024.5 (141)
Bugfixes:
- Fix gallery controls being positioned incorrectly during dismiss animation on certain devices
- Fix gallery controls being positioned incorrectly in landscape orientations
## 2024.5 (139)
Bugfixes:
- Fix error decoding certain posts
## 2024.5 (138)
Bugfixes:
- Fix potential crash when displaying certain attachments
- Fix potential crash due to race condition when opening push notification in app
- Fix misaligned text between profile field values/labels
- Fix rate limited error message not including reset timestamp
- iPadOS/macOS: Fix Cmd+R shortcut not working
## 2024.5 (137)
Features/Improvements:
- Improve gallery presentation/dismissal transitions
Bugfixes:
- Account for bidirectional text in display names
- Fix crash when playing back gifv
- Fix gallery controls not hiding if video loading fails
- iPadOS: Fix incorrect gallery dismiss animation on non-fullscreen windows
- iPadOS: Fix hang when switching accounts
## 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)
Features/Improvements:
- Change favorite/reblog button order to match Mastodon
- Use QuickLook as a fallback for uknown attachment types
Bugfixes:
- Fix crash when adding drawing attachment
## 2023.5 (96)
Features/Improvements:
- Resolve Mastodon's remote status links
Bugfixes:
- Fix handoff to iPad/Mac presenting new screen modally rather than navigating
- Fix crash if timeline gap cell is accessibility-activated after leaking
- Fix various crashes when multiple Compose/Drafts screens are opened
- Delete orphaned draft attachments
- Fix deleted posts not getting removed from Notifications screen
- Fix replied-to status not changing when selecting draft
## 2023.5 (94)
Features/Improvements:
- Apply filters to Notifications screen
Bugfixes:
- Fix editing posts not working on Akkoma
- Fix editing Markdown/HTML posts
- Fix crash when editing filter with Hide action
## 2023.5 (91)
Features/Improvements:
- Improve performance when scrolling through timeline
- Improve error messages when editing filters
- Enable editing posts on Pleroma 2.5+
Bugfixes:
- Fix share sheet extension not working with Apple News
- Fix crash when sharing certain photos with share extension
- Fix reblog button being enabled on Direct posts
- Fix expanded statuses collapsing when opening Conversation
- Fix main post in Conversation flickering when context loaded
- Fix link card images not loading on Mastodon
## 2023.5 (89)
This build is a hotfix for an issue loading notifications in certain circumstances. The changelong for the previous build (adding post editing) is included below.
## 2023.5 (85)
This build adds support for editing posts and showing edit timestamps and history.
Features/Improvements:
- Post editing
- Show post edit history
- Improve rate limit exceeded error message
- Shorten hashtag save/follow action subtitles so they fit in the context menu
- Remove Hide/Show Reblogs action for accounts the user isn't following
Bugfixes:
- Fix nodeinfo not being fetched on instances with punycode domains
- Fix potential crash with interactive push gesture
- Fix list timelines opened in new window lacking Edit button
- Fix hashtag timelines opened in new window lacking save/follow actions
- Fix being able to scroll to top while fast account switcher is active
- Fix decoding statuses lacking emojis on Calckey
- Fix decoding polls on Calckey
## 2023.5 (84)
Bugfixes:
- Fix notifications scrolling to top when refreshing
- Fix decoding statuses failing on GoToSocial
- Fix assorted issues when collapsing/expanding between sidebar and tab bar modes
## 2023.5 (83)
This build contains significant refactors to the notifications screen, please report any issues you encounter.
Features/Improvements:
- Tweak appearance of profile fields
- Make language picker sheet half-height
Bugfixes:
- Fix crash when laying out profile fields on certain accounts
- Fix other presented screens getting dismissed when opened after closing expanded attachment view
- Fix janky status collapse/expand animation on notifications screen
- Fix link previews not appearing in notifications
## 2023.5 (81)
Further improvements and fixes to the Compose screen, see below. Features are frozen for the upcoming release, please report any bugs you encounter!
Features/Improvements:
- Add expanded attachment view on Compose screen
- Add an attachment, select the description text field, and tap on the expand button on the attachment thumbnail
- Expanded attachment view allows you to view the attachment larger while writing the description
- Plays back videos while writing the description
- iOS 16: Allow zooming in to expanded attachment view
- Add language picker to Compose screen
- Persist sidebar visibility across app launches
- Align link verification checkmarks to link rather than creen edge
- Fully dismiss, rather than ducking, the Compose screen when swiped down with no content
- Remove Automatically Save Drafts preference
- Drafts are always saved automatically, and the save/delete sheet is now always shown on dismiss
Bugfixes:
- Fix share sheet extension being unavailable on iOS 15
- Fix crash when loading draft with poll from share sheet extension
- Fix active draft being deleted when Compose screen ducked
- Fix restored, ducked Compose screen lacking title
- Fix error when reloading empty profile
- Fix local attachments not being deleted upon draft deletion
- Fix GIFs being converted to still images on upload
- Fix crash on deleting draft with attachments in share extension
- Fix deleted attachments in Compose screen reappearing
- Fix spinner on Send Report button being misplaced
- Fix crash on launch loop when migrating from previous version in certain circumstances
## 2023.5 (80)
This build adds a Share Sheet extension and introduces further Compose screen refactors.
Features/Improvements:
- Add Share Sheet extension
- Show reblogger's avatar on reblogged posts
Bugfixes:
- Fix not being able to close Compose screen when Automatically Save Drafts preference is off
- Fix Post button always being disabled when Require Attachment Descriptions preference is on
- Fix crash when pasting screenshots
- Fix not being able to paste gifs
- Don't consider HTTP 206 responses to timeline requests to be errors
- Fix crash when displaying menu for statuses missing URLs
- Fix errors while posting not displaying useful error messages
## 2023.5 (77)
The Compose screen has been substantially refactored in this build, in preparation for upcoming features, so please report any issues you encounter!
Features/Improvements:
- Use system photo picker instead of custom interface
- Improve Customize Timelines hashtag search UI
Bugfixes:
- Fix scroll-to-top not working in in-app Safari
- Fix crash when decoding pinned timelines fails
- Fix inaccurate titles in certain error popups
- Fix crash when comments present in status HTML
- Fix replied-to account not being the first mention
- Fix Compose window not having title set initially
- Fix crash when the API returns notifications that are missing statuses
- Fix "No Content" cell on profiles not using non-pure-black background
- Fix reblogged statuses appearing in the Bookmarks list
- Fix keyboard focus highlight not showing
- macOS: Fix sidebar item keyboard shortcuts not working
## 2023.4 (76)
App Store release
## 2023.4 (75)
This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below.
## 2023.4 (74)
Features/Improvements:
- Add state restoration for more screens
- Persist state when switching between accounts
- Add handoff for various screens
- Add preference to hide GIF/ALT badges on attachments
- Add preference to use Mastodon timeline marker API for syncing Home timeline position
- Show percentage of voters for multi-choice poll results, rather than percentage of votes
- Change search results view controller to dismiss keyboard on scroll
- Only show inaccurate favorite/reblog count warning for posts from remote instances
- Show message on remote profiles with no statuses
- Add banner to profiles that have moved
- Hide placeholder image for link cards without images
- Don't check for present statuses when refreshing timeline
- Make timeline Load More button more prominent
- iOS 16.4: Use iOS-provided link previews in Share Sheet
Bugfixes:
- Fix tapping reblog count in conversation main status showing favorites list
- Fix status favorite/reblog list not adjusting to non-pure-black dark mode
- Fix non-pure-black dark mode not applying to auxiliary windows
- Fix poll option tracking gesture unselecting options when touch location moves between options
- Fix crash when tapping conversation "More Replies" cell
- Fix crash when script/style tags are present in post HTML
- Fix crash when opening Report screen in certain circumstances
## 2023.4 (73)
Features/Improvements:
- Add preference for non-pure-black dark mode
- Add Jump to Present button to timelines
- Improve status collapse animation in search results screen
- Add more trending links/hashtags/profiles buttons to Trends screen
- Add infinite scrolling to trending links/hashtags screens
- Add Share action to trending link context menu
Bugfixes:
- Fix icon in suggested profile popover not adjusting to dark mode
## 2023.4 (72)
Features/Improvements:
- Consolidate Trends into a single screen
- Make attachment description text selectable in gallery
- Add long press to copy usernames on profile screen
- Add Favorites screen to Explore tab
- Optimize conversation loading when opening a conversation that is already fully-loaded
- Apply Mastodon poll limits in Compose screen
- VoiceOver: Fast account switcher improvements (make the screen modal, select the first account upon opening the switcher, make each account a single item)
- VoiceOver: Improve labels for notifications
- VoiceOver: Fix custom emoji picker buttons not having labels
Bugfixes:
- Fix trends sometimes appearing in Explore/sidebar on non-Mastodon instances
- Fix status favorite/reblog accounts list not resizing on device rotation
- Fix bookmarks screen sometimes going haywire
- Fix trending statuses not being deselected upon navigating back
- Fix crash when tapping My Profile tab too early in app lifecycle
- Handle 401 errors on instance timelines properly
- Fix potential crash when showing context menu previews for status
- Fix follow request accept/reject buttons not matching accent color preference
- iPadOS: Fix crash when switching between sidebar and tab bar while on the Explore screen
- iOS 15: Fix accent colors not being disaplyed in Preferences
## 2023.4 (71)
Features/Improvements:
- Allow pinning instance public timelines to the Home tab
- Improve UI and retry mechanism when adding account
- Increase page size to 40 on a bunch of screens
- Update bookmarks screen when posts are bookmarked/unbookmarked
- Allow loading older and refreshing bookmarks screen
- Tweak follow count button color
Bugfixes:
- Fix timeline position sync not working in certain circumstances
- iPadOS: Fix flicker when opening favorite/reblog list in notificationss
## 2023.4 (70)
Features/Improvements:
- Add GIF/ALT badges to attachments
- Add menu action to hide/show reblogs from specific accounts
- Apply Mastodon's link truncation
- Add preference to hide link preview cards
- Tweak link preview card border color in dark mode
- Unify haptic feedback across the app
- Move Drafts button to the nav bar when the post doesn't have any content, to reduce accidental presses
Bugfixes:
- Fix status URLs with fragments not being resolved
- Workaround for local-only posts not being decodable when logged in to Akkoma instances
## 2023.3 (69)
Features/Improvements:
- Add Tip Jar under Preferences
- Add scopes to search screen
- Workarounds for logging in to mastodon.social being unreliable
- Handle instance timeline authentication errors more gracefully
- iPadOS: Add Trending Posts and Profile Suggestions to Explore screen
Bugfixes:
- Fix Open in Safari action not working
- Fix notifications for posts from subscribed accounts not being shown
- Detect Misskey status links
- Fix trending hashtag cells not adjusting to Dynamic Type
- Fix raw HTML being shown in link preview cards
- iPadOS: Fix crash when expanding window size while showing trending statuses/hashtags/links screen
- iPadOS: Fix preview actions not working on Explore screen
- macOS: Fix not being able to right-click to remove pinned timelines
## 2023.2 (68)
Bugfixes:
- Fix crash when inserting present items in empty timeline

View File

@ -1,7 +0,0 @@
# Haptic Feedback
## Selection changed
`UISelectionFeedbackGenerator`
## Actions
On success, `UIImpactFeedbackGenerator` with the `.light` style. On error, `UINotificationFeedbackGenerator` with the `.error` type.

View File

@ -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>

View File

@ -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>

View File

@ -1,399 +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
}
content.threadIdentifier = conversationIdentifier ?? ""
let account: Account?
switch notification.kind {
case .mention, .status:
account = notification.status?.account
default:
account = notification.account
}
let sender: INPerson?
if let account {
let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown)
let image: INImage?
if let avatar = account.avatar,
let (data, resp) = try? await URLSession.shared.data(from: avatar),
let code = (resp as? HTTPURLResponse)?.statusCode,
(200...299).contains(code) {
image = INImage(imageData: data)
} else {
image = nil
}
sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: account.displayName,
image: image,
contactIdentifier: nil,
customIdentifier: account.id
)
} else {
sender = nil
}
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: notificationContent,
speakableGroupName: nil,
conversationIdentifier: nil,
serviceName: nil,
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
do {
try await interaction.donate()
} catch {
logger.error("Error donating interaction: \(String(describing: error))")
return
}
let updatedContent: UNMutableNotificationContent
let contentProviding: any UNNotificationContentProviding
if #available(iOS 18.0, visionOS 2.0, *),
await Preferences.shared.hasFeatureFlag(.pushNotifCustomEmoji) {
let attributedString = NSMutableAttributedString(string: content.body)
for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() {
let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }),
let url = URL(emoji.url),
let (data, _) = try? await URLSession.shared.data(from: url),
let image = UIImage(data: data) else {
continue
}
let attachment = NSTextAttachment(image: image)
let attachmentStr = NSAttributedString(attachment: attachment)
attributedString.replaceCharacters(in: match.range, with: attachmentStr)
}
let attributedCtx = UNNotificationAttributedMessageContext(sendMessageIntent: intent, attributedContent: attributedString)
contentProviding = attributedCtx
} else {
contentProviding = intent
}
do {
let newContent = try content.updating(from: contentProviding)
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
pendingRequest?.0 = newMutableContent
updatedContent = newMutableContent
} else {
updatedContent = content
}
} catch {
logger.error("Error updating notification from intent: \(String(describing: error))")
updatedContent = content
}
if let localAttachmentURL = await attachmentDataTask?.value,
let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) {
updatedContent.attachments = [
attachment
]
}
}
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
DispatchQueue.main.sync {
// this is necessary because of a swift bug: https://github.com/apple/swift/pull/72507
MainActor.runUnsafely {
PushManager.shared.pushSubscription(account: account)
}
}
}
private func decryptNotification(subscription: PushNotifications.PushSubscription, serverPublicKeyData: Data, salt: Data, encryptedBody: Data) -> Data? {
// See https://github.com/ClearlyClaire/webpush/blob/f14a4d52e201128b1b00245d11b6de80d6cfdcd9/lib/webpush/encryption.rb
var context = Data()
context.append(0)
let clientPublicKey = subscription.secretKey.publicKey.x963Representation
let clientPublicKeyLength = UInt16(clientPublicKey.count)
context.append(UInt8((clientPublicKeyLength >> 8) & 0xFF))
context.append(UInt8(clientPublicKeyLength & 0xFF))
context.append(clientPublicKey)
let serverPublicKeyLength = UInt16(serverPublicKeyData.count)
context.append(UInt8((serverPublicKeyLength >> 8) & 0xFF))
context.append(UInt8(serverPublicKeyLength & 0xFF))
context.append(serverPublicKeyData)
func info(encoding: String) -> Data {
var info = Data("Content-Encoding: \(encoding)\0P-256".utf8)
info.append(context)
return info
}
let sharedSecret: SharedSecret
do {
let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData)
sharedSecret = try subscription.secretKey.sharedSecretFromKeyAgreement(with: serverPublicKey)
} catch {
logger.error("Error getting shared secret: \(String(describing: error))")
return nil
}
let sharedInfo = Data("Content-Encoding: auth\0".utf8)
let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: subscription.authSecret, sharedInfo: sharedInfo, outputByteCount: 32)
let contentEncryptionKeyInfo = info(encoding: "aesgcm")
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: contentEncryptionKeyInfo, outputByteCount: 16)
let nonceInfo = info(encoding: "nonce")
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceAndEncryptedBody = nonce.withUnsafeBytes { noncePtr in
var data = Data(buffer: noncePtr.bindMemory(to: UInt8.self))
data.append(encryptedBody)
return data
}
do {
let sealedBox = try AES.GCM.SealedBox(combined: nonceAndEncryptedBody)
let decrypted = try AES.GCM.open(sealedBox, using: contentEncryptionKey)
return decrypted
} catch {
logger.error("Error decrypting push: \(String(describing: error))")
return nil
}
}
}
extension MainActor {
@_unavailableFromAsync
@available(macOS, obsoleted: 14.0)
@available(iOS, obsoleted: 17.0)
@available(watchOS, obsoleted: 10.0)
@available(tvOS, obsoleted: 17.0)
@available(visionOS 1.0, *)
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
return try MainActor.assumeIsolated(body)
}
dispatchPrecondition(condition: .onQueue(.main))
return try withoutActuallyEscaping(body) { fn in
try unsafeBitCast(fn, to: (() throws -> T).self)()
}
}
}
private func decodeBase64URL(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
if str.count % 4 != 0 {
str.append(String(repeating: "=", count: 4 - str.count % 4))
}
return Data(base64Encoded: str)
}
// copied from HTMLConverter.Callbacks, blergh
private struct HTMLCallbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? {
// Converting WebURL to URL is a small but non-trivial expense (since it works by
// serializing the WebURL as a string and then having Foundation parse it again),
// so, if available, use the system parser which doesn't require another round trip.
if #available(iOS 16.0, macOS 13.0, *),
let url = try? URL.ParseStrategy().parse(string) {
url
} else if let web = WebURL(string),
let url = URL(web) {
url
} else {
URL(string: string)
}
}
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
guard name == "span" else {
return .default
}
let clazz = attributes.attributeValue(for: "class")
if clazz == "invisible" {
return .skip
} else if clazz == "ellipsis" {
return .append("")
} else {
return .default
}
}
}

View File

@ -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>

View File

@ -8,7 +8,6 @@
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
class ActionViewController: UIViewController {
@ -18,29 +17,25 @@ class ActionViewController: UIViewController {
super.viewDidLoad()
findURLFromWebPage { (components) in
DispatchQueue.main.async {
if let components {
self.searchForURLInApp(components)
} else {
self.findURLItem { (components) in
if let components {
DispatchQueue.main.async {
self.searchForURLInApp(components)
}
}
if let components = components {
self.searchForURLInApp(components)
} else {
self.findURLItem { (components) in
if let components = 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 provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
guard provider.hasItemConformingToTypeIdentifier(kUTTypePropertyList as String) else {
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],
let jsResult = result[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
let urlString = jsResult["activityPubURL"] as? String ?? jsResult["url"] as? String,
@ -58,13 +53,13 @@ class ActionViewController: UIViewController {
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 provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {
guard provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) else {
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,
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
completion(nil)

View File

@ -24,8 +24,6 @@
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionServiceRoleType</key>
<string>NSExtensionServiceRoleTypeViewer</string>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>

View File

@ -1,9 +0,0 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -1,23 +0,0 @@
{
"pins" : [
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
"version" : "1.2.1"
}
},
{
"identity" : "swift-url",
"kind" : "remoteSourceControl",
"location" : "https://github.com/karwa/swift-url.git",
"state" : {
"branch" : "main",
"revision" : "6f45f3cd6606f39c3753b302fe30aea980067b30"
}
}
],
"version" : 2
}

View File

@ -1,49 +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: "ComposeUI",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "ComposeUI",
targets: ["ComposeUI"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(path: "../Pachyderm"),
.package(path: "../InstanceFeatures"),
.package(path: "../TuskerComponents"),
.package(path: "../TuskerPreferences"),
.package(path: "../UserAccounts"),
.package(path: "../GalleryVC"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "ComposeUI",
dependencies: [
"Pachyderm",
"InstanceFeatures",
"TuskerComponents",
"TuskerPreferences",
"UserAccounts",
"GalleryVC",
],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget(
name: "ComposeUITests",
dependencies: ["ComposeUI"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
]
)

View File

@ -1,3 +0,0 @@
# ComposeUI
A description of this package.

View File

@ -1,218 +0,0 @@
//
// PostService.swift
// ComposeUI
//
// Created by Shadowfacts on 4/27/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
import UniformTypeIdentifiers
@MainActor
final class PostService: ObservableObject {
private let mastodonController: any ComposeMastodonContext
private let contentType: StatusContentType
private let draft: Draft
@Published var currentStep = 1
@Published private(set) var totalSteps = 2
init(mastodonController: any ComposeMastodonContext, contentType: StatusContentType, draft: Draft) {
self.mastodonController = mastodonController
self.contentType = contentType
self.draft = draft
}
func post() async throws(Error) {
guard draft.hasContent || draft.editedStatusID != nil else {
return
}
// save before posting, so if a crash occurs during network request, the status won't be lost
DraftsPersistentContainer.shared.save()
let uploadedAttachments = try await uploadAttachments()
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : ""
let sensitive = !contentWarning.isEmpty
let request: Request<Status>
if let editedStatusID = draft.editedStatusID {
if mastodonController.instanceFeatures.needsEditAttachmentsInSeparateRequest {
await updateEditedAttachments()
}
let pollParams: EditPollParameters?
if draft.pollEnabled,
let poll = draft.poll {
pollParams = EditPollParameters(options: poll.pollOptions.map(\.text), expiresIn: Int(poll.duration), multiple: poll.multiple)
} else {
pollParams = nil
}
request = Client.editStatus(
id: editedStatusID,
text: textForPosting(),
contentType: contentType,
spoilerText: contentWarning,
sensitive: sensitive,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
mediaIDs: uploadedAttachments,
mediaAttributes: draft.draftAttachments.compactMap {
if let id = $0.editedAttachmentID {
return EditStatusMediaAttributes(id: id, description: $0.attachmentDescription, focus: nil)
} else {
return nil
}
},
poll: pollParams
)
} else {
let pollOptions: [String]?
let pollExpiresIn: Int?
let pollMultiple: Bool?
if draft.pollEnabled,
let poll = draft.poll {
pollOptions = poll.pollOptions.map(\.text)
pollExpiresIn = Int(poll.duration)
pollMultiple = poll.multiple
} else {
pollOptions = nil
pollExpiresIn = nil
pollMultiple = nil
}
request = Client.createStatus(
text: textForPosting(),
contentType: contentType,
inReplyTo: draft.inReplyToID,
mediaIDs: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.localOnly && mastodonController.instanceFeatures.localOnlyPostsVisibility ? Status.localPostVisibility : draft.visibility.rawValue,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
pollOptions: pollOptions,
pollExpiresIn: pollExpiresIn,
pollMultiple: pollMultiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts && !mastodonController.instanceFeatures.localOnlyPostsVisibility ? draft.localOnly : nil,
idempotencyKey: draft.id.uuidString
)
}
do {
let (status, _) = try await mastodonController.run(request)
currentStep += 1
mastodonController.storeCreatedStatus(status)
} catch {
throw Error.posting(error)
}
}
private func uploadAttachments() async throws(Error) -> [String] {
// 2 steps (request data, then upload) for each attachment
self.totalSteps += 2 * draft.attachments.count
var attachments: [String] = []
attachments.reserveCapacity(draft.attachments.count)
for (index, attachment) in draft.draftAttachments.enumerated() {
// if this attachment already exists and is being edited, we don't do anything
// edits to the description are handled as part of the edit status request
if let editedAttachmentID = attachment.editedAttachmentID {
attachments.append(editedAttachmentID)
currentStep += 2
continue
}
let data: Data
let utType: UTType
do {
(data, utType) = try await getData(for: attachment)
currentStep += 1
} catch {
throw Error.attachmentData(index: index, cause: error)
}
let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription)
attachments.append(uploaded.id)
currentStep += 1
}
return attachments
}
private func getData(for attachment: DraftAttachment) async throws(DraftAttachment.ExportError) -> (Data, UTType) {
let result = await withCheckedContinuation { continuation in
attachment.getData(features: mastodonController.instanceFeatures) { result in
continuation.resume(returning: result)
}
}
switch result {
case .success(let result):
return result
case .failure(let error):
throw error
}
}
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws(Error) -> Attachment {
guard let mimeType = utType.preferredMIMEType else {
throw Error.attachmentMissingMimeType(index: index, type: utType)
}
var filename = "file"
if let ext = utType.preferredFilenameExtension {
filename.append(".\(ext)")
}
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: filename)
let req = Client.upload(attachment: formAttachment, description: description)
do {
return try await mastodonController.run(req).0
} catch {
throw Error.attachmentUpload(index: index, cause: error)
}
}
private func textForPosting() -> String {
var text = draft.text
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
// which we want to strip out before actually posting the status
text = text.replacingOccurrences(of: "\u{fffc}", with: "")
if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack {
text += " 👁"
}
return text
}
// only needed for akkoma, not used on regular mastodon
private func updateEditedAttachments() async {
for attachment in draft.draftAttachments {
guard let id = attachment.editedAttachmentID else {
continue
}
let req = Client.updateAttachment(id: id, description: attachment.attachmentDescription, focus: nil)
_ = try? await mastodonController.run(req)
}
}
enum Error: Swift.Error, LocalizedError {
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
case attachmentMissingMimeType(index: Int, type: UTType)
case attachmentUpload(index: Int, cause: Client.Error)
case posting(Client.Error)
var localizedDescription: String {
switch self {
case let .attachmentData(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .attachmentMissingMimeType(index: index, type: type):
return "Attachment \(index + 1): unknown MIME type for \(type.identifier)"
case let .attachmentUpload(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .posting(error):
return error.localizedDescription
}
}
}
}

View File

@ -1,72 +0,0 @@
//
// ComposeInput.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import Foundation
import Combine
import UIKit
import SwiftUI
protocol ComposeInput: AnyObject, ObservableObject {
var toolbarElements: [ToolbarElement] { get }
var textInputMode: UITextInputMode? { get }
var autocompleteState: AutocompleteState? { get }
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { get }
func autocomplete(with string: String)
func applyFormat(_ format: StatusFormat)
func beginAutocompletingEmoji()
}
enum ToolbarElement {
case emojiPicker
case formattingButtons
}
private struct FocusedComposeInput: FocusedValueKey {
typealias Value = (any ComposeInput)?
}
extension FocusedValues {
// double optional is necessary pre-iOS 16
var composeInput: (any ComposeInput)?? {
get { self[FocusedComposeInput.self] }
set { self[FocusedComposeInput.self] = newValue }
}
}
@propertyWrapper
final class MutableObservableBox<Value>: ObservableObject {
@Published var wrappedValue: Value
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}
private struct FocusedComposeInputBox: EnvironmentKey {
static let defaultValue: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
}
extension EnvironmentValues {
var composeInputBox: MutableObservableBox<(any ComposeInput)?> {
get { self[FocusedComposeInputBox.self] }
set { self[FocusedComposeInputBox.self] = newValue }
}
}
struct FocusedInputModifier: ViewModifier {
@StateObject var box: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
func body(content: Content) -> some View {
content
.environment(\.composeInputBox, box)
.focusedValue(\.composeInput, box.wrappedValue)
}
}

View File

@ -1,32 +0,0 @@
//
// ComposeMastodonContext.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import Foundation
import Pachyderm
import InstanceFeatures
import UserAccounts
import SwiftUI
public protocol ComposeMastodonContext {
var accountInfo: UserAccountInfo? { get }
var instanceFeatures: InstanceFeatures { get }
func run<Result: Sendable>(_ request: Request<Result>) async throws(Client.Error) -> (Result, Pagination?)
func getCustomEmojis() async -> [Emoji]
@MainActor
func searchCachedAccounts(query: String) -> [AccountProtocol]
@MainActor
func cachedRelationship(for accountID: String) -> RelationshipProtocol?
@MainActor
func searchCachedHashtags(query: String) -> [Hashtag]
func storeCreatedStatus(_ status: Status)
func fetchStatus(id: String) -> (any StatusProtocol)?
}

View File

@ -1,51 +0,0 @@
//
// ComposeUIConfig.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Pachyderm
import PhotosUI
import PencilKit
import TuskerComponents
import GalleryVC
// Configuration/data injected from outside the compose UI.
public struct ComposeUIConfig {
// Config
public var allowSwitchingDrafts = true
public var textSelectionStartsAtBeginning = false
public var showToolbar = true
// Style
public var backgroundColor = Color(uiColor: .systemBackground)
public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground)
public var groupedCellBackgroundColor = Color(uiColor: .systemBackground)
public var fillColor = Color(uiColor: .systemFill)
// Host callbacks
public var dismiss: @MainActor (DismissMode) -> Void = { _ in }
public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)?
public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil }
public var fetchAvatar: AvatarImageView.FetchAvatar = { _ in nil }
public var displayNameLabel: (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView = { _, _, _ in AnyView(EmptyView()) }
public var replyContentView: (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView = { _, _ in AnyView(EmptyView()) }
public var fetchImageAndGIFData: (URL) async -> (UIImage, Data)? = { _ in nil }
public var makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)? = { _ in nil }
public init() {
}
}
private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
static let defaultValue = ComposeUIConfig()
}
extension EnvironmentValues {
var composeUIConfig: ComposeUIConfig {
get { self[ComposeUIConfigEnvironmentKey.self] }
set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
}
}

View File

@ -1,88 +0,0 @@
//
// Draft.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
import Pachyderm
@objc
public class Draft: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Draft> {
return NSFetchRequest<Draft>(entityName: "Draft")
}
@nonobjc public class func fetchRequest(id: UUID) -> NSFetchRequest<Draft> {
let req = NSFetchRequest<Draft>(entityName: "Draft")
req.predicate = NSPredicate(format: "id = %@", id as NSUUID)
return req
}
@NSManaged public var accountID: String
@NSManaged public var contentWarning: String
@NSManaged public var contentWarningEnabled: Bool
@NSManaged public var editedStatusID: String?
@NSManaged public var id: UUID
@NSManaged public var initialContentWarning: String?
@NSManaged public var initialText: String
@NSManaged public var inReplyToID: String?
@NSManaged public var language: String? // ISO 639 language code
@NSManaged public var lastModified: Date!
@NSManaged public var localOnly: Bool
@NSManaged private var pollEnabledInternal: NSNumber?
@NSManaged public var text: String
@NSManaged private var visibilityStr: String
@NSManaged internal var attachments: NSMutableOrderedSet
@NSManaged public var poll: Poll?
public var pollEnabled: Bool {
get { pollEnabledInternal.map(\.boolValue) ?? (poll != nil) }
set { pollEnabledInternal = NSNumber(booleanLiteral: newValue) }
}
public var visibility: Visibility {
get {
Visibility(rawValue: visibilityStr) ?? .public
}
set {
visibilityStr = newValue.rawValue
}
}
public var draftAttachments: [DraftAttachment] {
get {
attachments.array as! [DraftAttachment]
}
set {
attachments = NSMutableOrderedSet(array: newValue)
}
}
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
lastModified = Date()
}
public func addAttachment(_ attachment: DraftAttachment) {
attachments.add(attachment)
}
}
extension Draft {
var hasText: Bool {
!text.isEmpty && text != initialText
}
var hasContentWarning: Bool {
contentWarningEnabled && contentWarning != initialContentWarning
}
public var hasContent: Bool {
hasText || hasContentWarning || attachments.count > 0 || (pollEnabled && poll!.hasContent)
}
}

View File

@ -1,341 +0,0 @@
//
// DraftAttachment.swift
// CoreData
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
import PencilKit
import UniformTypeIdentifiers
import Photos
import InstanceFeatures
import Pachyderm
private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder()
@objc
public final class DraftAttachment: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<DraftAttachment> {
return NSFetchRequest<DraftAttachment>(entityName: "DraftAttachment")
}
@NSManaged internal var assetID: String?
@NSManaged public var attachmentDescription: String
@NSManaged internal private(set) var drawingData: Data?
@NSManaged public var editedAttachmentID: String?
@NSManaged private var editedAttachmentKindString: String?
@NSManaged public var editedAttachmentURL: URL?
@NSManaged public var fileURL: URL?
@NSManaged internal var fileType: String?
@NSManaged public var id: UUID!
@NSManaged public var draft: Draft
public var drawing: PKDrawing? {
get {
if let drawingData,
let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) {
return drawing
} else {
return nil
}
}
set {
drawingData = try! encoder.encode(newValue)
}
}
public var data: AttachmentData {
if let editedAttachmentID {
return .editing(editedAttachmentID, editedAttachmentKind!, editedAttachmentURL!)
} else if let assetID {
return .asset(assetID)
} else if let drawing {
return .drawing(drawing)
} else if let fileURL, let fileType {
return .file(fileURL, UTType(fileType)!)
} else {
return .none
}
}
public var editedAttachmentKind: Attachment.Kind? {
get {
editedAttachmentKindString.flatMap(Attachment.Kind.init(rawValue:))
}
set {
editedAttachmentKindString = newValue?.rawValue
}
}
public enum AttachmentData {
case asset(String)
case drawing(PKDrawing)
case file(URL, UTType)
case editing(String, Attachment.Kind, URL)
case none
}
public override func prepareForDeletion() {
super.prepareForDeletion()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
}
}
extension DraftAttachment {
public var type: AttachmentType {
if let editedAttachmentKind {
switch editedAttachmentKind {
case .image:
return .image
case .video:
return .video
case .gifv:
return .video
case .audio, .unknown:
return .unknown
}
} else if let assetID {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
return .unknown
}
switch asset.mediaType {
case .image:
return .image
case .video:
return .video
default:
return .unknown
}
} else if drawingData != nil {
return .image
} else if let fileType,
let type = UTType(fileType) {
if type.conforms(to: .image) {
return .image
} else if type.conforms(to: .movie) {
return .video
} else {
return .unknown
}
} else {
return .unknown
}
}
public enum AttachmentType {
case image, video, unknown
}
}
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
private let imageType = UTType.image.identifier
private let heifType = UTType.heif.identifier
private let heicType = UTType.heic.identifier
private let jpegType = UTType.jpeg.identifier
private let pngType = UTType.png.identifier
private let mp4Type = UTType.mpeg4Movie.identifier
private let quickTimeType = UTType.quickTimeMovie.identifier
private let gifType = UTType.gif.identifier
extension DraftAttachment: NSItemProviderReading {
public static var readableTypeIdentifiersForItemProvider: [String] {
// todo: is there a better way of handling movies than manually adding all possible UTI types?
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
[/*typeIdentifier, */ gifType, heifType, heicType, jpegType, pngType, imageType, mp4Type, quickTimeType]
}
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
var data = data
var type = UTType(typeIdentifier)!
// the type is .image in certain circumstances:
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
if type == .image,
let image = UIImage(data: data) ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)),
let pngData = image.pngData() {
data = pngData
type = .png
}
// Read the caption from the image itself, if there is one.
let caption: String
if let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceTypeIdentifierHint: typeIdentifier as CFString] as CFDictionary),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any],
// This is the dictionary for TIFF properties, but it's present for other image types too
let tiffProperties = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any],
let imageDescription = tiffProperties[kCGImagePropertyTIFFImageDescription as String] as? String {
caption = imageDescription
} else {
caption = ""
}
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
attachment.id = UUID()
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
attachment.fileType = type.identifier
attachment.attachmentDescription = caption
return attachment
}
static var attachmentsDirectory: URL {
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
return containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments")
}
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL {
let directoryURL = attachmentsDirectory
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type)
try data.write(to: attachmentURL)
return attachmentURL
}
}
// MARK: Exporting
extension DraftAttachment {
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
if let assetID {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
completion(.failure(.noAsset))
return
}
if asset.mediaType == .image {
let options = PHImageRequestOptions()
options.version = .current
options.deliveryMode = .highQualityFormat
options.resizeMode = .none
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, dataUTI, orientation, info in
guard let data, let dataUTI else {
completion(.failure(.missingAssetData))
return
}
let processed = Self.processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
completion(.success(processed))
}
} else if asset.mediaType == .video {
let options = PHVideoRequestOptions()
options.version = .current
options.deliveryMode = .automatic
options.isNetworkAccessAllowed = true
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
if let exportSession {
Self.exportVideoData(session: exportSession, features: features, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error)))
} else {
completion(.failure(.noVideoExportSession))
}
}
} else {
completion(.failure(.unknownAssetType))
}
} else if let drawingData {
guard let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) else {
completion(.failure(.loadingDrawing))
return
}
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(.success((image.pngData()!, .png)))
} else if let fileURL, let fileType {
let type = UTType(fileType)!
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: fileURL)
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
completion(.failure(.noVideoExportSession))
return
}
Self.exportVideoData(session: session, features: features, completion: completion)
} else {
let fileData: Data
do {
fileData = try Data(contentsOf: fileURL)
} catch {
completion(.failure(.loadingData))
return
}
if type != .gif,
type.conforms(to: .image) {
let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion)
completion(.success(result))
} else {
completion(.success((fileData, type)))
}
}
} else {
completion(.failure(.noData))
}
}
private static func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
guard !skipAllConversion else {
return (data, type)
}
var data = data
var type = type
let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic || type == .heif {
let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png {
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
} else {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
type = .jpeg
}
}
return (data, type)
}
private static func exportVideoData(session: AVAssetExportSession, features: InstanceFeatures, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
if let configuration = features.mediaAttachmentsConfiguration {
session.fileLengthLimit = Int64(configuration.videoSizeLimit)
}
session.exportAsynchronously {
guard session.status == .completed else {
completion(.failure(.videoExport(session.error!)))
return
}
do {
let data = try Data(contentsOf: session.outputURL!)
completion(.success((data, .mpeg4Movie)))
} catch {
completion(.failure(.videoExport(error)))
}
}
}
enum ExportError: Error {
case noAsset
case unknownAssetType
case missingAssetData
case videoExport(Error)
case noVideoExportSession
case loadingDrawing
case loadingData
case noData
}
}

View File

@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24A335" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
<attribute name="accountID" attributeType="String"/>
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="initialContentWarning" optional="YES" attributeType="String"/>
<attribute name="initialText" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="pollEnabledInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="text" attributeType="String" defaultValueString=""/>
<attribute name="visibilityStr" optional="YES" attributeType="String"/>
<relationship name="attachments" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="DraftAttachment" inverseName="draft" inverseEntity="DraftAttachment"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="draft" inverseEntity="Poll"/>
</entity>
<entity name="DraftAttachment" representedClassName="ComposeUI.DraftAttachment" syncable="YES">
<attribute name="assetID" optional="YES" attributeType="String"/>
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
<attribute name="drawingData" optional="YES" attributeType="Binary"/>
<attribute name="editedAttachmentID" optional="YES" attributeType="String"/>
<attribute name="editedAttachmentKindString" optional="YES" attributeType="String"/>
<attribute name="editedAttachmentURL" optional="YES" attributeType="URI"/>
<attribute name="fileType" optional="YES" attributeType="String"/>
<attribute name="fileURL" optional="YES" attributeType="URI"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="attachments" inverseEntity="Draft"/>
</entity>
<entity name="Poll" representedClassName="ComposeUI.Poll" syncable="YES">
<attribute name="duration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="poll" inverseEntity="Draft"/>
<relationship name="options" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
</entity>
<entity name="PollOption" representedClassName="ComposeUI.PollOption" syncable="YES">
<attribute name="text" attributeType="String" defaultValueString=""/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
</entity>
</model>

View File

@ -1,226 +0,0 @@
//
// DraftsPersistentContainer.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import Foundation
import CoreData
import OSLog
import Pachyderm
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer")
public final class DraftsPersistentContainer: NSPersistentContainer {
public static let shared = DraftsPersistentContainer()
public static var captureError: ((any Error) -> Void)?
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
private var lastHistoryToken: NSPersistentHistoryToken!
init() {
super.init(name: "Drafts", managedObjectModel: DraftsPersistentContainer.managedObjectModel)
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
let documentsURL = containerURL.appendingPathComponent("Documents")
let storeDesc = NSPersistentStoreDescription(url: documentsURL.appendingPathComponent("drafts").appendingPathExtension("sqlite"))
storeDesc.type = NSSQLiteStoreType
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
persistentStoreDescriptions = [
storeDesc
]
loadPersistentStores { _, error in
if let error {
DraftsPersistentContainer.captureError?(error)
fatalError("Loading persistent store: \(error)")
}
}
viewContext.automaticallyMergesChangesFromParent = true
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
lastHistoryToken = persistentStoreCoordinator.currentPersistentHistoryToken(fromStores: nil)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges(_:)), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
}
public func save() {
guard viewContext.hasChanges else {
return
}
do {
try viewContext.save()
} catch {
logger.error("Failed to save: \(String(describing: error))")
}
}
public func migrate(from url: URL, completion: @escaping (Result<(), any Error>) -> Void) {
performBackgroundTask { context in
let result = DraftsMigrator.migrate(from: url, to: context)
completion(result)
try! context.save()
}
}
public func getDraft(id: UUID) -> Draft? {
let req = Draft.fetchRequest(id: id)
return try? viewContext.fetch(req).first
}
public func createDraft(
accountID: String,
text: String,
contentWarning: String,
inReplyToID: String?,
visibility: Visibility,
language: String?,
localOnly: Bool
) -> Draft {
let draft = Draft(context: viewContext)
draft.accountID = accountID
draft.text = text
draft.initialText = text
draft.contentWarning = contentWarning
draft.initialContentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty
draft.inReplyToID = inReplyToID
draft.visibility = visibility
draft.language = language
draft.localOnly = localOnly
save()
return draft
}
public func createEditDraft(
accountID: String,
source: StatusSource,
inReplyToID: String?,
visibility: Visibility,
localOnly: Bool,
attachments: [Attachment],
poll: Pachyderm.Poll?
) -> Draft {
let draft = Draft(context: viewContext)
draft.accountID = accountID
draft.editedStatusID = source.id
draft.text = source.text
draft.initialText = source.text
draft.contentWarning = source.spoilerText
draft.contentWarningEnabled = !source.spoilerText.isEmpty
draft.initialContentWarning = source.spoilerText
draft.inReplyToID = inReplyToID
draft.visibility = visibility
draft.localOnly = localOnly
for attachment in attachments {
createEditDraftAttachment(attachment, in: draft)
}
if let existingPoll = poll {
let poll = Poll(context: viewContext)
poll.draft = draft
draft.poll = poll
if let expiresAt = existingPoll.expiresAt,
!existingPoll.effectiveExpired {
poll.duration = PollDuration.allCases.max(by: {
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
})!.timeInterval
} else {
poll.duration = PollDuration.oneDay.timeInterval
}
poll.multiple = existingPoll.multiple
// rmeove default empty options
for opt in poll.pollOptions {
viewContext.delete(opt)
}
for existingOpt in existingPoll.options {
let opt = PollOption(context: viewContext)
opt.poll = poll
poll.options.add(opt)
opt.text = existingOpt.title
}
}
save()
return draft
}
private func createEditDraftAttachment(_ attachment: Attachment, in draft: Draft) {
let draftAttachment = DraftAttachment(context: viewContext)
draftAttachment.id = UUID()
draftAttachment.attachmentDescription = attachment.description ?? ""
draftAttachment.editedAttachmentID = attachment.id
draftAttachment.editedAttachmentKind = attachment.kind
draftAttachment.editedAttachmentURL = attachment.url
draftAttachment.draft = draft
draft.attachments.add(draftAttachment)
}
public func removeOrphanedAttachments(completion: @escaping () -> Void) {
guard let files = try? FileManager.default.contentsOfDirectory(at: DraftAttachment.attachmentsDirectory, includingPropertiesForKeys: nil),
!files.isEmpty else {
return
}
performBackgroundTask { context in
let orphanedAttachmentsReq: NSFetchRequest<any NSFetchRequestResult> = DraftAttachment.fetchRequest()
orphanedAttachmentsReq.predicate = NSPredicate(format: "draft == nil")
let deleteReq = NSBatchDeleteRequest(fetchRequest: orphanedAttachmentsReq)
do {
try context.execute(deleteReq)
} catch {
logger.error("Failed to remove orphaned attachments: \(String(describing: error), privacy: .public)")
}
let allAttachmentsReq = DraftAttachment.fetchRequest()
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
return
}
let orphanedFiles = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
for url in orphanedFiles {
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to remove orphaned attachment files: \(String(describing: error), privacy: .public)")
}
}
completion()
}
}
@objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return
}
// todo: should this be on a background context?
let context = viewContext
context.perform {
let predicate = NSPredicate(format: "(%@ < token) AND (token <= %@)", self.lastHistoryToken, newHistoryToken)
let historyRequest = NSPersistentHistoryTransaction.fetchRequest!
historyRequest.predicate = predicate
let request = NSPersistentHistoryChangeRequest.fetchHistory(withFetch: historyRequest)
if let result = try? context.execute(request) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction] {
for transaction in transactions {
guard let userInfo = transaction.objectIDNotification().userInfo else {
continue
}
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context])
}
}
self.lastHistoryToken = newHistoryToken
}
}
}

View File

@ -1,46 +0,0 @@
//
// Poll.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
@objc
public class Poll: NSManagedObject {
@NSManaged public var duration: TimeInterval
@NSManaged public var multiple: Bool
@NSManaged public var draft: Draft
@NSManaged public var options: NSMutableOrderedSet
public var pollOptions: [PollOption] {
get {
options.array as! [PollOption]
}
set {
options = NSMutableOrderedSet(array: newValue)
}
}
public override func awakeFromInsert() {
super.awakeFromInsert()
self.multiple = false
self.duration = 24 * 60 * 60 // 1 day
if let managedObjectContext {
self.options = [
PollOption(context: managedObjectContext),
PollOption(context: managedObjectContext),
]
}
}
}
extension Poll {
public var hasContent: Bool {
pollOptions.contains { !$0.text.isEmpty }
}
}

View File

@ -1,21 +0,0 @@
//
// PollOption.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
@objc
public class PollOption: NSManagedObject, Identifiable {
public var id: NSManagedObjectID {
objectID
}
@NSManaged public var text: String
@NSManaged public var poll: Poll
}

View File

@ -1,255 +0,0 @@
//
// DraftsMigrator.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import Foundation
import OSLog
import UniformTypeIdentifiers
import Pachyderm
import PencilKit
import CoreData
struct DraftsMigrator {
private init() {}
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsMigrator")
private static let decoder = PropertyListDecoder()
static func migrate(from url: URL, to context: NSManagedObjectContext) -> Result<(), any Error> {
do {
let data = try Data(contentsOf: url)
let container = try decoder.decode(DraftsContainer.self, from: data)
for old in container.drafts.values {
let new = Draft(context: context)
new.id = old.id
new.lastModified = old.lastModified
new.accountID = old.accountID
new.text = old.text
new.contentWarningEnabled = old.contentWarningEnabled
new.contentWarning = old.contentWarning
new.inReplyToID = old.inReplyToID
new.visibility = old.visibility
new.localOnly = old.localOnly
new.initialText = old.initialText
if let oldPoll = old.poll {
let newPoll = Poll(context: context)
newPoll.draft = new
new.poll = newPoll
newPoll.multiple = oldPoll.multiple
newPoll.duration = oldPoll.duration
for oldOption in oldPoll.options {
let newOption = PollOption(context: context)
newOption.text = oldOption.text
newOption.poll = newPoll
newPoll.options.add(newOption)
}
}
for oldAttachment in old.attachments {
let newAttachment = DraftAttachment(context: context)
newAttachment.draft = new
new.attachments.add(newAttachment)
newAttachment.id = oldAttachment.id
newAttachment.attachmentDescription = oldAttachment.attachmentDescription
switch oldAttachment.data {
case .asset(let assetID):
newAttachment.assetID = assetID
case .image(let data, originalType: let type):
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: type)
newAttachment.fileType = type.identifier
case .video(_):
fatalError("unreachable, video attachments weren't encodable")
case .drawing(let drawing):
newAttachment.drawing = drawing
case .gif(let data):
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: .gif)
newAttachment.fileType = UTType.gif.identifier
}
}
}
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Error migrating: \(String(describing: error))")
return .failure(error)
}
return .success(())
}
// MARK: Supporting Types
struct DraftsContainer: Decodable {
let drafts: [UUID: OldDraft]
init(drafts: [UUID: OldDraft]) {
self.drafts = drafts
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft)
}
enum CodingKeys: CodingKey {
case drafts
}
}
// a container that always succeeds at decoding
// so if a single draft can't be decoded, we don't lose all drafts
struct SafeDraft: Decodable {
let draft: OldDraft?
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.draft = try? container.decode(OldDraft.self)
}
}
struct OldDraft: Decodable {
let id: UUID
let lastModified: Date
let accountID: String
let text: String
let contentWarningEnabled: Bool
let contentWarning: String
let attachments: [OldDraftAttachment]
let inReplyToID: String?
let visibility: Visibility
let poll: OldPoll?
let localOnly: Bool
let initialText: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
self.accountID = try container.decode(String.self, forKey: .accountID)
self.text = try container.decode(String.self, forKey: .text)
self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled)
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
self.attachments = try container.decode([OldDraftAttachment].self, forKey: .attachments)
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
self.visibility = try container.decode(Visibility.self, forKey: .visibility)
self.poll = try container.decode(OldPoll?.self, forKey: .poll)
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
self.initialText = try container.decode(String.self, forKey: .initialText)
}
enum CodingKeys: String, CodingKey {
case id
case lastModified
case accountID
case text
case contentWarningEnabled
case contentWarning
case attachments
case inReplyToID
case visibility
case poll
case localOnly
case initialText
}
}
struct OldDraftAttachment: Decodable {
let id: UUID
let data: OldDraftAttachmentData
let attachmentDescription: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.data = try container.decode(OldDraftAttachmentData.self, forKey: .data)
self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription)
}
enum CodingKeys: String, CodingKey {
case id
case data
case attachmentDescription
}
}
enum OldDraftAttachmentData: Decodable {
case asset(String)
case image(Data, originalType: UTType)
case video(URL)
case drawing(PKDrawing)
case gif(Data)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(String.self, forKey: .type) {
case "asset":
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
self = .asset(identifier)
case "image":
let data = try container.decode(Data.self, forKey: .imageData)
if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) {
self = .image(data, originalType: type)
} else {
guard let image = UIImage(data: data) else {
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage")
}
let jpegData = image.jpegData(compressionQuality: 1)!
self = .image(jpegData, originalType: .jpeg)
}
case "drawing":
let drawingData = try container.decode(Data.self, forKey: .drawing)
let drawing = try PKDrawing(data: drawingData)
self = .drawing(drawing)
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
}
}
enum CodingKeys: CodingKey {
case type
case imageData
case imageType
/// The local identifier of the PHAsset for this attachment
case assetIdentifier
/// The PKDrawing object for this attachment.
case drawing
}
}
struct OldPoll: Decodable {
let options: [OldPollOption]
let multiple: Bool
let duration: TimeInterval
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.options = try container.decode([OldPollOption].self, forKey: .options)
self.multiple = try container.decode(Bool.self, forKey: .multiple)
self.duration = try container.decode(TimeInterval.self, forKey: .duration)
}
enum CodingKeys: String, CodingKey {
case options
case multiple
case duration
}
}
struct OldPollOption: Decodable {
let text: String
init(from decoder: Decoder) throws {
self.text = try decoder.singleValueContainer().decode(String.self)
}
}
}

View File

@ -1,49 +0,0 @@
//
// Environment.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
import Pachyderm
//@propertyWrapper
//struct RequiredEnvironment<Value>: DynamicProperty {
// private let keyPath: KeyPath<EnvironmentValues, Value?>
// @Environment private var value: Value?
//
// init(_ keyPath: KeyPath<EnvironmentValues, Value?>) {
// self.keyPath = keyPath
// self._value = Environment(keyPath)
// }
//
// var wrappedValue: Value {
// guard let value else {
// preconditionFailure("Missing required environment value for \(keyPath)")
// }
// return value
// }
//}
private struct ComposeMastodonContextKey: EnvironmentKey {
static let defaultValue: (any ComposeMastodonContext)? = nil
}
extension EnvironmentValues {
var mastodonController: (any ComposeMastodonContext)? {
get { self[ComposeMastodonContextKey.self] }
set { self[ComposeMastodonContextKey.self] = newValue }
}
}
private struct CurrentAccountKey: EnvironmentKey {
static let defaultValue: (any AccountProtocol)? = nil
}
extension EnvironmentValues {
var currentAccount: (any AccountProtocol)? {
get { self[CurrentAccountKey.self] }
set { self[CurrentAccountKey.self] = newValue }
}
}

View File

@ -1,40 +0,0 @@
//
// KeyboardReader.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
#if !os(visionOS)
import UIKit
import Combine
@available(iOS, obsoleted: 16.0)
class KeyboardReader: ObservableObject {
@Published var keyboardHeight: CGFloat = 0
var isVisible: Bool {
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
keyboardHeight > 72
}
init() {
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc func willShow(_ notification: Foundation.Notification) {
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
keyboardHeight = endFrame.height
}
@objc func willHide() {
// sometimes willHide is called during a SwiftUI view update
DispatchQueue.main.async {
self.keyboardHeight = 0
}
}
}
#endif

View File

@ -1,12 +0,0 @@
//
// DismissMode.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
import Foundation
public enum DismissMode {
case cancel, post
}

View File

@ -1,47 +0,0 @@
//
// PollDuration.swift
// ComposeUI
//
// Created by Shadowfacts on 2/7/25.
//
import Foundation
enum PollDuration: Hashable, Equatable, CaseIterable {
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.maximumUnitCount = 1
f.unitsStyle = .full
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
return f
}()
static func fromTimeInterval(_ ti: TimeInterval) -> PollDuration? {
for it in allCases where it.timeInterval == ti {
return it
}
return nil
}
var timeInterval: TimeInterval {
switch self {
case .fiveMinutes:
return 5 * 60
case .thirtyMinutes:
return 30 * 60
case .oneHour:
return 60 * 60
case .sixHours:
return 6 * 60 * 60
case .oneDay:
return 24 * 60 * 60
case .threeDays:
return 3 * 24 * 60 * 60
case .sevenDays:
return 7 * 24 * 60 * 60
}
}
}

View File

@ -1,36 +0,0 @@
//
// OptionalObservedObject.swift
// ComposeUI
//
// Created by Shadowfacts on 4/15/23.
//
import SwiftUI
import Combine
@propertyWrapper
struct OptionalObservedObject<T: ObservableObject>: DynamicProperty {
private class Republisher: ObservableObject {
var cancellable: AnyCancellable?
var wrapped: T? {
didSet {
cancellable?.cancel()
cancellable = wrapped?.objectWillChange
.sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}
@StateObject private var republisher = Republisher()
var wrappedValue: T?
init(wrappedValue: T?) {
self.wrappedValue = wrappedValue
}
func update() {
republisher.wrapped = wrappedValue
}
}

View File

@ -1,41 +0,0 @@
//
// PlaceholderController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/6/23.
//
import SwiftUI
struct PlaceholderController: PlaceholderViewProvider {
static func makePlaceholderView() -> some View {
let components = Calendar.current.dateComponents([.month, .day], from: Date())
if components.month == 3 && components.day == 14,
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
Text("Happy π day!")
} else if components.month == 4 && components.day == 1 {
Text("April Fools!").rotationEffect(.radians(.pi), anchor: .center)
} else if components.month == 9 && components.day == 5 {
// https://weirder.earth/@noracodes/109276419847254552
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
} else if components.month == 9 && components.day == 21 {
Text("Do you remember?")
} else if components.month == 10 && components.day == 31 {
if .random() {
Text("Post something spooky!")
} else {
Text("Any questions?")
}
} else {
Text("Whats on your mind?")
}
}
}
// exists to provide access to the type alias since the @State property needs it to be explicit
protocol PlaceholderViewProvider {
associatedtype PlaceholderView: View
@ViewBuilder
static func makePlaceholderView() -> PlaceholderView
}

View File

@ -1,11 +0,0 @@
//
// Preferences.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import Foundation
import TuskerPreferences
typealias Preferences = TuskerPreferences.Preferences

View File

@ -1,183 +0,0 @@
//
// UITextInput+Autocomplete.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import UIKit
import SwiftUI
extension UITextInput {
func autocomplete(with string: String, permittedModes: AutocompleteModes, autocompleteState: inout AutocompleteState?) {
guard let selectedTextRange,
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
let text = self.text(in: wholeDocumentRange),
let (lastWordStartIndex, _) = findAutocompleteLastWord() else {
return
}
let distanceToEnd = self.offset(from: selectedTextRange.start, to: self.endOfDocument)
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
let insertSpace: Bool
if distanceToEnd > 0 {
let charAfterCursor = text[characterBeforeCursorIndex]
insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
} else {
insertSpace = true
}
let string = insertSpace ? string + " " : string
let startPosition = self.position(from: self.beginningOfDocument, offset: text.utf16.distance(from: text.startIndex, to: lastWordStartIndex))!
let lastWordRange = self.textRange(from: startPosition, to: selectedTextRange.start)!
replace(lastWordRange, withText: string)
autocompleteState = updateAutocompleteState(permittedModes: permittedModes)
// keep the cursor at the same position in the text, immediately after what was inserted
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
let insertSpaceOffset = insertSpace ? 0 : 1
let newCursorPosition = self.position(from: self.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)!
self.selectedTextRange = self.textRange(from: newCursorPosition, to: newCursorPosition)
}
func updateAutocompleteState(permittedModes: AutocompleteModes) -> AutocompleteState? {
guard let selectedTextRange,
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
let text = self.text(in: wholeDocumentRange),
!text.isEmpty,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
return nil
}
let triggerChars = permittedModes.triggerChars
if lastWordStartIndex > text.startIndex {
// if the character before the "word" beginning is a valid part of a "word",
// we aren't able to autocomplete
let c = text[text.index(before: lastWordStartIndex)]
if isPermittedForAutocomplete(c) || triggerChars.contains(c) {
return nil
}
}
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: self.offset(from: self.beginningOfDocument, to: selectedTextRange.start))
if lastWordStartIndex >= text.startIndex {
let lastWord = text[lastWordStartIndex..<characterBeforeCursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
// periods are only allowed in mentions in the domain part
if lastWord.contains(".") {
if lastWord.first == "@" && foundFirstAtSign && permittedModes.contains(.mentions) {
return .mention(String(exceptFirst))
} else {
return nil
}
}
switch lastWord.first {
case "@" where permittedModes.contains(.mentions):
return .mention(String(exceptFirst))
case ":" where permittedModes.contains(.emojis):
return .emoji(String(exceptFirst))
case "#" where permittedModes.contains(.hashtags):
return .hashtag(String(exceptFirst))
default:
return nil
}
} else {
return nil
}
}
private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? {
guard (self as? UIView)?.isFirstResponder == true,
let selectedTextRange,
selectedTextRange.isEmpty,
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
let text = self.text(in: wholeDocumentRange),
!text.isEmpty else {
return nil
}
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
guard cursorIndex != text.startIndex else {
return nil
}
var lastWordStartIndex = text.index(before: cursorIndex)
var foundFirstAtSign = false
while true {
let c = text[lastWordStartIndex]
if !isPermittedForAutocomplete(c) {
if foundFirstAtSign {
if c != "@" {
// move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it
lastWordStartIndex = text.index(after: lastWordStartIndex)
}
break
} else {
if c == "@" {
foundFirstAtSign = true
} else if c != "." {
// periods are allowed for domain names in mentions
break
}
}
}
guard lastWordStartIndex > text.startIndex else {
break
}
lastWordStartIndex = text.index(before: lastWordStartIndex)
}
return (lastWordStartIndex, foundFirstAtSign)
}
}
enum AutocompleteState: Equatable {
case mention(String)
case emoji(String)
case hashtag(String)
}
struct AutocompleteModes: OptionSet {
static let mentions = AutocompleteModes(rawValue: 1 << 0)
static let hashtags = AutocompleteModes(rawValue: 1 << 2)
static let emojis = AutocompleteModes(rawValue: 1 << 3)
static let all: AutocompleteModes = [
.mentions,
.hashtags,
.emojis,
]
let rawValue: Int
var triggerChars: [Character] {
var chars: [Character] = []
if contains(.mentions) {
chars.append("@")
}
if contains(.hashtags) {
chars.append("#")
}
if contains(.emojis) {
chars.append(":")
}
return chars
}
}
private func isPermittedForAutocomplete(_ c: Character) -> Bool {
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
}

View File

@ -1,55 +0,0 @@
//
// View+ForwardsCompat.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
extension View {
#if os(visionOS)
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
self.scrollDisabled(disabled)
}
#else
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
if #available(iOS 16.0, *) {
self.scrollDisabled(disabled)
} else {
self
}
}
#endif
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
#if os(visionOS)
self.scrollDismissesKeyboard(.interactively)
#else
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
}
#endif
}
@available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *)
@ViewBuilder
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
#if os(visionOS)
self.contextMenu(menuItems: menuItems, preview: preview)
#else
if #available(iOS 16.0, *) {
self.contextMenu(menuItems: menuItems, preview: preview)
} else {
self.contextMenu(menuItems: menuItems)
}
#endif
}
}

View File

@ -1,30 +0,0 @@
//
// ViewController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Combine
public protocol ViewController: ObservableObject {
associatedtype ContentView: View
@MainActor
@ViewBuilder
var view: ContentView { get }
}
public struct ControllerView<Controller: ViewController>: View {
@StateObject private var controller: Controller
public init(controller: @escaping () -> Controller) {
self._controller = StateObject(wrappedValue: controller())
}
public var body: some View {
controller.view
.environmentObject(controller)
}
}

View File

@ -1,151 +0,0 @@
//
// AttachmentDescriptionTextView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/12/23.
//
import SwiftUI
private var placeholder: some View {
Text("Describe for the visually impaired…")
}
struct InlineAttachmentDescriptionView: View {
@ObservedObject private var attachment: DraftAttachment
private let minHeight: CGFloat
@State private var height: CGFloat?
init(attachment: DraftAttachment, minHeight: CGFloat) {
self.attachment = attachment
self.minHeight = minHeight
}
private var placeholderOffset: CGSize {
#if os(visionOS)
CGSize(width: 8, height: 8)
#else
CGSize(width: 4, height: 8)
#endif
}
var body: some View {
ZStack(alignment: .topLeading) {
if attachment.attachmentDescription.isEmpty {
placeholder
.font(.body)
.foregroundColor(.secondary)
.offset(placeholderOffset)
}
WrappedTextView(
text: $attachment.attachmentDescription,
backgroundColor: .clear,
textDidChange: self.textDidChange
)
.frame(height: height ?? minHeight)
}
}
private func textDidChange(_ textView: UITextView) {
height = max(minHeight, textView.contentSize.height)
}
}
struct FocusedAttachmentDescriptionView: View {
@ObservedObject var attachment: DraftAttachment
var body: some View {
ZStack(alignment: .topLeading) {
WrappedTextView(
text: $attachment.attachmentDescription,
backgroundColor: .secondarySystemBackground,
textDidChange: nil
)
.edgesIgnoringSafeArea([.bottom, .leading, .trailing])
if attachment.attachmentDescription.isEmpty {
placeholder
.font(.body)
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
.allowsHitTesting(false)
}
}
}
}
private struct WrappedTextView: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
let backgroundColor: UIColor
let textDidChange: (((UITextView) -> Void))?
@Environment(\.isEnabled) private var isEnabled
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.delegate = context.coordinator
view.backgroundColor = backgroundColor
view.font = .preferredFont(forTextStyle: .body)
view.adjustsFontForContentSizeCategory = true
view.textContainer.lineBreakMode = .byWordWrapping
#if os(visionOS)
view.borderStyle = .roundedRect
view.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
#endif
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
uiView.isEditable = isEnabled
context.coordinator.textView = uiView
context.coordinator.text = $text
context.coordinator.didChange = textDidChange
if let textDidChange {
// wait until the next runloop iteration so that SwiftUI view updates have finished and
// the text view knows its new content size
DispatchQueue.main.async {
textDidChange(uiView)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text, didChange: textDidChange)
}
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
weak var textView: UITextView?
var text: Binding<String>
var didChange: ((UITextView) -> Void)?
var caretScrollPositionAnimator: UIViewPropertyAnimator?
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
self.text = text
self.didChange = didChange
super.init()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
}
@objc private func keyboardDidShow() {
guard let textView,
textView.isFirstResponder else {
return
}
ensureCursorVisible(textView: textView)
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
didChange?(textView)
ensureCursorVisible(textView: textView)
}
}
}

View File

@ -1,175 +0,0 @@
//
// AttachmentThumbnailView.swift
// ComposeUI
//
// Created by Shadowfacts on 10/14/24.
//
import SwiftUI
import TuskerComponents
import AVFoundation
import Photos
struct AttachmentThumbnailView: View {
let attachment: DraftAttachment
var contentMode: ContentMode = .fit
var thumbnailSize: CGSize?
var body: some View {
AttachmentThumbnailViewContent(
attachment: attachment,
contentMode: contentMode,
thumbnailSize: thumbnailSize
)
.id(attachment.id)
}
}
private struct AttachmentThumbnailViewContent: View {
var attachment: DraftAttachment
var contentMode: ContentMode = .fit
var thumbnailSize: CGSize?
@State private var mode: Mode = .empty
@Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData
var body: some View {
switch mode {
case .empty:
Image(systemName: "photo")
.imageScale(.large)
.foregroundStyle(.gray)
.task {
await loadThumbnail()
}
case .image(let image):
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: contentMode)
.task(id: attachment.drawingData) {
await loadThumbnail()
}
case .gifController(let controller):
GIFViewWrapper(controller: controller)
}
}
private func loadThumbnail() async {
switch attachment.data {
case .editing(_, let kind, let url):
switch kind {
case .image:
if let (image, _) = await fetchImageAndGIFData(url) {
self.mode = .image(image)
}
case .video, .gifv:
await loadVideoThumbnail(url: url)
case .audio, .unknown:
break
}
case .asset(let id):
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return
}
let isGIF = PHAssetResource.assetResources(for: asset).contains {
$0.uniformTypeIdentifier == UTType.gif.identifier
}
if isGIF {
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
guard let data else { return }
if typeIdentifier == UTType.gif.identifier {
self.mode = .gifController(GIFController(gifData: data))
} else if let image = UIImage(data: data) {
self.mode = .image(image)
}
}
} else {
let size = thumbnailSize ?? CGSize(width: 80, height: 80)
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in
if let image {
self.mode = .image(image)
}
}
}
case .drawing(let drawing):
self.mode = .image(drawing.imageInLightMode(from: drawing.bounds))
case .file(let url, let type):
if type.conforms(to: .movie) {
await loadVideoThumbnail(url: url)
} else if let data = try? Data(contentsOf: url) {
if type == .gif {
self.mode = .gifController(GIFController(gifData: data))
} else if type.conforms(to: .image) {
if let image = UIImage(data: data),
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
// crashing share extension. see FB12186346
let prepared = await thumbnailImage(image) {
self.mode = .image(prepared)
}
}
}
case .none:
break
}
}
private func thumbnailImage(_ image: UIImage) async -> UIImage? {
if let thumbnailSize {
await image.byPreparingThumbnail(ofSize: thumbnailSize)
} else {
await image.byPreparingForDisplay()
}
}
private func loadVideoThumbnail(url: URL) async {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
self.mode = .image(UIImage(cgImage: cgImage))
}
#else
if #available(iOS 16.0, *) {
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
self.mode = .image(UIImage(cgImage: cgImage))
}
} else {
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.mode = .image(UIImage(cgImage: cgImage))
}
}
#endif
}
enum Mode {
case empty
case image(UIImage)
case gifController(GIFController)
}
}
struct GIFViewWrapper: UIViewRepresentable {
typealias UIViewType = GIFImageView
@State var controller: GIFController
func makeUIView(context: Context) -> GIFImageView {
let view = GIFImageView()
controller.attach(to: view)
controller.startAnimating()
view.contentMode = .scaleAspectFit
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
return view
}
func updateUIView(_ uiView: GIFImageView, context: Context) {
}
}

View File

@ -1,282 +0,0 @@
//
// AttachmentCollectionViewCell.swift
// ComposeUI
//
// Created by Shadowfacts on 11/20/24.
//
import UIKit
import SwiftUI
import InstanceFeatures
import Vision
struct AttachmentCollectionViewCellView: View {
let attachment: DraftAttachment?
@State private var recognizingText = false
var body: some View {
if let attachment {
AttachmentThumbnailView(attachment: attachment, contentMode: .fill)
.squareFrame()
.background {
RoundedSquare(cornerRadius: 5)
.fill(.quaternary)
}
.overlay(alignment: .bottomLeading) {
AttachmentDescriptionLabel(attachment: attachment, recognizingText: recognizingText)
}
.overlay(alignment: .topLeading) {
AttachmentOptionsMenu(attachment: attachment, recognizingText: $recognizingText)
}
.overlay(alignment: .topTrailing) {
AttachmentRemoveButton(attachment: attachment)
}
.clipShape(RoundedSquare(cornerRadius: 5))
}
}
}
private struct AttachmentOptionsMenu: View {
let attachment: DraftAttachment
@Binding var recognizingText: Bool
var body: some View {
if attachment.drawingData != nil || attachment.type == .image {
Menu {
if attachment.drawingData != nil {
EditDrawingButton(attachment: attachment)
} else if attachment.type == .image {
RecognizeTextButton(attachment: attachment, recognizingText: $recognizingText)
}
} label: {
Label("Options", systemImage: "ellipsis.circle.fill")
}
.buttonStyle(AttachmentOverlayButtonStyle())
.padding([.top, .leading], 2)
}
}
}
private struct RecognizeTextButton: View {
let attachment: DraftAttachment
@Binding var recognizingText: Bool
@State private var error: (any Error)?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View {
Button(action: self.recognizeText) {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
.alertWithData("Text Recognition Failed", data: $error) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
}
private func recognizeText() {
recognizingText = true
attachment.getData(features: instanceFeatures) { result in
switch result {
case .failure(let error):
self.recognizingText = false
self.error = error
case .success(let (data, _)):
let handler = VNImageRequestHandler(data: data)
let request = VNRecognizeTextRequest { request, error in
DispatchQueue.main.async {
if let results = request.results as? [VNRecognizedTextObservation] {
var text = ""
for observation in results {
let result = observation.topCandidates(1).first!
text.append(result.string)
text.append("\n")
}
self.attachment.attachmentDescription = text
}
self.recognizingText = false
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
DispatchQueue.global(qos: .userInitiated).async {
do {
try handler.perform([request])
} catch let error as NSError where error.code == 1 {
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
return
} catch {
DispatchQueue.main.async {
self.recognizingText = false
self.error = error
}
}
}
}
}
}
}
private struct EditDrawingButton: View {
let attachment: DraftAttachment
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
Button(action: self.editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw")
}
}
private func editDrawing() {
guard let drawing = attachment.drawing else {
return
}
presentDrawing?(drawing) { drawing in
self.attachment.drawing = drawing
}
}
}
private struct AttachmentRemoveButton: View {
let attachment: DraftAttachment
var body: some View {
Button("Remove", systemImage: "xmark.circle.fill") {
let draft = attachment.draft
let attachments = draft.attachments.mutableCopy() as! NSMutableOrderedSet
attachments.remove(attachment)
draft.attachments = attachments
DraftsPersistentContainer.shared.viewContext.delete(attachment)
}
.buttonStyle(AttachmentOverlayButtonStyle())
.padding([.top, .trailing], 2)
}
}
private struct AttachmentOverlayButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.labelStyle(.iconOnly)
.imageScale(.large)
.foregroundStyle(.white)
.shadow(radius: 2)
}
}
private struct AttachmentDescriptionLabel: View {
@ObservedObject var attachment: DraftAttachment
let recognizingText: Bool
var body: some View {
ZStack(alignment: .bottomLeading) {
LinearGradient(
stops: [.init(color: .clear, location: 0.6), .init(color: .black.opacity(0.15), location: 0.7), .init(color: .black.opacity(0.5), location: 1)],
startPoint: .top,
endPoint: .bottom
)
labelOrProgress
.padding([.horizontal, .bottom], 4)
}
}
@ViewBuilder
private var labelOrProgress: some View {
if recognizingText {
ProgressView()
.progressViewStyle(.circular)
} else {
label
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.75), radius: 1)
}
}
@ViewBuilder
private var label: some View {
if attachment.attachmentDescription.isEmpty {
Label("Add alt", systemImage: "pencil")
.labelStyle(NarrowSpacingLabelStyle())
.font(.callout)
.lineLimit(1)
} else {
Text(attachment.attachmentDescription)
.font(.caption)
.lineLimit(2)
}
}
}
private struct NarrowSpacingLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 4) {
configuration.icon
configuration.title
}
}
}
private struct RoundedSquare: Shape {
let cornerRadius: CGFloat
nonisolated func path(in rect: CGRect) -> Path {
let minDimension = min(rect.width, rect.height)
let square = CGRect(x: rect.minX - (rect.width - minDimension) / 2, y: rect.minY - (rect.height - minDimension), width: minDimension, height: minDimension)
return RoundedRectangle(cornerRadius: cornerRadius).path(in: square)
}
}
@available(iOS 16.0, *)
private struct SquareFrame: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
precondition(subviews.count == 1)
let size = proposal.replacingUnspecifiedDimensions(by: subviews[0].sizeThatFits(proposal))
let minDimension = min(size.width, size.height)
return CGSize(width: minDimension, height: minDimension)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
precondition(subviews.count == 1)
let subviewSize = subviews[0].sizeThatFits(proposal)
let minDimension = min(bounds.width, bounds.height)
let origin = CGPoint(x: bounds.minX - (subviewSize.width - minDimension) / 2, y: bounds.minY - (subviewSize.height - minDimension) / 2)
subviews[0].place(at: origin, proposal: ProposedViewSize(subviewSize))
}
}
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private struct LegacySquareFrame<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
GeometryReader { proxy in
let minDimension = min(proxy.size.width, proxy.size.height)
content
.frame(width: minDimension, height: minDimension, alignment: .center)
}
}
}
#endif
private extension View {
@ViewBuilder
func squareFrame() -> some View {
#if os(visionOS)
SquareFrame {
self
}
#else
if #available(iOS 16.0, *) {
SquareFrame {
self
}
} else {
LegacySquareFrame {
self
}
}
#endif
}
}

View File

@ -1,217 +0,0 @@
//
// AttachmentWrapperGalleryContentViewController.swift
// ComposeUI
//
// Created by Shadowfacts on 11/22/24.
//
import UIKit
import GalleryVC
class AttachmentWrapperGalleryContentViewController: UIViewController, GalleryContentViewController {
let draftAttachment: DraftAttachment
let wrapped: any GalleryContentViewController
var container: (any GalleryContentViewControllerContainer)?
var contentSize: CGSize {
wrapped.contentSize
}
var activityItemsForSharing: [Any] {
wrapped.activityItemsForSharing
}
var caption: String? {
wrapped.caption
}
private lazy var editDescriptionViewController: EditAttachmentDescriptionViewController = EditAttachmentDescriptionViewController(draftAttachment: draftAttachment, wrapped: wrapped.bottomControlsAccessoryViewController)
var bottomControlsAccessoryViewController: UIViewController? {
editDescriptionViewController
}
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
false
}
var presentationAnimation: GalleryContentPresentationAnimation {
wrapped.presentationAnimation
}
var hideControlsOnZoom: Bool {
false
}
var showBelowSafeArea: Bool {
false
}
init(draftAttachment: DraftAttachment, wrapped: any GalleryContentViewController) {
self.draftAttachment = draftAttachment
self.wrapped = wrapped
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
wrapped.container = container
addChild(wrapped)
wrapped.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(wrapped.view)
NSLayoutConstraint.activate([
wrapped.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
wrapped.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
wrapped.view.topAnchor.constraint(equalTo: view.topAnchor),
wrapped.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
wrapped.didMove(toParent: self)
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
wrapped.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
if !visible {
editDescriptionViewController.textView?.resignFirstResponder()
}
}
func setInsetForBottomControls(_ inset: CGFloat) {
wrapped.setInsetForBottomControls(inset)
}
func galleryContentDidAppear() {
wrapped.galleryContentDidAppear()
}
func galleryContentWillDisappear() {
wrapped.galleryContentWillDisappear()
}
func shouldHideControls() -> Bool {
if editDescriptionViewController.textView.isFirstResponder {
editDescriptionViewController.textView.resignFirstResponder()
return false
} else {
return true
}
}
func galleryShouldBeginInteractiveDismiss() -> Bool {
if editDescriptionViewController.textView.isFirstResponder {
editDescriptionViewController.textView.resignFirstResponder()
return false
} else {
return true
}
}
}
private class EditAttachmentDescriptionViewController: UIViewController {
private let draftAttachment: DraftAttachment
private let wrapped: UIViewController?
private(set) var textView: UITextView!
private var isShowingPlaceholder = false
private var descriptionObservation: NSKeyValueObservation?
init(draftAttachment: DraftAttachment, wrapped: UIViewController?) {
self.draftAttachment = draftAttachment
self.wrapped = wrapped
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.overrideUserInterfaceStyle = .dark
view.backgroundColor = .secondarySystemFill
let stack = UIStackView()
stack.axis = .vertical
stack.distribution = .fill
stack.spacing = 0
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
])
if let wrapped {
addChild(wrapped)
stack.addArrangedSubview(wrapped.view)
wrapped.didMove(toParent: self)
}
textView = UITextView()
textView.backgroundColor = nil
textView.font = .preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
if draftAttachment.attachmentDescription.isEmpty {
showPlaceholder()
} else {
removePlaceholder()
textView.text = draftAttachment.attachmentDescription
}
textView.delegate = self
stack.addArrangedSubview(textView)
textView.heightAnchor.constraint(equalToConstant: 150).isActive = true
descriptionObservation = draftAttachment.observe(\.attachmentDescription) { [unowned self] _, _ in
let desc = self.draftAttachment.attachmentDescription
if desc.isEmpty {
if !isShowingPlaceholder {
showPlaceholder()
}
} else {
if isShowingPlaceholder {
removePlaceholder()
}
self.textView.text = desc
}
}
}
fileprivate func showPlaceholder() {
isShowingPlaceholder = true
textView.text = "Describe for the visually impaired"
textView.textColor = .secondaryLabel
}
fileprivate func removePlaceholder() {
isShowingPlaceholder = false
textView.text = ""
textView.textColor = .label
}
}
extension EditAttachmentDescriptionViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
if isShowingPlaceholder {
removePlaceholder()
}
}
func textViewDidEndEditing(_ textView: UITextView) {
draftAttachment.attachmentDescription = textView.text
if textView.text.isEmpty {
showPlaceholder()
}
}
}

View File

@ -1,117 +0,0 @@
//
// AttachmentsGalleryDataSource.swift
// ComposeUI
//
// Created by Shadowfacts on 11/21/24.
//
import UIKit
import GalleryVC
import TuskerComponents
import Photos
struct AttachmentsGalleryDataSource: GalleryDataSource {
let collectionView: UICollectionView
let fetchImageAndGIFData: (URL) async -> (UIImage, Data)?
let makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)?
let attachmentAtIndex: (Int) -> DraftAttachment?
func galleryItemsCount() -> Int {
collectionView.numberOfItems(inSection: 0) - 1
}
func galleryContentViewController(forItemAt index: Int) -> any GalleryVC.GalleryContentViewController {
let attachment = attachmentAtIndex(index)!
let content: any GalleryContentViewController
switch attachment.data {
case .editing(_, let kind, let url):
switch kind {
case .image:
content = LoadingGalleryContentViewController(caption: nil) {
if let (image, data) = await fetchImageAndGIFData(url) {
let gifController: GIFController? = if url.pathExtension == "gif" {
GIFController(gifData: data)
} else {
nil
}
return ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
} else {
return nil
}
}
case .video, .audio:
content = VideoGalleryContentViewController(url: url, caption: nil)
case .gifv:
content = LoadingGalleryContentViewController(caption: nil) { makeGifvGalleryContentVC(url) }
case .unknown:
content = LoadingGalleryContentViewController(caption: nil) { nil }
}
case .asset(let id):
content = LoadingGalleryContentViewController(caption: nil) {
if let (image, gifData) = await fetchAssetImageAndGIFData(assetID: id) {
let gifController = gifData.map(GIFController.init)
return ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
} else {
return nil
}
}
case .drawing(let drawing):
let image = drawing.imageInLightMode(from: drawing.bounds)
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: nil)
case .file(let url, let type):
if type.conforms(to: .movie) {
content = VideoGalleryContentViewController(url: url, caption: nil)
} else if type.conforms(to: .image),
let data = try? Data(contentsOf: url),
let image = UIImage(data: data) {
let gifController = type == .gif ? GIFController(gifData: data) : nil
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
} else {
return LoadingGalleryContentViewController(caption: nil) {
nil
}
}
case .none:
return LoadingGalleryContentViewController(caption: nil) {
nil
}
}
return AttachmentWrapperGalleryContentViewController(draftAttachment: attachment, wrapped: content)
}
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
if let cell = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? HostingCollectionViewCell {
// Use the hostView, because otherwise, the animation's changes to the source view opacity get clobbered by SwiftUI
cell.hostView
} else {
nil
}
}
private func fetchAssetImageAndGIFData(assetID id: String) async -> (UIImage, Data?)? {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return nil
}
let (type, data) = await withCheckedContinuation { continuation in
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
continuation.resume(returning: (typeIdentifier, data))
}
}
guard let data,
let image = UIImage(data: data) else {
return nil
}
if type == UTType.gif.identifier {
return (image, data)
} else {
return (image, nil)
}
}
}

View File

@ -1,554 +0,0 @@
//
// AttachmentsSection.swift
// ComposeUI
//
// Created by Shadowfacts on 11/17/24.
//
import SwiftUI
import PhotosUI
import PencilKit
import GalleryVC
import InstanceFeatures
struct AttachmentsSection: View {
@ObservedObject var draft: Draft
private let spacing: CGFloat = 8
private let minItemSize: CGFloat = 100
var body: some View {
#if os(visionOS)
collectionView
#else
if #available(iOS 16.0, *) {
collectionView
} else {
LegacyCollectionViewSizingView {
collectionView
} computeHeight: { width in
WrappedCollectionView.totalHeight(width: width, minItemSize: minItemSize, spacing: spacing, items: draft.attachments.count + 1)
}
}
#endif
}
private var collectionView: some View {
WrappedCollectionView(
draft: draft,
spacing: spacing,
minItemSize: minItemSize
)
// Impose a minimum height, because otherwise it defaults to zero which prevents the collection
// view from laying out, and leaving the intrinsic content size at zero too.
// Add 4 to the minItemSize because otherwise drag-and-drop while reordering can alter the contentOffset by that much.
.frame(minHeight: minItemSize + 4)
}
static func insertAttachments(in draft: Draft, at index: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = draft
draft.attachments.add(attachment)
}
}
}
}
}
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private struct LegacyCollectionViewSizingView<Content: View>: View {
@ViewBuilder let content: Content
let computeHeight: (CGFloat) -> CGFloat
@State private var width: CGFloat = 0
var body: some View {
let height = computeHeight(width)
content
.frame(height: max(height, 10))
.overlay {
GeometryReader { proxy in
Color.clear
.preference(key: WidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(WidthPrefKey.self) {
width = $0
}
}
}
}
}
private struct WidthPrefKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
let next = nextValue()
if next != 0 {
value = next
}
}
}
#endif
// Use a UIViewControllerRepresentable so we have something from which to present the gallery VC.
private struct WrappedCollectionView: UIViewControllerRepresentable {
@ObservedObject var draft: Draft
let spacing: CGFloat
let minItemSize: CGFloat
@Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData
@Environment(\.composeUIConfig.makeGifvGalleryContentVC) private var makeGifvGalleryContentVC
func makeUIViewController(context: Context) -> WrappedCollectionViewController {
WrappedCollectionViewController(
spacing: spacing,
minItemSize: minItemSize,
fetchImageAndGIFData: fetchImageAndGIFData,
makeGifvGalleryContentVC: makeGifvGalleryContentVC
)
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
uiViewController.draft = draft
uiViewController.addAttachment = {
DraftsPersistentContainer.shared.viewContext.insert($0)
$0.draft = draft
draft.attachments.add($0)
}
uiViewController.updateAttachments()
}
@available(iOS 16.0, *)
func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: WrappedCollectionViewController, context: Context) -> CGSize? {
guard let width = proposal.width,
width.isFinite else {
return nil
}
let count = draft.attachments.count + 1
return CGSize(
width: width,
height: Self.totalHeight(width: width, minItemSize: minItemSize, spacing: spacing, items: count)
)
}
fileprivate static func itemSize(width: CGFloat, minItemSize: CGFloat, spacing: CGFloat) -> (CGFloat, Int) {
// The maximum item size is 2*minItemSize + spacing - 1,
// in the case where one item fits in the row but we are one pt short of
// adding a second item.
var itemSize = minItemSize
var fittingCount = floor((width + spacing) / (itemSize + spacing))
var usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
var remainingSpace = width - usedSpaceForFittingCount
if fittingCount == 0 {
return (0, 0)
} else if fittingCount == 1 && remainingSpace > minItemSize / 2 {
// If there's only one item that would fit at min size, and giving
// it the rest of the space would increase it by at least 50%,
// add a second item anywyas.
itemSize = (width - spacing) / 2
fittingCount = 2
usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
remainingSpace = width - usedSpaceForFittingCount
}
itemSize = itemSize + remainingSpace / fittingCount
return (itemSize, Int(fittingCount))
}
fileprivate static func totalHeight(width: CGFloat, minItemSize: CGFloat, spacing: CGFloat, items: Int) -> CGFloat {
let (size, itemsPerRow) = itemSize(width: width, minItemSize: minItemSize, spacing: spacing)
guard itemsPerRow != 0 else {
return 0
}
let rows = ceil(Double(items) / Double(itemsPerRow))
return size * rows + spacing * (rows - 1)
}
}
private class WrappedCollectionViewController: UIViewController {
let spacing: CGFloat
let minItemSize: CGFloat
var draft: Draft!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
fileprivate var currentInteractiveMoveStartOffsetInCell: CGPoint?
fileprivate var currentInteractiveMoveCell: HostingCollectionViewCell?
fileprivate var addAttachment: ((DraftAttachment) -> Void)? = nil
fileprivate var fetchImageAndGIFData: (URL) async -> (UIImage, Data)?
fileprivate var makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)?
var collectionView: UICollectionView {
view as! UICollectionView
}
init(
spacing: CGFloat,
minItemSize: CGFloat,
fetchImageAndGIFData: @escaping (URL) async -> (UIImage, Data)?,
makeGifvGalleryContentVC: @escaping (URL) -> (any GalleryContentViewController)?
) {
self.spacing = spacing
self.minItemSize = minItemSize
self.fetchImageAndGIFData = fetchImageAndGIFData
self.makeGifvGalleryContentVC = makeGifvGalleryContentVC
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let layout = UICollectionViewCompositionalLayout { [unowned self] section, environment in
let (itemSize, itemsPerRow) = WrappedCollectionView.itemSize(width: environment.container.contentSize.width, minItemSize: minItemSize, spacing: spacing)
let items = Array(repeating: NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(itemSize), heightDimension: .absolute(itemSize))), count: itemsPerRow)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemSize)), subitems: items)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
return section
}
let attachmentCell = UICollectionView.CellRegistration<HostingCollectionViewCell, DraftAttachment> { [unowned self] cell, indexPath, attachment in
#if !os(visionOS)
cell.containingViewController = self
#endif
cell.setView(AttachmentCollectionViewCellView(attachment: attachment))
}
let addButtonCell = UICollectionView.CellRegistration<HostingCollectionViewCell, Void> { [unowned self] cell, indexPath, item in
#if !os(visionOS)
cell.containingViewController = self
#endif
cell.setView(AddAttachmentButton(viewController: self))
}
let collectionView = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
self.view = collectionView
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .attachment(let attachment):
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
case .addButton:
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: ())
}
}
dataSource.reorderingHandlers.canReorderItem = { item in
switch item {
case .attachment(_):
true
case .addButton:
false
}
}
dataSource.reorderingHandlers.didReorder = { [unowned self] transaction in
let attachmentChanges = transaction.difference.map {
switch $0 {
case .insert(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
case .remove(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
}
}
let attachmentsDiff = CollectionDifference(attachmentChanges)!
let array = draft.draftAttachments.applying(attachmentsDiff)!
draft.attachments = NSMutableOrderedSet(array: array)
}
collectionView.isScrollEnabled = false
collectionView.clipsToBounds = false
collectionView.delegate = self
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(reorderingLongPressRecognized))
longPressRecognizer.delegate = self
collectionView.addGestureRecognizer(longPressRecognizer)
}
func updateAttachments() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.all])
snapshot.appendItems(draft.draftAttachments.map { .attachment($0) })
snapshot.appendItems([.addButton])
dataSource.apply(snapshot)
}
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
let collectionView = recognizer.view as! UICollectionView
switch recognizer.state {
case .began:
break
case .changed:
var pos = recognizer.location(in: collectionView)
if let currentInteractiveMoveStartOffsetInCell {
pos.x -= currentInteractiveMoveStartOffsetInCell.x
pos.y -= currentInteractiveMoveStartOffsetInCell.y
}
collectionView.updateInteractiveMovementTargetPosition(pos)
case .ended:
collectionView.endInteractiveMovement()
UIView.animate(withDuration: 0.2) {
self.currentInteractiveMoveCell?.hostView?.transform = .identity
}
currentInteractiveMoveCell = nil
currentInteractiveMoveStartOffsetInCell = nil
case .cancelled:
collectionView.cancelInteractiveMovement()
UIView.animate(withDuration: 0.2) {
self.currentInteractiveMoveCell?.hostView?.transform = .identity
}
currentInteractiveMoveCell = nil
currentInteractiveMoveStartOffsetInCell = nil
default:
break
}
}
enum Section {
case all
}
enum Item: Hashable {
case attachment(DraftAttachment)
case addButton
}
}
extension WrappedCollectionViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let collectionView = gestureRecognizer.view as! UICollectionView
let location = gestureRecognizer.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: location),
let cell = collectionView.cellForItem(at: indexPath) as? HostingCollectionViewCell else {
return false
}
guard collectionView.beginInteractiveMovementForItem(at: indexPath) else {
return false
}
UIView.animate(withDuration: 0.2) {
cell.hostView?.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}
currentInteractiveMoveCell = cell
currentInteractiveMoveStartOffsetInCell = gestureRecognizer.location(in: cell)
currentInteractiveMoveStartOffsetInCell!.x -= cell.bounds.midX
currentInteractiveMoveStartOffsetInCell!.y -= cell.bounds.midY
return true
}
}
extension WrappedCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
let snapshot = dataSource.snapshot()
let items = snapshot.itemIdentifiers(inSection: .all).count
if proposedIndexPath.row == items - 1 {
return IndexPath(item: items - 2, section: proposedIndexPath.section)
} else {
return proposedIndexPath
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard case .attachment(_) = dataSource.itemIdentifier(for: indexPath) else {
return
}
let dataSource = AttachmentsGalleryDataSource(
collectionView: collectionView,
fetchImageAndGIFData: self.fetchImageAndGIFData,
makeGifvGalleryContentVC: self.makeGifvGalleryContentVC
) { [dataSource] in
let item = dataSource?.itemIdentifier(for: IndexPath(item: $0, section: 0))
switch item {
case .attachment(let attachment):
return attachment
default:
return nil
}
}
let galleryVC = GalleryViewController(dataSource: dataSource, initialItemIndex: indexPath.item)
galleryVC.showShareButton = false
present(galleryVC, animated: true)
}
}
private final class IntrinsicContentSizeCollectionView: UICollectionView {
private var _intrinsicContentSize = CGSize.zero
override var intrinsicContentSize: CGSize {
_intrinsicContentSize
}
override func layoutSubviews() {
super.layoutSubviews()
if contentSize != _intrinsicContentSize {
_intrinsicContentSize = contentSize
invalidateIntrinsicContentSize()
}
}
}
#if os(visionOS)
final class HostingCollectionViewCell: UICollectionViewCell {
private(set) var hostView: UIView?
func setView<V: View>(_ view: V) {
let config = UIHostingConfiguration(content: {
view
}).margins(.all, 0)
if let hostView = hostView as? UIContentView {
hostView.configuration = config
} else {
hostView = config.makeContentView()
hostView!.frame = contentView.bounds
contentView.addSubview(hostView!)
}
}
}
#else
@available(iOS, obsoleted: 16.0)
final class HostingCollectionViewCell: UICollectionViewCell {
weak var containingViewController: UIViewController?
@available(iOS, obsoleted: 16.0)
private var hostController: UIHostingController<AnyView>?
private(set) var hostView: UIView?
func setView<V: View>(_ view: V) {
if #available(iOS 16.0, *) {
let config = UIHostingConfiguration(content: {
view
}).margins(.all, 0)
// We don't just use the cell's contentConfiguration property because we need to animate
// the size of the host view, and when the host view is the contentView, that doesn't work.
if let hostView = hostView as? UIContentView {
hostView.configuration = config
} else {
hostView = config.makeContentView()
hostView!.frame = contentView.bounds
contentView.addSubview(hostView!)
}
} else {
if let hostController {
hostController.rootView = AnyView(view)
} else {
let host = UIHostingController(rootView: AnyView(view))
containingViewController!.addChild(host)
host.view.frame = contentView.bounds
contentView.addSubview(host.view)
host.didMove(toParent: containingViewController!)
hostController = host
hostView = host.view
}
}
}
}
#endif
private struct AddAttachmentButton: View {
unowned let viewController: WrappedCollectionViewController
@Environment(\.canAddAttachment) private var enabled
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
Menu {
if let presentAssetPicker {
Button("Add photo or video", systemImage: "photo") {
presentAssetPicker {
let draft = viewController.draft!
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
}
}
}
if let presentDrawing {
Button("Draw something", systemImage: "hand.draw") {
presentDrawing(PKDrawing()) { drawing in
let draft = viewController.draft!
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
attachment.id = UUID()
attachment.drawing = drawing
attachment.draft = draft
draft.attachments.add(attachment)
}
}
}
} label: {
Image(systemName: iconName)
.imageScale(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
RoundedRectangle(cornerRadius: 5)
.foregroundStyle(.tint.opacity(0.1))
RoundedRectangle(cornerRadius: 5)
.stroke(.tint, style: StrokeStyle(lineWidth: 2, dash: [5]))
}
}
.disabled(!enabled)
.animation(.linear(duration: 0.2), value: enabled)
}
private var iconName: String {
if #available(iOS 17.0, *) {
"photo.badge.plus"
} else {
"photo"
}
}
}
struct AddAttachmentConditionsModifier: ViewModifier {
@ObservedObject var draft: Draft
@EnvironmentObject private var instanceFeatures: InstanceFeatures
private var canAddAttachment: Bool {
if instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4
&& draft.draftAttachments.allSatisfy { $0.type == .image }
&& !draft.pollEnabled
} else {
return true
}
}
func body(content: Content) -> some View {
content
.environment(\.canAddAttachment, canAddAttachment)
}
}
private struct CanAddAttachmentKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var canAddAttachment: Bool {
get { self[CanAddAttachmentKey.self] }
set { self[CanAddAttachmentKey.self] = newValue }
}
}
struct DropAttachmentModifier: ViewModifier {
let draft: Draft
@Environment(\.canAddAttachment) private var canAddAttachment
func body(content: Content) -> some View {
content
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, delegate: AttachmentDropDelegate(draft: draft, canAddAttachment: canAddAttachment))
}
}
private struct AttachmentDropDelegate: DropDelegate {
let draft: Draft
let canAddAttachment: Bool
func validateDrop(info: DropInfo) -> Bool {
canAddAttachment
}
func performDrop(info: DropInfo) -> Bool {
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider))
return true
}
}

View File

@ -1,242 +0,0 @@
//
// ComposeNavigationBarActions.swift
// ComposeUI
//
// Created by Shadowfacts on 1/30/25.
//
import SwiftUI
import Combine
import InstanceFeatures
import TuskerPreferences
struct ComposeNavigationBarActions: ToolbarContent {
@ObservedObject var draft: Draft
@Binding var isShowingDrafts: Bool
let isPosting: Bool
let cancel: (_ deleteDraft: Bool) -> Void
let postStatus: () async -> Void
var body: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
ToolbarCancelButton(draft: draft, isPosting: isPosting, cancel: cancel)
}
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) {
DraftsButton(isShowingDrafts: $isShowingDrafts)
}
ToolbarItem(placement: .confirmationAction) {
PostButton(draft: draft, isPosting: isPosting, postStatus: postStatus)
}
#else
ToolbarItem(placement: .confirmationAction) {
PostOrDraftsButton(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: isPosting, postStatus: postStatus)
}
#endif
}
}
private struct ToolbarCancelButton: View {
let draft: Draft
let isPosting: Bool
let cancel: (_ deleteDraft: Bool) -> Void
@State private var isShowingSaveDraftSheet = false
var body: some View {
Button(role: .cancel, action: self.showConfirmationOrCancel) {
Text("Cancel")
}
.disabled(isPosting)
.confirmationDialog("Are you sure?", isPresented: $isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
Button(action: { cancel(false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { cancel(true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { cancel(true) }) {
Text("Cancel Edit")
}
}
}
}
private func showConfirmationOrCancel() {
if draft.hasContent {
isShowingSaveDraftSheet = true
} else {
cancel(true)
}
}
}
#if !targetEnvironment(macCatalyst)
private struct PostOrDraftsButton: View {
@DraftObserving var draft: Draft
@Binding var isShowingDrafts: Bool
let isPosting: Bool
let postStatus: () async -> Void
@Environment(\.composeUIConfig.allowSwitchingDrafts) private var allowSwitchingDrafts
var body: some View {
if !draftIsEmpty || draft.editedStatusID != nil || !allowSwitchingDrafts {
PostButton(draft: draft, isPosting: isPosting, postStatus: postStatus)
} else {
DraftsButton(isShowingDrafts: $isShowingDrafts)
}
}
private var draftIsEmpty: Bool {
draft.text == draft.initialText && (!draft.contentWarningEnabled || draft.contentWarning == draft.initialContentWarning) && draft.attachments.count == 0 && (!draft.pollEnabled || !draft.poll!.hasContent)
}
}
#endif
private struct PostButton: View {
@DraftObserving var draft: Draft
let isPosting: Bool
let postStatus: () async -> Void
@EnvironmentObject private var instanceFeatures: InstanceFeatures
@PreferenceObserving(\.$requireAttachmentDescriptions) private var requireAttachmentDescriptions
var body: some View {
Button {
Task {
await postStatus()
}
} label: {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!draftValid)
.disabled(isPosting)
}
private var hasCharactersRemaining: Bool {
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
let bodyCount = CharacterCounter.count(text: draft.text, for: instanceFeatures)
let remaining = limit - (cwCount + bodyCount)
return remaining >= 0
}
private var attachmentsCombinationValid: Bool {
if !instanceFeatures.mastodonAttachmentRestrictions {
true
} else if draft.attachments.count > 1,
draft.draftAttachments.contains(where: { $0.type == .video }) {
false
} else if draft.attachments.count > 4 {
false
} else {
true
}
}
private var attachmentsValid: Bool {
(!requireAttachmentDescriptions || draft.draftAttachments.allSatisfy { !$0.attachmentDescription.isEmpty })
&& attachmentsCombinationValid
}
private var pollValid: Bool {
!draft.pollEnabled || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
}
private var draftValid: Bool {
draft.editedStatusID != nil ||
((draft.hasText || draft.attachments.count > 0)
&& hasCharactersRemaining
&& attachmentsValid
&& pollValid)
}
}
private struct DraftsButton: View {
@Binding var isShowingDrafts: Bool
var body: some View {
Button {
isShowingDrafts = true
} label: {
Text("Drafts")
}
}
}
// This property wrapper lets a View observe all of the following:
// 1. The Draft itself
// 2. The Draft's Poll (if it has one)
// 3. Each of the Poll's PollOptions (if there is a Poll)
// 4. Each of the Draft's DraftAttachments
@propertyWrapper
private struct DraftObserving: DynamicProperty {
let wrappedValue: Draft
@StateObject private var observer = Observer()
init(wrappedValue: Draft) {
self.wrappedValue = wrappedValue
}
func update() {
observer.update(draft: wrappedValue)
}
private class Observer: ObservableObject {
private var draft: Draft?
private var cancellable: AnyCancellable?
private var draftPollObservation: NSKeyValueObservation?
private var pollOptionsObservation: NSKeyValueObservation?
private var pollOptionsCancellables: [AnyCancellable] = []
private var draftAttachmentsObservation: NSKeyValueObservation?
private var draftAttachmentsCancellables: [AnyCancellable] = []
func update(draft: Draft) {
guard draft !== self.draft else {
return
}
self.draft = draft
cancellable = draft.objectWillChange
.sink { [unowned self] _ in self.objectWillChange.send() }
draftPollObservation = draft.observe(\.poll) { [unowned self] _, _ in
objectWillChange.send()
self.pollChanged()
}
pollChanged()
draftAttachmentsObservation = draft.observe(\.attachments) { [unowned self] _, _ in
objectWillChange.send()
self.draftAttachmentsChanged()
}
draftAttachmentsChanged()
}
private func pollChanged() {
pollOptionsObservation = (draft?.poll).map {
$0.observe(\.options) { [unowned self] _, _ in
objectWillChange.send()
self.pollOptionsChanged()
}
}
pollOptionsChanged()
}
private func pollOptionsChanged() {
pollOptionsCancellables = draft?.poll?.pollOptions.map {
$0.objectWillChange
.sink { [unowned self] _ in self.objectWillChange.send() }
} ?? []
}
private func draftAttachmentsChanged() {
draftAttachmentsCancellables = draft?.draftAttachments.map {
$0.objectWillChange
.sink { [unowned self] _ in self.objectWillChange.send() }
} ?? []
}
}
}

View File

@ -1,242 +0,0 @@
//
// ComposeToolbarView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
import TuskerComponents
import InstanceFeatures
import Pachyderm
import TuskerPreferences
struct ComposeToolbarView: View {
static let height: CGFloat = 44
@ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
#if os(visionOS)
buttons
#else
ToolbarScrollView {
buttons
.padding(.horizontal, 16)
}
.frame(height: Self.height)
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
.overlay(alignment: .top) {
Divider()
.ignoresSafeArea(edges: [.leading, .trailing])
}
#endif
}
private var buttons: some View {
HStack(spacing: 4) {
ContentWarningButton(enabled: $draft.contentWarningEnabled, focusedField: $focusedField)
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController)
InsertEmojiButton()
FormatButtons()
Spacer()
}
}
}
#if !os(visionOS)
private struct ToolbarScrollView<Content: View>: View {
@ViewBuilder let content: Content
@State private var minWidth: CGFloat?
@State private var realWidth: CGFloat?
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
content
.frame(minWidth: minWidth)
.background {
GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) {
realWidth = $0
}
}
}
}
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
.frame(maxWidth: .infinity)
.background {
GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) {
minWidth = $0
}
}
}
}
}
#endif
private struct ToolbarWidthPrefKey: SwiftUI.PreferenceKey {
static var defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
private struct ContentWarningButton: View {
@Binding var enabled: Bool
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
Button("CW", action: toggleContentWarning)
.accessibilityLabel(enabled ? "Remove content warning" : "Add content warning")
.padding(5)
.hoverEffect()
}
private func toggleContentWarning() {
enabled.toggle()
if focusedField != nil {
if enabled {
focusedField = .contentWarning
} else if focusedField == .contentWarning {
focusedField = .body
}
}
}
}
private struct VisibilityButton: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
private var visibilityBinding: Binding<Pachyderm.Visibility> {
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
// changing the visibility when local-only.
if draft.localOnly,
instanceFeatures.localOnlyPostsVisibility {
return .constant(.public)
} else {
return $draft.visibility
}
}
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
let visibilities: [Pachyderm.Visibility]
if !instanceFeatures.composeDirectStatuses {
visibilities = [.public, .unlisted, .private]
} else {
visibilities = Pachyderm.Visibility.allCases
}
return visibilities.map { vis in
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
}
}
var body: some View {
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
.disabled(draft.editedStatusID != nil)
.disabled(instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
}
}
private struct LocalOnlyButton: View {
@Binding var enabled: Bool
var mastodonController: any ComposeMastodonContext
@ObservedObject private var instanceFeatures: InstanceFeatures
init(enabled: Binding<Bool>, mastodonController: any ComposeMastodonContext) {
self._enabled = enabled
self.mastodonController = mastodonController
self.instanceFeatures = mastodonController.instanceFeatures
}
private var options: [MenuPicker<Bool>.Option] {
let domain = mastodonController.accountInfo!.instanceURL.host!
return [
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
]
}
var body: some View {
if mastodonController.instanceFeatures.localOnlyPosts {
MenuPicker(selection: $enabled, options: options, buttonStyle: .iconOnly)
}
}
}
private struct InsertEmojiButton: View {
@FocusedValue(\.composeInput) private var input
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
var body: some View {
if input??.toolbarElements.contains(.emojiPicker) == true {
Button(action: beginAutocompletingEmoji) {
Label("Insert custom emoji", systemImage: "face.smiling")
}
.labelStyle(.iconOnly)
.font(.system(size: imageSize))
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
}
private func beginAutocompletingEmoji() {
input??.beginAutocompletingEmoji()
}
}
private struct FormatButtons: View {
@FocusedValue(\.composeInput) private var input
@PreferenceObserving(\.$statusContentType) private var contentType
var body: some View {
if let input = input.flatMap(\.self),
input.toolbarElements.contains(.formattingButtons),
contentType != .plain {
Spacer()
ForEach(StatusFormat.allCases) { format in
FormatButton(format: format, input: input)
}
}
}
}
private struct FormatButton: View {
let format: StatusFormat
let input: any ComposeInput
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
var body: some View {
Button(action: applyFormat) {
Image(systemName: format.imageName)
.font(.system(size: imageSize))
}
.accessibilityLabel(format.accessibilityLabel)
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
private func applyFormat() {
input.applyFormat(format)
}
}
//#Preview {
// ComposeToolbarView()
//}

View File

@ -1,296 +0,0 @@
//
// ComposeView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
import CoreData
import Pachyderm
import TuskerComponents
import TuskerPreferences
// State owned by the compose UI but that needs to be accessible from outside.
public final class ComposeViewState: ObservableObject {
@Published var poster: PostService?
@Published public internal(set) var draft: Draft
@Published public internal(set) var didPostSuccessfully = false
public var isPosting: Bool {
poster != nil
}
public init(draft: Draft) {
self.draft = draft
}
}
public struct ComposeView: View {
@ObservedObject var state: ComposeViewState
let mastodonController: any ComposeMastodonContext
let currentAccount: (any AccountProtocol)?
let config: ComposeUIConfig
public init(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
currentAccount: (any AccountProtocol)?,
config: ComposeUIConfig
) {
self.state = state
self.mastodonController = mastodonController
self.currentAccount = currentAccount
self.config = config
}
public var body: some View {
ComposeViewBody(
draft: state.draft,
mastodonController: mastodonController,
state: state,
setDraft: self.setDraft
)
.environment(\.composeUIConfig, config)
.environment(\.currentAccount, currentAccount)
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
}
private func setDraft(_ draft: Draft) {
let oldDraft = state.draft
state.draft = draft
if oldDraft.hasContent {
oldDraft.lastModified = Date()
} else {
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
}
DraftsPersistentContainer.shared.save()
}
private func managedObjectsDidChange(_ notification: Foundation.Notification) {
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
deleted.contains(where: { $0.objectID == state.draft.objectID }) {
config.dismiss(.cancel)
}
}
}
// TODO: see if this can be broken up further
private struct ComposeViewBody: View {
@ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext
@ObservedObject var state: ComposeViewState
let setDraft: (Draft) -> Void
@State private var postError: PostService.Error?
@FocusState private var focusedField: FocusableField?
@State private var isShowingDrafts = false
@State private var isDismissing = false
@State private var userConfirmedDelete = false
@Environment(\.composeUIConfig) private var config
@PreferenceObserving(\.$statusContentType) private var statusContentType
public var body: some View {
navigation
.environmentObject(mastodonController.instanceFeatures)
.sheet(isPresented: $isShowingDrafts) {
DraftsView(
currentDraft: draft,
isShowingDrafts: $isShowingDrafts,
accountInfo: mastodonController.accountInfo!,
selectDraft: {
self.setDraft($0)
self.isShowingDrafts = false
}
)
}
.alertWithData("Error Posting", data: $postError, actions: { _ in
Button("OK") {}
}, message: { error in
Text(error.localizedDescription)
})
.onDisappear(perform: self.deleteOrSaveDraft)
}
@ViewBuilder
private var navigation: some View {
#if os(visionOS)
NavigationStack {
navigationRoot
}
#else
if #available(iOS 16.0, *) {
NavigationStack {
navigationRoot
}
} else {
NavigationView {
navigationRoot
}
.navigationViewStyle(.stack)
}
#endif
}
private var navigationRoot: some View {
ZStack {
ScrollView {
scrollContent
}
#if !os(visionOS)
.scrollDismissesKeyboardInteractivelyIfAvailable()
#endif
#if !os(visionOS) && !targetEnvironment(macCatalyst)
.modifier(ToolbarSafeAreaInsetModifier())
#endif
}
.overlay(alignment: .top) {
if let poster = state.poster {
PostProgressView(poster: poster)
.frame(alignment: .top)
}
}
#if !os(visionOS)
.overlay(alignment: .bottom, content: {
// This needs to be in an overlay, ignoring the keyboard safe area
// doesn't work with the safeAreaInset modifier.
if config.showToolbar {
toolbarView
.frame(maxHeight: .infinity, alignment: .bottom)
// TODO: use a input accessory view (controller) for the toolbar
// .ignoresSafeArea(.keyboard)
.transition(.move(edge: .bottom))
.animation(.snappy, value: config.showToolbar)
}
})
#endif
// Have these after the overlays so they barely work instead of not working at all. FB11790805
.modifier(DropAttachmentModifier(draft: draft))
.modifier(AddAttachmentConditionsModifier(draft: draft))
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ComposeNavigationBarActions(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: state.isPosting, cancel: self.cancel(deleteDraft:), postStatus: self.postStatus)
#if os(visionOS)
ToolbarItem(placement: .bottomOrnament) {
toolbarView
}
#endif
}
}
private var toolbarView: some View {
ComposeToolbarView(draft: draft, mastodonController: mastodonController, focusedField: $focusedField)
}
@ViewBuilder
private var scrollContent: some View {
VStack(spacing: 8) {
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
DraftEditor(draft: draft, focusedField: $focusedField)
}
.padding(8)
}
private func addAttachments(_ itemProviders: [NSItemProvider]) {
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
}
private func deleteOrSaveDraft() {
if isDismissing,
!draft.hasContent || state.didPostSuccessfully || userConfirmedDelete {
DraftsPersistentContainer.shared.viewContext.delete(draft)
} else {
draft.lastModified = Date()
}
DraftsPersistentContainer.shared.save()
}
private func cancel(deleteDraft: Bool) {
isDismissing = true
userConfirmedDelete = deleteDraft
config.dismiss(.cancel)
}
private func postStatus() async {
guard !state.isPosting,
draft.editedStatusID != nil || draft.hasContent else {
return
}
let poster = PostService(mastodonController: mastodonController, contentType: statusContentType, draft: draft)
state.poster = poster
do {
try await poster.post()
isDismissing = true
state.didPostSuccessfully = true
// wait .25 seconds so the user can see the progress bar has completed
try? await Task.sleep(nanoseconds: 250_000_000)
// don't unset the poster, so the ui remains disabled while dismissing
config.dismiss(.post)
} catch {
self.postError = error
state.poster = nil
}
}
}
extension ComposeView {
public static func navigationTitle(for draft: Draft, mastodonController: any ComposeMastodonContext) -> String {
if let id = draft.inReplyToID,
let status = mastodonController.fetchStatus(id: id) {
return "Reply to @\(status.account.acct)"
} else if draft.editedStatusID != nil {
return "Edit Post"
} else {
return "New Post"
}
}
}
private struct NavigationTitleModifier: ViewModifier {
let draft: Draft
let mastodonController: any ComposeMastodonContext
func body(content: Content) -> some View {
let title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
content
.navigationTitle(title)
}
}
enum FocusableField: Hashable {
case contentWarning
case body
case pollOption(NSManagedObjectID)
}
#if !os(visionOS) && !targetEnvironment(macCatalyst)
private struct ToolbarSafeAreaInsetModifier: ViewModifier {
@StateObject private var keyboardReader = KeyboardReader()
func body(content: Content) -> some View {
if #available(iOS 17.0, *) {
content
.safeAreaPadding(.bottom, keyboardReader.isVisible ? 0 : ComposeToolbarView.height)
} else {
content
.safeAreaInset(edge: .bottom) {
if !keyboardReader.isVisible {
Color.clear.frame(height: ComposeToolbarView.height)
}
}
}
}
}
#endif
//#Preview {
// ComposeView()
//}

View File

@ -1,30 +0,0 @@
//
// ContentWarningTextField.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
struct ContentWarningTextField: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
EmojiTextField(
text: $draft.contentWarning,
placeholder: "Write your warning here",
maxLength: nil,
focusNextView: {
focusedField = .body
}
)
.focused($focusedField, equals: .contentWarning)
.modifier(FocusedInputModifier())
}
}
//#Preview {
// ContentWarningTextField()
//}

View File

@ -1,137 +0,0 @@
//
// DraftContentEditor.swift
// ComposeUI
//
// Created by Shadowfacts on 11/16/24.
//
import SwiftUI
import InstanceFeatures
struct DraftContentEditor: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
var body: some View {
VStack(spacing: 4) {
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
HStack(alignment: .firstTextBaseline) {
LanguageButton(draft: draft)
TogglePollButton(draft: draft, focusedField: $focusedField)
Spacer()
CharactersRemaining(draft: draft)
.padding(.trailing, 6)
}
}
.composePlatterBackground()
}
private func addAttachments(_ providers: [NSItemProvider]) {
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: providers)
}
}
private struct CharactersRemaining: View {
@ObservedObject var draft: Draft
@EnvironmentObject var instanceFeatures: InstanceFeatures
private var charsRemaining: Int {
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
}
var body: some View {
Text(verbatim: charsRemaining.description)
.foregroundStyle(charsRemaining < 0 ? .red : .secondary)
.font(.body.monospacedDigit())
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
}
}
private struct LanguageButton: View {
@ObservedObject var draft: Draft
@EnvironmentObject private var instanceFeatures: InstanceFeatures
@FocusedValue(\.composeInput) private var input
@State private var hasChanged = false
var body: some View {
if #available(iOS 16.0, *),
instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
.buttonStyle(LanguageButtonStyle())
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
}
}
@available(iOS 16.0, *)
private func currentInputModeChanged(_ notification: Foundation.Notification) {
guard !hasChanged,
!draft.hasContent,
let mode = input??.textInputMode,
let code = LanguagePicker.codeFromInputMode(mode) else {
return
}
draft.language = code.identifier
}
}
private struct LanguageButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.body.monospaced())
.foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1))
.padding(.vertical, 2)
.padding(.horizontal, 4)
.background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3))
.animation(.linear(duration: 0.1), value: configuration.isPressed)
.padding(2)
}
}
private struct TogglePollButton: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View {
Button(action: togglePoll) {
Image(systemName: draft.pollEnabled ? "chart.bar.doc.horizontal.fill" : "chart.bar.doc.horizontal")
}
.buttonStyle(LanguageButtonStyle())
.disabled(disabled)
.animation(.linear(duration: 0.2), value: disabled)
.animation(.linear(duration: 0.2), value: draft.pollEnabled)
}
private var disabled: Bool {
instanceFeatures.mastodonAttachmentRestrictions && draft.attachments.count > 0
}
private func togglePoll() {
if draft.pollEnabled {
draft.pollEnabled = false
if case .pollOption(_) = focusedField {
focusedField = .body
}
} else {
let poll: Poll
if let p = draft.poll {
poll = p
} else {
poll = Poll(context: DraftsPersistentContainer.shared.viewContext)
draft.poll = poll
}
draft.pollEnabled = true
if focusedField != nil {
let optionToFocus = poll.pollOptions.first(where: { $0.text.isEmpty }) ?? poll.pollOptions.last!
focusedField = .pollOption(optionToFocus.id)
}
}
}
}

View File

@ -1,97 +0,0 @@
//
// DraftEditor.swift
// ComposeUI
//
// Created by Shadowfacts on 11/16/24.
//
import SwiftUI
import Pachyderm
import TuskerComponents
import TuskerPreferences
struct DraftEditor: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@Environment(\.currentAccount) private var currentAccount
var body: some View {
HStack(alignment: .top, spacing: 8) {
// TODO: scroll effect?
AvatarView(account: currentAccount)
VStack(alignment: .leading, spacing: 4) {
if let currentAccount {
AccountNameView(account: currentAccount)
}
if draft.contentWarningEnabled {
ContentWarningTextField(draft: draft, focusedField: $focusedField)
.transition(.opacity)
}
DraftContentEditor(draft: draft, focusedField: $focusedField)
if let poll = draft.poll,
draft.pollEnabled {
PollEditor(poll: poll, focusedField: $focusedField)
.padding(.bottom, 4)
// So that during the appearance transition, it's behind the text view.
.zIndex(-1)
.transition(.move(edge: .top).combined(with: .opacity))
}
AttachmentsSection(draft: draft)
// We want the padding between the poll/attachments to be part of the poll, so it animates in/out with the transition.
// Otherwise, when the poll is added, its bottom edge is aligned with the top edge of the attachments
.padding(.top, draft.pollEnabled ? -4 : 0)
}
// These animations are here, because the height of the VStack and the positions of the lower views needs to animate too.
.animation(.snappy, value: draft.pollEnabled)
.modifier(PollAnimatingModifier(poll: draft.poll))
.animation(.snappy, value: draft.contentWarningEnabled)
}
}
}
private struct AvatarView: View {
let account: (any AccountProtocol)?
@PreferenceObserving(\.$avatarStyle) private var avatarStyle
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
var body: some View {
AvatarImageView(
url: account?.avatar,
size: 50,
style: avatarStyle == .circle ? .circle : .roundRect,
fetchAvatar: fetchAvatar
)
.accessibilityHidden(true)
}
}
private struct AccountNameView: View {
let account: any AccountProtocol
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel
var body: some View {
HStack(spacing: 4) {
displayNameLabel(account, .body, 16)
.lineLimit(1)
Text(verbatim: "@\(account.acct)")
.font(.body.weight(.light))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
// Separate modifier because we need to observe the Poll itself, not the draft
private struct PollAnimatingModifier: ViewModifier {
@OptionalObservedObject var poll: Poll?
func body(content: Content) -> some View {
content.animation(.snappy, value: poll?.pollOptions.count)
}
}

View File

@ -1,163 +0,0 @@
//
// DraftsView.swift
// ComposeUI
//
// Created by Shadowfacts on 1/27/25.
//
import SwiftUI
import UserAccounts
struct DraftsView: View {
let currentDraft: Draft
@Binding var isShowingDrafts: Bool
let accountInfo: UserAccountInfo
let selectDraft: (Draft) -> Void
@State private var draftForDifferentReply: Draft? = nil
var body: some View {
navigationView
.alertWithData("Different Reply", data: $draftForDifferentReply) { draft in
Button(role: .cancel) {
draftForDifferentReply = nil
} label: {
Text("Cancel")
}
Button {
selectDraft(draft)
} label: {
Text("Restore Draft")
}
} message: { _ in
Text("The selected draft is a reply to a different post, do you wish to use it?")
}
}
@ViewBuilder
private var navigationView: some View {
if #available(iOS 16.0, *) {
NavigationStack {
navigationRoot
}
} else {
NavigationView {
navigationRoot
}
.navigationViewStyle(.stack)
}
}
private var navigationRoot: some View {
DraftsListView(currentDraft: currentDraft, isShowingDrafts: $isShowingDrafts, accountInfo: accountInfo, selectDraft: selectDraft)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
}
}
private struct DraftsListView: View {
let currentDraft: Draft
@Binding var isShowingDrafts: Bool
let accountInfo: UserAccountInfo
let selectDraft: (Draft) -> Void
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
@Environment(\.composeUIConfig.userActivityForDraft) private var userActivityForDraft
var body: some View {
List {
ForEach(drafts) { draft in
Button {
self.selectDraft(draft)
} label: {
DraftRowView(draft: draft)
}
.contextMenu {
Button(role: .destructive) {
deleteDraft(draft)
} label: {
Label("Delete Draft", systemImage: "trash")
}
}
.onDrag {
userActivityForDraft(draft) ?? NSItemProvider()
}
}
.onDelete { indices in
indices.map { drafts[$0] }.forEach(deleteDraft)
}
}
.listStyle(.plain)
.navigationTitle("Drafts")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
cancelButton
}
}
.onAppear {
drafts.nsPredicate = NSPredicate(
format: "accountID == %@ AND id != %@ AND lastModified != nil",
accountInfo.id,
currentDraft.id as NSUUID
)
}
}
private var cancelButton: some View {
Button {
isShowingDrafts = false
} label: {
Text("Cancel")
}
}
private func deleteDraft(_ draft: Draft) {
DraftsPersistentContainer.shared.viewContext.delete(draft)
}
}
private struct DraftRowView: View {
@ObservedObject var draft: Draft
var body: some View {
HStack {
VStack(alignment: .leading) {
if draft.editedStatusID != nil {
// shouldn't happen unless the app crashed/was killed during an edit
Text("Edit")
.font(.body.bold())
.foregroundStyle(.orange)
}
if draft.contentWarningEnabled && !draft.contentWarning.isEmpty {
Text(draft.contentWarning)
.font(.body.bold())
.foregroundStyle(.secondary)
}
Text(draft.text)
.font(.body)
if draft.pollEnabled {
Text("Poll")
.font(.body.bold())
}
HStack(spacing: 8) {
ForEach(draft.draftAttachments) { attachment in
AttachmentThumbnailView(attachment: attachment, thumbnailSize: CGSize(width: 50, height: 50))
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 5))
.frame(height: 50)
}
}
}
Spacer()
if let lastModified = draft.lastModified {
Text(lastModified.formatted(.abbreviatedTimeAgo))
.font(.body)
.foregroundStyle(.secondary)
}
}
}
}

View File

@ -1,140 +0,0 @@
//
// EmojiTextField.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import SwiftUI
struct EmojiTextField: UIViewRepresentable {
typealias UIViewType = UITextField
@Environment(\.composeUIConfig.fillColor) private var fillColor
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeInputBox) private var inputBox
@Binding var text: String
let placeholder: String
let maxLength: Int?
let focusNextView: (() -> Void)?
init(text: Binding<String>, placeholder: String, maxLength: Int?, focusNextView: (() -> Void)? = nil) {
self._text = text
self.placeholder = placeholder
self.maxLength = maxLength
self.focusNextView = focusNextView
}
func makeUIView(context: Context) -> UITextField {
let view = UITextField()
view.borderStyle = .roundedRect
view.font = .preferredFont(forTextStyle: .body)
view.adjustsFontForContentSizeCategory = true
view.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
.foregroundColor: UIColor.secondaryLabel,
])
context.coordinator.textField = view
view.delegate = context.coordinator
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered)
// otherwise when the text gets too wide it starts expanding the ComposeView
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view
}
func updateUIView(_ uiView: UITextField, context: Context) {
if text != uiView.text {
uiView.text = text
}
if placeholder != uiView.attributedPlaceholder?.string {
uiView.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
.foregroundColor: UIColor.secondaryLabel,
])
}
context.coordinator.text = $text
context.coordinator.maxLength = maxLength
context.coordinator.focusNextView = focusNextView
#if !os(visionOS)
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
#endif
}
func makeCoordinator() -> Coordinator {
let coordinator = Coordinator(text: $text, focusNextView: focusNextView)
DispatchQueue.main.async {
inputBox.wrappedValue = coordinator
}
return coordinator
}
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
var text: Binding<String>
var focusNextView: (() -> Void)?
var maxLength: Int?
@Published var autocompleteState: AutocompleteState?
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
weak var textField: UITextField?
init(text: Binding<String>, focusNextView: (() -> Void)?, maxLength: Int? = nil) {
self.text = text
self.focusNextView = focusNextView
self.maxLength = maxLength
}
@objc func didChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? ""
}
@objc func returnKeyPressed() {
focusNextView?()
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if let maxLength {
return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength
} else {
return true
}
}
func textFieldDidBeginEditing(_ textField: UITextField) {
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
func textFieldDidEndEditing(_ textField: UITextField) {
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
func textFieldDidChangeSelection(_ textField: UITextField) {
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
// MARK: ComposeInput
var toolbarElements: [ToolbarElement] { [.emojiPicker] }
var textInputMode: UITextInputMode? {
textField?.textInputMode
}
func applyFormat(_ format: StatusFormat) {
}
func beginAutocompletingEmoji() {
textField?.insertText(":")
}
func autocomplete(with string: String) {
textField?.autocomplete(with: string, permittedModes: .emojis, autocompleteState: &autocompleteState)
}
}
}

View File

@ -1,221 +0,0 @@
//
// LanguagePicker.swift
// ComposeUI
//
// Created by Shadowfacts on 5/4/23.
//
import SwiftUI
@available(iOS 16.0, *)
struct LanguagePicker: View {
@Binding var draftLanguage: String?
@Binding var hasChangedSelection: Bool
@State private var isShowingSheet = false
private var codeFromDraft: Locale.LanguageCode? {
draftLanguage.map(Locale.LanguageCode.init(_:))
}
private var codeFromActiveInputMode: Locale.LanguageCode? {
UITextInputMode.activeInputModes.first.flatMap(Self.codeFromInputMode(_:))
}
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
guard let bcp47Lang = mode.primaryLanguage,
!bcp47Lang.isEmpty else {
return nil
}
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: min(3, bcp47Lang.count))]
if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
}
let identifier = String(maybeIso639Code)
// mul (for multiple languages) and unk (unknown) are ISO codes, but not ones that akkoma permits, so we ignore them on all platforms
guard identifier != "mul",
identifier != "und" else {
return nil
}
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage {
return code
} else {
return nil
}
}
private var codeFromPreferredLanguages: Locale.LanguageCode? {
if let identifier = Locale.preferredLanguages.first,
case let code = Locale.LanguageCode(identifier),
code.isISOLanguage {
return code
} else {
return nil
}
}
private var languageCode: Binding<Locale.LanguageCode> {
Binding {
return codeFromDraft ?? codeFromActiveInputMode ?? codeFromPreferredLanguages ?? .english
} set: { newValue in
draftLanguage = newValue.identifier
}
}
var body: some View {
Button {
isShowingSheet = true
} label: {
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
}
.accessibilityLabel("Post Language")
.padding(5)
.hoverEffect()
.sheet(isPresented: $isShowingSheet) {
NavigationStack {
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
}
.presentationDetents([.large, .medium])
}
}
}
@available(iOS 16.0, *)
private struct LanguagePickerList: View {
@Binding var languageCode: Locale.LanguageCode
@Binding var hasChangedSelection: Bool
@Binding var isPresented: Bool
@Environment(\.composeUIConfig.groupedBackgroundColor) private var groupedBackgroundColor
@Environment(\.composeUIConfig.groupedCellBackgroundColor) private var groupedCellBackgroundColor
@State private var recentLangs: [Lang] = []
@State private var langs: [Lang] = []
@State private var filteredLangs: [Lang]?
@State private var query = ""
private var defaults: UserDefaults {
UserDefaults(suiteName: "group.space.vaccor.Tusker") ?? .standard
}
private var recentIdentifiers: [String] {
get {
defaults.object(forKey: "LanguagePickerRecents") as? [String] ?? []
}
nonmutating set {
defaults.set(newValue, forKey: "LanguagePickerRecents")
}
}
var body: some View {
List {
Section {
ForEach(recentLangs) { lang in
button(for: lang)
}
.listRowBackground(groupedCellBackgroundColor)
} header: {
Text("Recently Used")
}
Section {
ForEach(filteredLangs ?? langs) { lang in
button(for: lang)
}
.listRowBackground(groupedCellBackgroundColor)
} header: {
Text("All Languages")
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
.searchable(text: $query)
#if !os(visionOS)
.scrollDismissesKeyboard(.interactively)
#endif
.navigationTitle("Post Language")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
isPresented = false
}
}
}
.onAppear {
// make sure recents always contains the currently selected lang
let recents = addRecentLang(languageCode)
recentLangs = recents
.filter { $0 != "mul" && $0 != "und" }
.map { Lang(code: .init($0)) }
.sorted { $0.name < $1.name }
langs = Locale.LanguageCode.isoLanguageCodes
.filter { $0.identifier != "mul" && $0.identifier != "und" }
.map { Lang(code: $0) }
.sorted { $0.name < $1.name }
}
#if os(visionOS)
.onChange(of: query, initial: true) {
filteredLangsChanged(query: query)
}
#else
.onChange(of: query) { newValue in
filteredLangsChanged(query: newValue)
}
#endif
}
private func filteredLangsChanged(query: String) {
if query.isEmpty {
filteredLangs = nil
} else {
filteredLangs = langs.filter {
$0.name.localizedCaseInsensitiveContains(query) || $0.code.identifier.localizedCaseInsensitiveContains(query)
}
}
}
@discardableResult
private func addRecentLang(_ code: Locale.LanguageCode) -> [String] {
var recents = recentIdentifiers
if !recents.contains(languageCode.identifier) {
recents.insert(languageCode.identifier, at: 0)
if recents.count > 5 {
recents = Array(recents[..<5])
}
recentIdentifiers = recents
}
return recents
}
private func button(for lang: Lang) -> some View {
Button {
languageCode = lang.code
hasChangedSelection = true
isPresented = false
addRecentLang(lang.code)
} label: {
HStack {
Text(lang.name)
Spacer()
if lang.code == languageCode {
Image(systemName: "checkmark")
}
}
}
}
struct Lang: Identifiable {
let code: Locale.LanguageCode
let name: String
var id: String {
code.identifier
}
init(code: Locale.LanguageCode) {
self.code = code
self.name = Locale.current.localizedString(forLanguageCode: code.identifier) ?? code.identifier
}
}
}

View File

@ -1,361 +0,0 @@
//
// NewMainTextView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/11/24.
//
import SwiftUI
import Pachyderm
import TuskerPreferences
struct NewMainTextView: View {
static var minHeight: CGFloat { 150 }
@Binding var value: String
@FocusState.Binding var focusedField: FocusableField?
var handleAttachmentDrop: ([NSItemProvider]) -> Void
@State private var becomeFirstResponder = true
var body: some View {
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop)
.focused($focusedField, equals: .body)
.modifier(FocusedInputModifier())
.overlay(alignment: .topLeading) {
if value.isEmpty {
PlaceholderView()
}
}
}
}
private struct NewMainTextViewRepresentable: UIViewRepresentable {
@Binding var value: String
@Binding var becomeFirstResponder: Bool
var handleAttachmentDrop: ([NSItemProvider]) -> Void
@Environment(\.composeInputBox) private var inputBox
@Environment(\.isEnabled) private var isEnabled
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
@PreferenceObserving(\.$useTwitterKeyboard) private var useTwitterKeyboard
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
@PreferenceObserving(\.$statusContentType) private var statusContentType
func makeUIView(context: Context) -> WrappedTextView {
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
let view = if #available(iOS 16.0, *) {
WrappedTextView(usingTextLayoutManager: true)
} else {
WrappedTextView()
}
view.addInteraction(UIDropInteraction(delegate: context.coordinator))
view.delegate = context.coordinator
view.adjustsFontForContentSizeCategory = true
view.textContainer.lineBreakMode = .byWordWrapping
view.isScrollEnabled = false
view.typingAttributes = [
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
]
view.backgroundColor = nil
// view.layer.cornerRadius = 5
// view.layer.cornerCurve = .continuous
// view.layer.shadowColor = UIColor.black.cgColor
// view.layer.shadowOpacity = 0.15
// view.layer.shadowOffset = .zero
// view.layer.shadowRadius = 1
if textSelectionStartsAtBeginning {
// Update the text immediately so that the selection isn't invalidated by the text changing.
context.coordinator.updateTextViewTextIfNecessary(value, textView: view)
view.selectedTextRange = view.textRange(from: view.beginningOfDocument, to: view.beginningOfDocument)
}
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) {
context.coordinator.value = $value
context.coordinator.handleAttachmentDrop = handleAttachmentDrop
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
uiView.isEditable = isEnabled
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
uiView.contentType = statusContentType
// #if !os(visionOS)
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
// #endif
// Trying to set this with the @FocusState binding in onAppear results in the
// keyboard not appearing until after the sheet presentation animation completes :/
if becomeFirstResponder {
uiView.becomeFirstResponder()
DispatchQueue.main.async {
becomeFirstResponder = false
}
}
}
func makeCoordinator() -> WrappedTextViewCoordinator {
let coordinator = WrappedTextViewCoordinator(value: $value, handleAttachmentDrop: handleAttachmentDrop)
// DispatchQueue.main.async {
// inputBox.wrappedValue = coordinator
// }
return coordinator
}
@available(iOS 16.0, *)
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIViewType, context: Context) -> CGSize? {
let width = proposal.width ?? 10
let size = uiView.sizeThatFits(CGSize(width: width, height: 0))
return CGSize(width: width, height: max(NewMainTextView.minHeight, size.height))
}
}
// laxer than the CharacterCounter regex, because we want to find mentions that are being typed but aren't yet complete (e.g., "@a@b")
private let mentionRegex = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+)?", options: .caseInsensitive)
private final class WrappedTextViewCoordinator: NSObject {
private static let attachment: NSTextAttachment = {
let font = UIFont.systemFont(ofSize: 20)
let size = /*1.4 * */font.capHeight
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
let image = renderer.image { ctx in
UIColor.systemRed.setFill()
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
}
let attachment = NSTextAttachment(image: image)
attachment.bounds = CGRect(x: 0, y: -1, width: size + 2, height: size)
attachment.lineLayoutPadding = 1
return attachment
}()
var value: Binding<String>
var handleAttachmentDrop: ([NSItemProvider]) -> Void
init(value: Binding<String>, handleAttachmentDrop: @escaping ([NSItemProvider]) -> Void) {
self.value = value
self.handleAttachmentDrop = handleAttachmentDrop
}
private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String {
attributedText.string.replacingOccurrences(of: "\u{FFFC}", with: "")
}
@MainActor
private func attributedTextFromPlain(_ text: String) -> NSAttributedString {
let str = NSMutableAttributedString(string: text)
str.addAttributes([
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
], range: NSRange(location: 0, length: str.length))
if Preferences.shared.hasFeatureFlag(.composeTextAttributes) {
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
let range = NSRange(location: match.range.location, length: match.range.length + 1)
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: range)
}
}
return str
}
@MainActor
func updateTextViewTextIfNecessary(_ text: String, textView: UITextView) {
if text != plainTextFromAttributed(textView.attributedText) {
textView.attributedText = attributedTextFromPlain(text)
}
}
@MainActor
private func updateAttributes(in textView: UITextView) {
guard Preferences.shared.hasFeatureFlag(.composeTextAttributes) else {
return
}
let str = NSMutableAttributedString(attributedString: textView.attributedText!)
var changed = false
var cursorOffset = 0
// remove existing mentions that aren't valid
str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in
var substr = (str.string as NSString).substring(with: range)
let hasTextAttachment = substr.unicodeScalars.first == UnicodeScalar(NSTextAttachment.character)
if hasTextAttachment {
substr = String(substr.dropFirst())
}
if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
changed = true
str.removeAttribute(.mention, range: range)
str.addAttribute(.foregroundColor, value: UIColor.label, range: range)
if hasTextAttachment {
str.deleteCharacters(in: NSRange(location: range.location, length: 1))
cursorOffset -= 1
}
}
}
// add mentions for those missing
let mentionMatches = mentionRegex.matches(in: str.string, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() {
var attributeRange = NSRange()
let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange)
// the attribute range should always be one greater than the match range, to account for the text attachment
if attribute == nil || attributeRange.length <= match.range.length {
changed = true
let newAttributeRange: NSRange
if attribute == nil {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1)
cursorOffset += 1
} else {
newAttributeRange = match.range
}
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: newAttributeRange)
}
}
if changed {
let selection = textView.selectedRange
textView.attributedText = str
textView.selectedRange = NSRange(location: selection.location + cursorOffset, length: selection.length)
}
}
}
extension WrappedTextViewCoordinator: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
if textView.text.isEmpty {
textView.typingAttributes = [
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
]
} else {
updateAttributes(in: textView)
}
let plain = plainTextFromAttributed(textView.attributedText)
if plain != value.wrappedValue {
value.wrappedValue = plain
}
}
func textViewDidChangeSelection(_ textView: UITextView) {
}
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
guard let textView = textView as? WrappedTextView else {
return nil
}
var actions = suggestedActions
if textView.contentType != .plain,
let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) {
if range.length > 0 {
let formatMenu = suggestedActions[index] as! UIMenu
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { _ in
// TODO
}
})
actions[index] = newFormatMenu
} else {
actions.remove(at: index)
}
}
// TODO
// if range.length == 0 {
// actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
// self?.controller.shouldEmojiAutocompletionBeginExpanded = true
// self?.beginAutocompletingEmoji()
// }))
// }
return UIMenu(children: actions)
}
}
//extension WrappedTextViewCoordinator: ComposeInput {
//
//}
// Because of FB11790805, we can't handle drops for the entire screen.
// The onDrop modifier also doesn't work when applied to the NewMainTextView.
// So, manually add the UIInteraction to at least handle that.
extension WrappedTextViewCoordinator: UIDropInteractionDelegate {
func dropInteraction(_ interaction: UIDropInteraction, canHandle session: any UIDropSession) -> Bool {
session.canLoadObjects(ofClass: DraftAttachment.self)
}
func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession) -> UIDropProposal {
UIDropProposal(operation: .copy)
}
func dropInteraction(_ interaction: UIDropInteraction, performDrop session: any UIDropSession) {
handleAttachmentDrop(session.items.map(\.itemProvider))
}
}
private final class WrappedTextView: UITextView {
var contentType: StatusContentType = .plain
private static var formattingActions: [Selector] {
[#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if Self.formattingActions.contains(action) {
return contentType != .plain
}
return super.canPerformAction(action, withSender: sender)
}
override func toggleBoldface(_ sender: Any?) {
// TODO
}
override func toggleItalics(_ sender: Any?) {
// TODO
}
override func validate(_ command: UICommand) {
super.validate(command)
if Self.formattingActions.contains(command.action),
contentType != .plain {
command.attributes.remove(.disabled)
}
}
}
private extension NSAttributedString.Key {
static let mention = NSAttributedString.Key("Tusker.ComposeUI.mention")
}
private struct PlaceholderView: View {
@State private var placeholder: PlaceholderController.PlaceholderView = PlaceholderController.makePlaceholderView()
@ScaledMetric private var fontSize = 20
private var placeholderOffset: CGSize {
#if os(visionOS)
CGSize(width: 8, height: 8)
#else
CGSize(width: 4, height: 8)
#endif
}
var body: some View {
placeholder
.font(.system(size: fontSize))
.foregroundStyle(.secondary)
.offset(placeholderOffset)
.accessibilityHidden(true)
.allowsHitTesting(false)
}
}

View File

@ -1,29 +0,0 @@
//
// BackgroundPlatterView.swift
// ComposeUI
//
// Created by Shadowfacts on 1/29/25.
//
import SwiftUI
extension View {
func composePlatterBackground() -> some View {
self.background {
PlatterBackgroundView()
}
}
}
private struct PlatterBackgroundView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
var body: some View {
RoundedRectangle(cornerRadius: 5)
// TODO: fillColor is semi-transparent in pure-black dark mode, but it needs to be fully opaque for the poll transition to look right
// .fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground))
.fill(Color(uiColor: .secondarySystemBackground))
}
}

View File

@ -1,182 +0,0 @@
//
// PollEditor.swift
// ComposeUI
//
// Created by Shadowfacts on 1/29/25.
//
import SwiftUI
import InstanceFeatures
import TuskerComponents
struct PollEditor: View {
@ObservedObject var poll: Poll
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Poll")
.font(.headline)
ForEach(poll.pollOptions) { option in
PollOptionEditor(poll: poll, option: option, focusedField: $focusedField)
.transition(.opacity)
}
AddOptionButton(poll: poll, focusedField: $focusedField)
Toggle("Multiple choice", isOn: $poll.multiple)
.padding(.bottom, -8)
HStack {
Text("Duration")
Spacer()
PollDurationPicker(poll: poll)
.frame(minHeight: 32)
}
}
.padding(.all.subtracting(.bottom), 8)
.padding(.bottom, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.composePlatterBackground()
}
}
private struct AddOptionButton: View {
@ObservedObject var poll: Poll
@FocusState.Binding var focusedField: FocusableField?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
private var canAddOption: Bool {
if let max = instanceFeatures.maxPollOptionsCount {
poll.options.count < max
} else {
true
}
}
var body: some View {
Button(action: addOption) {
Label("Add Option", systemImage: "plus")
}
.buttonStyle(.borderless)
.disabled(!canAddOption)
}
private func addOption() {
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
option.poll = poll
poll.options.add(option)
focusedField = .pollOption(option.id)
}
}
private struct PollDurationPicker: View {
@ObservedObject var poll: Poll
@State var duration: PollDuration
init(poll: Poll) {
self.poll = poll
self._duration = State(wrappedValue: .fromTimeInterval(poll.duration) ?? .oneDay)
}
private var options: [MenuPicker<PollDuration>.Option] {
PollDuration.allCases.map {
.init(value: $0, title: PollDuration.formatter.string(from: $0.timeInterval)!)
}
}
var body: some View {
MenuPicker(selection: $duration, options: options)
#if os(visionOS)
.onChange(of: duration) {
poll.duration = duration.timeInterval
}
#else
.onChange(of: duration) { newValue in
poll.duration = newValue.timeInterval
}
#endif
}
}
private struct PollOptionEditor: View {
@ObservedObject var poll: Poll
@ObservedObject var option: PollOption
@FocusState.Binding var focusedField: FocusableField?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var placeholder: String {
let index = poll.options.index(of: option)
if index != NSNotFound {
return "Option \(index + 1)"
} else {
return ""
}
}
var body: some View {
HStack(spacing: 4) {
Button(role: .destructive, action: removeOption) {
Label("Remove option", systemImage: "minus")
}
.labelStyle(.iconOnly)
.buttonStyle(PollOptionButtonStyle())
.accessibilityLabel("Remove option")
.disabled(poll.options.count == 1)
EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars, focusNextView: self.focusNextOption)
.focused($focusedField, equals: .pollOption(option.id))
}
}
private func removeOption() {
let index = poll.options.index(of: option)
if index != NSNotFound && poll.options.count > 1 {
var array = poll.options.array
array.remove(at: index)
poll.options = NSMutableOrderedSet(array: array)
// TODO: does this leave dangling PollOptions in the managed object context?
if case .pollOption(let id) = focusedField,
id == option.id {
let indexToFocus = index > 0 ? index - 1 : 0
focusedField = .pollOption(poll.pollOptions[indexToFocus].id)
}
}
}
private func focusNextOption() {
let index = poll.options.index(of: option)
if index != NSNotFound && index + 1 < poll.options.count {
let nextOption = poll.options.object(at: index + 1) as! PollOption
focusedField = .pollOption(nextOption.objectID)
}
}
}
private struct PollOptionButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.font(.body.bold())
.padding(4)
.frame(width: 20, height: 20)
.background {
let color = if isEnabled {
if configuration.role == .destructive {
Color.red
} else {
Color.green
}
} else {
Color.gray
}
let opacity = configuration.isPressed ? 0.8 : 1
Circle()
.fill(color.opacity(opacity))
}
}
}

View File

@ -1,21 +0,0 @@
//
// PostProgressView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
struct PostProgressView: View {
@ObservedObject var poster: PostService
var body: some View {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
}
}
//#Preview {
// PostProgressView()
//}

View File

@ -1,156 +0,0 @@
//
// ReplyStatusView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
import Pachyderm
import TuskerComponents
import TuskerPreferences
struct ReplyStatusView: View {
let status: any StatusProtocol
let rowTopInset: CGFloat
let globalFrameOutsideList: CGRect
@PreferenceObserving(\.$avatarStyle) private var avatarStyle
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel
@Environment(\.composeUIConfig.replyContentView) private var replyContentView
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
@State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat?
private let horizSpacing: CGFloat = 8
var body: some View {
HStack(alignment: .top, spacing: horizSpacing) {
GeometryReader(content: self.replyAvatarImage)
.frame(width: 50)
VStack(alignment: .leading, spacing: 0) {
HStack {
displayNameLabel(status.account, .body, 17)
.lineLimit(1)
.layoutPriority(1)
Text(verbatim: "@\(status.account.acct)")
.font(.body.weight(.light))
.foregroundColor(.secondary)
.lineLimit(1)
Spacer()
}
.background(GeometryReader { proxy in
Color.clear
.preference(key: DisplayNameHeightPrefKey.self, value: proxy.size.height)
.onPreferenceChange(DisplayNameHeightPrefKey.self) { newValue in
displayNameHeight = newValue
}
})
replyContentView(status) { newHeight in
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
// and it ends up partially behind the header
DispatchQueue.main.async {
contentHeight = newHeight
}
}
.frame(height: contentHeight ?? 0)
}
}
.frame(minHeight: 50, alignment: .top)
}
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
// using a coordinate space declared outside of the List doesn't work, so we do the math ourselves
let globalFrame = geometry.frame(in: .global)
let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY)
// add rowTopInset so that the image is always at least rowTopInset away from the top
var offset = scrollOffset + rowTopInset
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
offset = max(offset, 0)
// subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view
let maxOffset = max((contentHeight ?? 0) + (displayNameHeight ?? 0) - 50, 0)
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
offset = min(offset, maxOffset)
return AvatarContainerRepresentable(offset: offset) {
AvatarImageView(
url: status.account.avatar,
size: 50,
style: avatarStyle == .circle ? .circle : .roundRect,
fetchAvatar: fetchAvatar
)
}
.frame(width: 50, height: 50)
.accessibilityHidden(true)
}
}
private struct DisplayNameHeightPrefKey: SwiftUI.PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
// This whole dance is necessary so that the offset can be animatable from
// UIKit animations, like TextViewCaretScrolling.
private struct AvatarContainerRepresentable<Content: View>: UIViewControllerRepresentable {
let offset: CGFloat
@ViewBuilder let content: Content
func makeUIViewController(context: Context) -> Controller {
Controller(host: UIHostingController(rootView: content))
}
func updateUIViewController(_ uiViewController: Controller, context: Context) {
uiViewController.host.rootView = content
uiViewController.host.view.transform = CGAffineTransform(translationX: 0, y: offset)
}
// This extra layer is necessary because applying a transform to the
// representable's VC's view doesn't seem to have an effect.
class Controller: UIViewController {
let host: UIHostingController<Content>
init(host: UIHostingController<Content>) {
self.host = host
super.init(nibName: nil, bundle: nil)
addChild(host)
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(host.view)
host.view.frame = view.bounds
host.didMove(toParent: self)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
}
struct NewReplyStatusView: View {
let draft: Draft
let mastodonController: any ComposeMastodonContext
var body: some View {
if let id = draft.inReplyToID,
let status = mastodonController.fetchStatus(id: id) {
ReplyStatusView(
status: status,
rowTopInset: 8,
globalFrameOutsideList: .zero
)
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
.id(id)
}
}
}

View File

@ -1,29 +0,0 @@
//
// WrappedProgressView.swift
// Tusker
//
// Created by Shadowfacts on 8/30/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct WrappedProgressView: UIViewRepresentable {
typealias UIViewType = UIProgressView
let value: Int
let total: Int
func makeUIView(context: Context) -> UIProgressView {
return UIProgressView(progressViewStyle: .bar)
}
func updateUIView(_ uiView: UIProgressView, context: Context) {
if total > 0 {
let progress = Float(value) / Float(total)
uiView.setProgress(progress, animated: true)
} else {
uiView.setProgress(0, animated: true)
}
}
}

View File

@ -1,25 +0,0 @@
//
// FuzzyMatcherTests.swift
// ComposeUITests
//
// Created by Shadowfacts on 10/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import XCTest
@testable import ComposeUI
class FuzzyMatcherTests: XCTestCase {
func testExample() throws {
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6)
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4)
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6)
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2)
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1)
XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score)
}
}

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@ -23,12 +23,9 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "Duckable",
dependencies: [],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
// .testTarget(
// name: "DuckableTests",
// dependencies: ["Duckable"]),
dependencies: []),
.testTarget(
name: "DuckableTests",
dependencies: ["Duckable"]),
]
)

View File

@ -7,9 +7,8 @@
import UIKit
@MainActor
public protocol DuckableViewController: UIViewController {
func duckableViewControllerShouldDuck() -> DuckAttemptAction
var duckableDelegate: DuckableViewControllerDelegate? { get set }
func duckableViewControllerMayAttemptToDuck()
@ -19,25 +18,22 @@ public protocol DuckableViewController: UIViewController {
}
extension DuckableViewController {
public func duckableViewControllerShouldDuck() -> DuckAttemptAction { .duck }
public func duckableViewControllerMayAttemptToDuck() {}
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
public func duckableViewControllerDidFinishAnimatingDuck() {}
}
public enum DuckAttemptAction {
case duck
case dismiss
case block
public protocol DuckableViewControllerDelegate: AnyObject {
func duckableViewControllerWillDismiss(animated: Bool)
}
extension UIViewController {
@available(iOS 16.0, *)
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false, completion: (() -> Void)? = nil) -> Bool {
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
var cur: UIViewController? = self
while let vc = cur {
if let container = vc as? DuckableContainerViewController {
container._presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: completion)
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
return true
} else {
cur = vc.parent

View File

@ -63,9 +63,6 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
presented.view.layer.opacity = 0
}
fadeAnimator.addCompletion { _ in
presented.view.layer.opacity = 1
}
fadeAnimator.startAnimation(afterDelay: 0.3)
} else {
@ -83,7 +80,6 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
presented.view.layer.opacity = 0
}
fadeAnimator.addCompletion { _ in
presented.view.layer.opacity = 1
duckable.duckableViewControllerDidFinishAnimatingDuck()
transitionContext.completeTransition(true)
}

View File

@ -11,7 +11,7 @@ let duckedCornerRadius: CGFloat = 10
let detentHeight: CGFloat = 44
@available(iOS 16.0, *)
public class DuckableContainerViewController: UIViewController {
public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate {
public let child: UIViewController
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 {
if animated,
case .ducked(_, placeholder: let placeholder) = state {
#if !os(visionOS)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
#endif
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
let origConstant = placeholder.topConstraint.constant
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
@ -89,18 +87,17 @@ public class DuckableContainerViewController: UIViewController {
}
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = self
present(viewController, animated: animated) {
viewController.duckableDelegate = self
let nav = UINavigationController(rootViewController: viewController)
nav.modalPresentationStyle = .custom
nav.transitioningDelegate = self
present(nav, animated: animated) {
self.configureChildForDuckedPlaceholder()
completion?()
}
}
func dismissalTransitionWillBegin() {
guard case .presentingDucked(_, _) = state else {
return
}
public func duckableViewControllerWillDismiss(animated: Bool) {
state = .idle
bottomConstraint.isActive = false
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
@ -139,18 +136,10 @@ public class DuckableContainerViewController: UIViewController {
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
return
}
switch viewController.duckableViewControllerShouldDuck() {
case .duck:
let placeholder = createPlaceholderForDuckedViewController(viewController)
state = .ducked(viewController, placeholder: placeholder)
configureChildForDuckedPlaceholder()
dismiss(animated: true)
case .block:
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
case .dismiss:
// duckableViewControllerWillDismiss()
dismiss(animated: true)
}
let placeholder = createPlaceholderForDuckedViewController(viewController)
state = .ducked(viewController, placeholder: placeholder)
configureChildForDuckedPlaceholder()
dismiss(animated: true)
}
private func configureChildForDuckedPlaceholder() {
@ -159,7 +148,6 @@ public class DuckableContainerViewController: UIViewController {
bottomConstraint.isActive = true
child.view.layer.cornerRadius = duckedCornerRadius
child.view.layer.cornerCurve = .continuous
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
child.view.layer.masksToBounds = true
}
@ -193,7 +181,7 @@ public class DuckableContainerViewController: UIViewController {
@available(iOS 16.0, *)
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
let controller = DuckableSheetPresentationController(presentedViewController: presented, presenting: presenting)
let controller = UISheetPresentationController(presentedViewController: presented, presenting: presenting)
controller.delegate = self
controller.prefersGrabberVisible = true
controller.selectedDetentIdentifier = .large
@ -219,14 +207,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, *)
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
@ -256,3 +236,4 @@ extension DuckableContainerViewController: UISheetPresentationControllerDelegate
}
}
}

View File

@ -14,8 +14,6 @@ class DuckedPlaceholderViewController: UIViewController {
var topConstraint: NSLayoutConstraint!
private var titleObservation: NSKeyValueObservation?
init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) {
self.owner = owner
@ -23,12 +21,8 @@ class DuckedPlaceholderViewController: UIViewController {
let item = UINavigationItem()
item.title = duckableViewController.navigationItem.title
assert(duckableViewController.navigationItem.titleView == nil)
item.titleView = duckableViewController.navigationItem.titleView
navBar.setItems([item], animated: false)
titleObservation = duckableViewController.navigationItem.observe(\.title, changeHandler: { _, _ in
item.title = duckableViewController.navigationItem.title
})
}
required init?(coder: NSCoder) {

View File

@ -1,8 +0,0 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -1,36 +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(.v15),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "GalleryVC",
targets: ["GalleryVC"]),
],
dependencies: [
.package(path: "../TuskerComponents"),
],
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",
dependencies: ["TuskerComponents"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget(
name: "GalleryVCTests",
dependencies: ["GalleryVC"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
]
)

View File

@ -1,92 +0,0 @@
//
// FallbackGalleryContentViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 3/18/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import QuickLook
private class FallbackGalleryContentViewController: QLPreviewController {
private let previewItem = GalleryPreviewItem()
init(url: URL) {
super.init(nibName: nil, bundle: nil)
self.previewItem.previewItemURL = url
dataSource = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .dark
navigationItem.rightBarButtonItems = [
UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(closePressed))
]
}
@objc private func closePressed() {
self.dismiss(animated: true)
}
}
extension FallbackGalleryContentViewController: QLPreviewControllerDataSource {
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem {
previewItem
}
}
public class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController {
public init(url: URL) {
super.init(nibName: nil, bundle: nil)
self.viewControllers = [FallbackGalleryContentViewController(url: url)]
}
public override func viewDidLoad() {
super.viewDidLoad()
container?.disableGalleryScrollAndZoom()
}
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: GalleryContentViewController
public weak var container: (any GalleryContentViewControllerContainer)?
public var contentSize: CGSize {
.zero
}
public var activityItemsForSharing: [Any] {
[]
}
public var caption: String? {
nil
}
public var presentationAnimation: GalleryContentPresentationAnimation {
.fade
}
}
private class GalleryPreviewItem: NSObject, QLPreviewItem {
var previewItemURL: URL? = nil
}

View File

@ -1,124 +0,0 @@
//
// ImageGalleryContentViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 3/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import TuskerComponents
@preconcurrency import VisionKit
open class ImageGalleryContentViewController: UIViewController, GalleryContentViewController {
public let caption: String?
public var image: UIImage {
didSet {
imageView?.image = image
}
}
let gifController: GIFController?
private var imageView: GIFImageView!
@available(iOS 16.0, macCatalyst 17.0, *)
private static let analyzer = ImageAnalyzer()
private var _analysisInteraction: AnyObject?
@available(iOS 16.0, macCatalyst 17.0, *)
private var analysisInteraction: ImageAnalysisInteraction? { _analysisInteraction as? ImageAnalysisInteraction }
public init(image: UIImage, caption: String?, gifController: GIFController?) {
self.caption = caption
self.image = image
self.gifController = gifController
super.init(nibName: nil, bundle: nil)
preferredContentSize = image.size
}
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
imageView = GIFImageView(image: image)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
imageView.isUserInteractionEnabled = true
view.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
imageView.topAnchor.constraint(equalTo: view.topAnchor),
imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
if let gifController {
gifController.attach(to: imageView)
}
if gifController == nil,
#available(iOS 16.0, macCatalyst 17.0, *) {
let interaction = ImageAnalysisInteraction(self)
self._analysisInteraction = interaction
interaction.preferredInteractionTypes = .automatic
imageView.addInteraction(interaction)
Task {
do {
let result = try await ImageGalleryContentViewController.analyzer.analyze(image, configuration: ImageAnalyzer.Configuration([.text, .machineReadableCode]))
interaction.analysis = result
} catch {
// if analysis fails, we just don't show anything
}
}
}
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let gifController {
gifController.startAnimating()
}
}
// MARK: GalleryContentViewController
public weak var container: (any GalleryContentViewControllerContainer)?
public var contentSize: CGSize {
image.size
}
open var activityItemsForSharing: [Any] {
return [image]
}
public var presentationAnimation: GalleryContentPresentationAnimation {
gifController != nil ? .fromSourceViewWithoutSnapshot : .fromSourceView
}
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if #available(iOS 16.0, macCatalyst 17.0, *),
let analysisInteraction {
analysisInteraction.setSupplementaryInterfaceHidden(!visible, animated: animated)
}
}
public func setInsetForBottomControls(_ inset: CGFloat) {
if #available(iOS 16.0, macCatalyst 17.0, *),
let analysisInteraction {
analysisInteraction.supplementaryInterfaceContentInsets = UIEdgeInsets(top: 0, left: 0, bottom: inset, right: 0)
}
}
}
@available(iOS 16.0, macCatalyst 17.0, *)
extension ImageGalleryContentViewController: ImageAnalysisInteractionDelegate {
public func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool {
return container?.galleryControlsVisible ?? true
}
}

View File

@ -1,132 +0,0 @@
//
// LoadingGalleryContentViewController.swift
// Tusker
//
// Created by Shadowfacts on 3/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
public class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
private let fallbackCaption: String?
private let provider: () async -> (any GalleryContentViewController)?
private var wrapped: (any GalleryContentViewController)!
public weak var container: GalleryContentViewControllerContainer?
public var contentSize: CGSize {
wrapped?.contentSize ?? .zero
}
public var activityItemsForSharing: [Any] {
wrapped?.activityItemsForSharing ?? []
}
public var caption: String? {
wrapped?.caption ?? fallbackCaption
}
public var presentationAnimation: GalleryContentPresentationAnimation {
wrapped?.presentationAnimation ?? .fade
}
public var hideControlsOnZoom: Bool {
wrapped?.hideControlsOnZoom ?? true
}
public var showBelowSafeArea: Bool {
wrapped?.showBelowSafeArea ?? true
}
public init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
self.fallbackCaption = caption
self.provider = provider
super.init(nibName: nil, bundle: nil)
}
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
container?.setGalleryContentLoading(true)
Task { @MainActor in
if let wrapped = await provider() {
self.wrapped = wrapped
wrapped.container = container
wrapped.setControlsVisible(container?.galleryControlsVisible ?? false, animated: false, dueToUserInteraction: false)
addChild(wrapped)
wrapped.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(wrapped.view)
NSLayoutConstraint.activate([
wrapped.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
wrapped.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
wrapped.view.topAnchor.constraint(equalTo: view.topAnchor),
wrapped.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
wrapped.didMove(toParent: self)
container?.galleryContentChanged()
} else {
showErrorView()
}
container?.setGalleryContentLoading(false)
}
}
private func showErrorView() {
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
image.tintColor = .secondaryLabel
image.contentMode = .scaleAspectFit
let label = UILabel()
label.text = "Error Loading"
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
label.textColor = .secondaryLabel
label.adjustsFontForContentSizeCategory = true
let stackView = UIStackView(arrangedSubviews: [
image,
label,
])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.alignment = .center
stackView.spacing = 8
view.addSubview(stackView)
NSLayoutConstraint.activate([
image.widthAnchor.constraint(equalToConstant: 64),
image.heightAnchor.constraint(equalToConstant: 64),
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
wrapped?.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
}
public func setInsetForBottomControls(_ inset: CGFloat) {
wrapped?.setInsetForBottomControls(inset)
}
public func galleryContentDidAppear() {
wrapped?.galleryContentDidAppear()
}
public func galleryContentWillDisappear() {
wrapped?.galleryContentWillDisappear()
}
public func galleryShouldBeginInteractiveDismiss() -> Bool {
wrapped?.galleryShouldBeginInteractiveDismiss() ?? true
}
}

View File

@ -1,530 +0,0 @@
//
// VideoControlsViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 3/21/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import AVFoundation
@propertyWrapper
final class Box<T> {
var wrappedValue: T
init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
}
class VideoControlsViewController: UIViewController {
private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.minute, .second]
f.zeroFormattingBehavior = .pad
return f
}()
private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float
#endif
private lazy var muteButton: MuteButton = {
let button = MuteButton()
button.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
button.setMuted(false, animated: false)
return button
}()
private let timestampLabel: UILabel = {
let label = UILabel()
label.text = "0:00"
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
return label
}()
private lazy var scrubbingControl: VideoScrubbingControl = {
let control = VideoScrubbingControl()
control.heightAnchor.constraint(equalToConstant: 44).isActive = true
control.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
control.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
control.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
return control
}()
private let timeRemainingLabel: UILabel = {
let label = UILabel()
label.text = "-0:00"
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
return label
}()
private lazy var optionsButton = MenuButton { [unowned self] in
let imageName: String
#if os(visionOS)
let playbackSpeed = player.defaultRate
#else
let playbackSpeed = self.playbackSpeed
#endif
if #available(iOS 17.0, *) {
switch playbackSpeed {
case 0.5:
imageName = "gauge.with.dots.needle.0percent"
case 1:
imageName = "gauge.with.dots.needle.33percent"
case 1.25:
imageName = "gauge.with.dots.needle.50percent"
case 2:
imageName = "gauge.with.dots.needle.100percent"
default:
imageName = "gauge.with.dots.needle.67percent"
}
} else {
imageName = "speedometer"
}
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
UIAction(title: speed.displayName, state: playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in
#if os(visionOS)
self.player.defaultRate = speed.rate
#else
self.playbackSpeed = speed.rate
#endif
if self.player.rate > 0 {
self.player.rate = speed.rate
}
}
})
return UIMenu(children: [speedMenu])
}
private lazy var hStack: UIStackView = {
let stack = UIStackView(arrangedSubviews: [
muteButton,
timestampLabel,
scrubbingControl,
timeRemainingLabel,
optionsButton,
])
stack.axis = .horizontal
stack.spacing = 8
stack.alignment = .center
return stack
}()
private var timestampObserverToken: Any?
private var scrubberObserverToken: Any?
private var wasPlayingWhenScrubbingStarted = false
private var scrubbingTargetTime: CMTime?
private var isSeeking = false
#if os(visionOS)
init(player: AVPlayer) {
self.player = player
super.init(nibName: nil, bundle: nil)
}
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player
self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil)
}
#endif
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let timestampObserverToken {
player.removeTimeObserver(timestampObserverToken)
}
if let scrubberObserverToken {
player.removeTimeObserver(scrubberObserverToken)
}
}
override func viewDidLoad() {
super.viewDidLoad()
hStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hStack)
NSLayoutConstraint.activate([
hStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 4),
hStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -4),
hStack.topAnchor.constraint(equalTo: view.topAnchor),
hStack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
timestampObserverToken = player.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 2), queue: .main) { [unowned self] _ in
self.updateTimestamps()
}
}
private func updateTimestamps() {
let current = player.currentTime()
timestampLabel.text = VideoControlsViewController.formatter.string(from: current.seconds)!
let duration = player.currentItem!.duration
if duration != .indefinite {
let remaining = duration - current
timeRemainingLabel.text = "-" + VideoControlsViewController.formatter.string(from: remaining.seconds)!
if scrubberObserverToken == nil {
let interval = CMTime(value: 1, timescale: CMTimeScale(self.scrubbingControl.bounds.width))
if interval.isValid {
self.scrubberObserverToken = self.player.addPeriodicTimeObserver(forInterval: interval, queue: .main, using: { _ in
self.scrubbingControl.fractionComplete = self.player.currentTime().seconds / duration.seconds
})
}
}
}
}
@objc private func scrubbingStarted() {
wasPlayingWhenScrubbingStarted = player.rate > 0
player.rate = 0
}
@objc private func scrubbingChanged() {
let duration = player.currentItem!.duration
let time = CMTime(value: CMTimeValue(scrubbingControl.fractionComplete * duration.seconds * 1_000_000_000), timescale: 1_000_000_000)
scrubbingTargetTime = time
if !isSeeking {
seekToScrubbingTime()
}
}
private func seekToScrubbingTime() {
guard let scrubbingTargetTime else {
return
}
isSeeking = true
player.seek(to: scrubbingTargetTime) { finished in
if finished {
if self.scrubbingTargetTime != scrubbingTargetTime {
self.seekToScrubbingTime()
} else {
self.isSeeking = false
}
}
}
}
@objc private func scrubbingEnded() {
scrubbingChanged()
if wasPlayingWhenScrubbingStarted {
#if os(visionOS)
player.play()
#else
player.rate = playbackSpeed
#endif
}
}
@objc private func muteButtonPressed() {
player.isMuted.toggle()
muteButton.setMuted(player.isMuted, animated: true)
}
}
private class VideoScrubbingControl: UIControl {
var fractionComplete: Double = 0 {
didSet {
updateFillLayerMask()
}
}
private let trackLayer = CAShapeLayer()
private let fillLayer = CAShapeLayer()
private let fillMaskLayer = CALayer()
private var scrubbingStartFraction: Double?
private var touchStartLocation: CGPoint?
private var animator: UIViewPropertyAnimator?
#if !os(visionOS)
private var feedbackGenerator: UIImpactFeedbackGenerator?
#endif
init() {
super.init(frame: .zero)
trackLayer.fillColor = UIColor.systemGray.cgColor
trackLayer.shadowColor = UIColor.black.cgColor
layer.addSublayer(trackLayer)
fillLayer.fillColor = UIColor.white.cgColor
fillLayer.mask = fillMaskLayer
layer.addSublayer(fillLayer)
fillMaskLayer.backgroundColor = UIColor.black.cgColor
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSublayers(of layer: CALayer) {
super.layoutSublayers(of: layer)
let trackFrame = CGRect(x: 0, y: (layer.bounds.height - 8) / 2, width: layer.bounds.width, height: 8)
trackLayer.frame = trackFrame
trackLayer.path = CGPath(roundedRect: CGRect(x: 0, y: 0, width: trackFrame.width, height: trackFrame.height), cornerWidth: 4, cornerHeight: 4, transform: nil)
trackLayer.shadowPath = trackLayer.path
fillLayer.frame = trackFrame
fillLayer.path = trackLayer.path
updateFillLayerMask()
}
private func updateFillLayerMask() {
// I don't know where this animation is coming from
fillMaskLayer.frame = CGRect(x: 0, y: 0, width: fractionComplete * bounds.width, height: 8)
fillMaskLayer.removeAllAnimations()
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
if touch.type == .pencil || touch.type == .indirectPointer {
touchStartLocation = .zero
scrubbingStartFraction = 0
} else {
touchStartLocation = touch.location(in: self)
scrubbingStartFraction = fractionComplete
}
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
animator!.addAnimations {
self.transform = CGAffineTransform(scaleX: 1, y: 1.5)
}
animator!.startAnimation()
sendActions(for: .editingDidBegin)
#if !os(visionOS)
if #available(iOS 17.5, *) {
feedbackGenerator = UIImpactFeedbackGenerator(style: .light, view: self)
} else {
feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
}
feedbackGenerator!.prepare()
#endif
updateScrubbing(for: touch)
return true
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
updateScrubbing(for: touch)
return true
}
private func updateScrubbing(for touch: UITouch) {
guard let touchStartLocation,
let scrubbingStartFraction else {
return
}
let location = touch.location(in: self)
let translation = CGPoint(x: location.x - touchStartLocation.x, y: location.y - touchStartLocation.y)
let scrubbingAmount = translation.x / bounds.width
let unclampedFractionComplete = scrubbingStartFraction + scrubbingAmount
let newFractionComplete = max(0, min(1, unclampedFractionComplete))
#if !os(visionOS)
if newFractionComplete != fractionComplete && (newFractionComplete == 0 || newFractionComplete == 1) {
if #available(iOS 17.5, *) {
feedbackGenerator!.impactOccurred(intensity: 0.5, at: location)
} else {
feedbackGenerator!.impactOccurred(intensity: 0.5)
}
}
#endif
fractionComplete = newFractionComplete
sendActions(for: .editingChanged)
if unclampedFractionComplete < 0 || unclampedFractionComplete > 1 {
let stretchFactor: CGFloat
if unclampedFractionComplete < 0 {
stretchFactor = 1/(unclampedFractionComplete * bounds.width / 10 - 1) + 1
} else {
stretchFactor = -1/((unclampedFractionComplete-1) * bounds.width / 10 + 1) + 1
}
let stretchAmount = 8 * stretchFactor
transform = CGAffineTransform(scaleX: 1 + stretchAmount / bounds.width, y: 1 + 0.5 * (1 - stretchFactor))
.translatedBy(x: sign(unclampedFractionComplete) * stretchAmount / 2, y: 0)
}
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
touchStartLocation = nil
resetScale()
sendActions(for: .editingDidEnd)
#if !os(visionOS)
feedbackGenerator = nil
#endif
}
override func cancelTracking(with event: UIEvent?) {
touchStartLocation = nil
resetScale()
sendActions(for: .editingDidEnd)
#if !os(visionOS)
feedbackGenerator = nil
#endif
}
private func resetScale() {
if let animator,
animator.isRunning {
animator.isReversed = true
animator.startAnimation()
} else {
animator?.pauseAnimation()
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
animator!.addAnimations {
self.transform = .identity
}
animator!.startAnimation()
}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
false
}
}
private class MuteButton: UIControl {
private let imageView = UIImageView()
override var intrinsicContentSize: CGSize {
CGSize(width: 32, height: 32)
}
init() {
super.init(frame: .zero)
imageView.contentMode = .scaleAspectFit
imageView.tintColor = .white
imageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(imageView)
NSLayoutConstraint.activate([
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
addInteraction(UIPointerInteraction(delegate: nil))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setMuted(_ muted: Bool, animated: Bool) {
let image = UIImage(systemName: muted ? "speaker.slash.fill" : "speaker.wave.3.fill")!
if animated,
#available(iOS 17.0, *) {
imageView.setSymbolImage(image, contentTransition: .replace.byLayer)
} else {
imageView.image = image
}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
!(gestureRecognizer is UITapGestureRecognizer)
}
}
private class MenuButton: UIControl {
private let menuProvider: () -> UIMenu
private let imageView = UIImageView()
override var intrinsicContentSize: CGSize {
CGSize(width: 32, height: 32)
}
init(menuProvider: @escaping () -> UIMenu) {
self.menuProvider = menuProvider
super.init(frame: .zero)
imageView.image = UIImage(systemName: "ellipsis.circle")
imageView.contentMode = .scaleAspectFit
imageView.tintColor = .white
imageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(imageView)
NSLayoutConstraint.activate([
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
isContextMenuInteractionEnabled = true
showsMenuAsPrimaryAction = true
addInteraction(UIPointerInteraction(delegate: nil))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(actionProvider: { _ in
self.menuProvider()
})
}
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: (any UIContextMenuInteractionAnimating)?) {
animator?.addAnimations {
self.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
}
}
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: (any UIContextMenuInteractionAnimating)?) {
if let animator {
animator.addAnimations {
self.transform = .identity
}
} else {
self.transform = .identity
}
}
}
private enum PlaybackSpeed: CaseIterable {
case half, regular, oneAndAQuarter, oneAndAHalf, two
var rate: Float {
switch self {
case .half:
0.5
case .regular:
1
case .oneAndAQuarter:
1.25
case .oneAndAHalf:
1.5
case .two:
2
}
}
var displayName: String {
switch self {
case .half:
"0.5×"
case .regular:
"1×"
case .oneAndAQuarter:
"1.25×"
case .oneAndAHalf:
"1.5×"
case .two:
"2×"
}
}
}

View File

@ -1,243 +0,0 @@
//
// VideoGalleryContentViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 3/19/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import AVFoundation
import CoreImage
open class VideoGalleryContentViewController: UIViewController, GalleryContentViewController {
public let url: URL
public let caption: String?
public private(set) var item: AVPlayerItem
public let player: AVPlayer
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate")
@Box private var playbackSpeed: Float = 1
#endif
private var presentationSizeObservation: NSKeyValueObservation?
private var statusObservation: NSKeyValueObservation?
private var rateObservation: NSKeyValueObservation?
private var hideControlsWorkItem: DispatchWorkItem?
private var isShowingError = false
public init(url: URL, caption: String?) {
self.url = url
self.caption = caption
let asset = AVAsset(url: url)
self.item = Self.createItem(asset: asset)
self.player = AVPlayer(playerItem: item)
super.init(nibName: nil, bundle: nil)
}
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
open class func createItem(asset: AVAsset) -> AVPlayerItem {
return AVPlayerItem(asset: asset)
}
public func replaceCurrentItem(with item: AVPlayerItem) {
self.item = item
player.replaceCurrentItem(with: item)
updateItemObservations()
}
public override func viewDidLoad() {
super.viewDidLoad()
container?.setGalleryContentLoading(true)
let playerView = PlayerView(item: item, player: player)
playerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(playerView)
NSLayoutConstraint.activate([
playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
playerView.topAnchor.constraint(equalTo: view.topAnchor),
playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
preferredContentSize = item.presentationSize
updateItemObservations()
rateObservation = player.observe(\.rate, options: .old, changeHandler: { [unowned self] player, info in
if player.rate == 0 {
hideControlsWorkItem?.cancel()
} else if player.rate > 0 && info.oldValue == 0 {
scheduleControlsHide()
}
})
}
private func updateItemObservations() {
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
MainActor.assumeIsolated {
self.preferredContentSize = item.presentationSize
self.container?.galleryContentChanged()
}
})
statusObservation = item.observe(\.status, changeHandler: { [unowned self] item, _ in
MainActor.assumeIsolated {
if item.status == .readyToPlay {
self.container?.setGalleryContentLoading(false)
self.statusObservation = nil
} else if item.status == .failed,
let error = item.error {
self.container?.setGalleryContentLoading(false)
self.showErrorView(error)
self.statusObservation = nil
self.overlayVC.setVisible(false)
}
}
})
}
private func showErrorView(_ error: any Error) {
isShowingError = true
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
image.tintColor = .secondaryLabel
image.contentMode = .scaleAspectFit
let label = UILabel()
label.text = "Error Loading"
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
label.textColor = .secondaryLabel
label.adjustsFontForContentSizeCategory = true
let reason = UILabel()
reason.text = error.localizedDescription
reason.font = .preferredFont(forTextStyle: .subheadline)
reason.textColor = .secondaryLabel
reason.adjustsFontForContentSizeCategory = true
let stackView = UIStackView(arrangedSubviews: [
image,
label,
reason,
])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.alignment = .center
stackView.spacing = 8
view.addSubview(stackView)
NSLayoutConstraint.activate([
image.widthAnchor.constraint(equalToConstant: 64),
image.heightAnchor.constraint(equalToConstant: 64),
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
private func scheduleControlsHide() {
hideControlsWorkItem = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated {
guard let self,
let container = self.container,
container.galleryControlsVisible else {
return
}
container.setGalleryControlsVisible(false, animated: true)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5), execute: hideControlsWorkItem!)
}
// MARK: GalleryContentViewController
public weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public var contentSize: CGSize {
item.presentationSize
}
open var activityItemsForSharing: [Any] {
[]
}
public var presentationAnimation: GalleryContentPresentationAnimation {
isShowingError ? .fade : .fromSourceViewWithoutSnapshot
}
#if os(visionOS)
private lazy var overlayVC = VideoOverlayViewController(player: player)
#else
private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
public var contentOverlayAccessoryViewController: UIViewController? {
overlayVC
}
#if os(visionOS)
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
#else
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if !isShowingError {
overlayVC.setVisible(visible)
}
if !visible {
hideControlsWorkItem?.cancel()
} else if dueToUserInteraction,
player.rate > 0 {
scheduleControlsHide()
}
}
open func galleryContentDidAppear() {
}
open func galleryContentWillDisappear() {
player.pause()
}
}
private class PlayerView: UIView {
override class var layerClass: AnyClass {
AVPlayerLayer.self
}
private var playerLayer: AVPlayerLayer {
layer as! AVPlayerLayer
}
private let player: AVPlayer
private var presentationSizeObservation: NSKeyValueObservation?
override var intrinsicContentSize: CGSize {
player.currentItem?.presentationSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
init(item: AVPlayerItem, player: AVPlayer) {
self.player = player
super.init(frame: .zero)
playerLayer.player = player
playerLayer.videoGravity = .resizeAspect
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] _, _ in
MainActor.assumeIsolated {
self?.invalidateIntrinsicContentSize()
}
})
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -1,233 +0,0 @@
//
// VideoOverlayViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 3/26/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import AVFoundation
class VideoOverlayViewController: UIViewController {
private static let playImage = UIImage(systemName: "play.fill")!
private static let pauseImage = UIImage(systemName: "pause.fill")!
private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float
#endif
private var dimmingView: UIView!
private var controlsStack: UIStackView!
private var skipBackButton: VideoOverlayButton!
private var skipForwardButton: VideoOverlayButton!
private var rateObservation: NSKeyValueObservation?
#if os(visionOS)
init(player: AVPlayer) {
self.player = player
super.init(nibName: nil, bundle: nil)
}
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player
self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil)
}
#endif
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
dimmingView = UIView()
dimmingView.backgroundColor = .black
dimmingView.alpha = 0.2
dimmingView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(dimmingView)
NSLayoutConstraint.activate([
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
skipBackButton = VideoOverlayButton(image: UIImage(systemName: "gobackward.10")!)
skipBackButton.addTarget(self, action: #selector(skipBackPressed), for: .touchUpInside)
let playPauseButton = VideoOverlayButton(image: VideoOverlayViewController.pauseImage)
playPauseButton.addTarget(self, action: #selector(playPausePressed), for: .touchUpInside)
skipForwardButton = VideoOverlayButton(image: UIImage(systemName: "goforward.10")!)
skipForwardButton.addTarget(self, action: #selector(skipForwardPressed), for: .touchUpInside)
controlsStack = UIStackView(arrangedSubviews: [
skipBackButton,
playPauseButton,
skipForwardButton,
])
controlsStack.axis = .horizontal
controlsStack.alignment = .center
controlsStack.spacing = 24
controlsStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(controlsStack)
NSLayoutConstraint.activate([
controlsStack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
controlsStack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
skipBackButton.widthAnchor.constraint(equalToConstant: 50),
skipBackButton.heightAnchor.constraint(equalToConstant: 50),
playPauseButton.widthAnchor.constraint(equalToConstant: 66),
playPauseButton.heightAnchor.constraint(equalToConstant: 66),
skipForwardButton.widthAnchor.constraint(equalToConstant: 50),
skipForwardButton.heightAnchor.constraint(equalToConstant: 50),
])
rateObservation = player.observe(\.rate, changeHandler: { player, _ in
MainActor.assumeIsolated {
playPauseButton.image = player.rate > 0 ? VideoOverlayViewController.pauseImage : VideoOverlayViewController.playImage
}
})
}
func setVisible(_ visible: Bool) {
loadViewIfNeeded()
view.alpha = visible ? 1 : 0
}
@objc private func playPausePressed() {
if player.rate > 0 {
player.rate = 0
} else {
if player.currentTime() >= player.currentItem!.duration {
player.seek(to: .zero)
}
#if os(visionOS)
player.play()
#else
player.rate = playbackSpeed
#endif
}
}
@objc private func skipBackPressed() {
player.seek(to: player.currentTime() - CMTime(value: 10, timescale: 1))
}
@objc private func skipForwardPressed() {
player.seek(to: player.currentTime() + CMTime(value: 10, timescale: 1))
}
}
private class VideoOverlayButton: UIControl {
var image: UIImage? {
get {
imageView.image
}
set {
imageView.image = newValue
}
}
private let backgroundView = UIView()
private let imageView = UIImageView()
private var animator: UIViewPropertyAnimator?
override var isEnabled: Bool {
didSet {
imageView.tintColor = isEnabled ? .white : .lightGray
}
}
init(image: UIImage) {
super.init(frame: .zero)
backgroundView.alpha = 0
backgroundView.backgroundColor = .lightGray.withAlphaComponent(0.5)
backgroundView.translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundView)
NSLayoutConstraint.activate([
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundView.topAnchor.constraint(equalTo: topAnchor),
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
imageView.image = image
imageView.tintColor = .white
imageView.contentMode = .scaleAspectFit
imageView.preferredSymbolConfiguration = .init(scale: .large)
imageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(imageView)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
imageView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
])
addInteraction(UIPointerInteraction(delegate: self))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
backgroundView.layer.cornerRadius = bounds.height / 2
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
bounds.contains(point) ? self : nil
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
if touch.type != .indirectPointer {
UIView.animate(withDuration: 0.2) {
self.backgroundView.alpha = 1
self.backgroundView.transform = CGAffineTransform(scaleX: 1/0.8, y: 1/0.8)
self.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
}
}
return super.beginTracking(touch, with: event)
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
if touch?.type != .indirectPointer {
UIView.animate(withDuration: 0.2) {
self.backgroundView.alpha = 0
self.backgroundView.transform = .identity
self.transform = .identity
}
}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UITapGestureRecognizer {
return false
}
return true
}
}
extension VideoOverlayButton: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
return UIPointerRegion(rect: bounds)
}
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let preview = UITargetedPreview(view: self)
return UIPointerStyle(effect: .highlight(preview), shape: .path(UIBezierPath(ovalIn: frame)))
}
}

View File

@ -1,81 +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 insetBottomControlsAccessoryViewControllerToSafeArea: Bool { get }
var presentationAnimation: GalleryContentPresentationAnimation { get }
var hideControlsOnZoom: Bool { get }
var showBelowSafeArea: Bool { get }
func shouldHideControls() -> Bool
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
func setInsetForBottomControls(_ inset: CGFloat)
func galleryContentDidAppear()
func galleryContentWillDisappear()
func galleryShouldBeginInteractiveDismiss() -> Bool
}
public extension GalleryContentViewController {
var contentOverlayAccessoryViewController: UIViewController? {
nil
}
var bottomControlsAccessoryViewController: UIViewController? {
nil
}
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
true
}
var presentationAnimation: GalleryContentPresentationAnimation {
.fromSourceView
}
var hideControlsOnZoom: Bool {
true
}
var showBelowSafeArea: Bool {
true
}
func shouldHideControls() -> Bool {
true
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
}
func setInsetForBottomControls(_ inset: CGFloat) {
}
func galleryContentDidAppear() {
}
func galleryContentWillDisappear() {
}
func galleryShouldBeginInteractiveDismiss() -> Bool {
true
}
}
public enum GalleryContentPresentationAnimation {
case fade
case fromSourceView
case fromSourceViewWithoutSnapshot
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -1,193 +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.presentationAnimation == .fade || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
animateCrossFadeTransition(using: transitionContext)
return
}
let container = transitionContext.containerView
// 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)
}
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
nil
} else {
sourceView.snapshotView(afterScreenUpdates: false)
}
if let sourceSnapshot {
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
snapshotContainer.addSubview(sourceSnapshot)
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
sourceSnapshot.frame = sourceFrameInShapshotContainer
sourceSnapshot.layer.opacity = 1
self.sourceView.layer.opacity = 0
}
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
sourceSnapshot?.transform = sourceToDestTransform
} else {
appliedSourceToDestTransform = false
}
from.view.frame = container.bounds
container.addSubview(from.view)
let contentContainer = UIView()
contentContainer.layer.masksToBounds = true
contentContainer.frame = destFrameInContainer
container.addSubview(contentContainer)
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.transform = .identity
content.view.layer.opacity = 1
content.view.frame = contentContainer.bounds
contentContainer.addSubview(content.view)
container.layoutIfNeeded()
// Hide overlaid controls immediately, to prevent the Live Text button's position
// getting caught up in the rest of the animation.
UIView.animate(withDuration: 0.1) {
content.setControlsVisible(false, animated: false, dueToUserInteraction: false)
}
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 spring's 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
sourceSnapshot?.transform = origSourceTransform
}
contentContainer.frame = sourceFrameInContainer
// Using sourceSizeWithDestAspectRatioCenteredInContentContainer does not seem to be necessary here.
// I guess autoresizing takes care of it?
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
}
// Delay fading out the content because if it's still big while it's semi-transparent,
// seeing the stuff behind it looks odd.
animator.addAnimations({
content.view.layer.opacity = 0
}, delayFactor: 0.35)
if let sourceSnapshot {
animator.addAnimations({
self.sourceView.layer.opacity = 1
sourceSnapshot.layer.opacity = 0
}, delayFactor: 0.5)
}
animator.addCompletion { _ in
sourceSnapshot?.removeFromSuperview()
// Having dismissed, we don't need to undo any of the changes to the content VC.
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
}
let container = transitionContext.containerView
// See comment above.
if toVC.view.superview == nil {
toVC.view.frame = container.bounds
container.addSubview(toVC.view)
}
toVC.view.frame = transitionContext.containerView.bounds
fromVC.view.frame = transitionContext.containerView.bounds
container.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()
}
}

View File

@ -1,113 +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?
private var cancelAnimator: UIViewPropertyAnimator?
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!
// Make sure the context remains behind the controls
content!.view.layer.zPosition = -1000
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)
let translationMagnitude = sqrt(translation.x.magnitudeSquared + translation.y.magnitudeSquared)
let velocityMagnitude = sqrt(velocity.x.magnitudeSquared + velocity.y.magnitudeSquared)
if translationMagnitude < 150 && velocityMagnitude < 500 {
isActive = false
cancelAnimator?.stopAnimation(true)
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: .zero)
cancelAnimator = UIViewPropertyAnimator(duration: 0.2, timingParameters: spring)
cancelAnimator!.addAnimations {
self.content!.view.frame = self.origContentFrameInGallery!
self.viewController.currentItemViewController.setControlsVisible(self.origControlsVisible!, animated: false, dueToUserInteraction: false)
}
cancelAnimator!.addCompletion { _ in
guard !self.isActive else {
// bail in case the animation finishing raced with the user's interaction
return
}
self.content!.view.layer.zPosition = 0
self.content!.view.removeFromSuperview()
self.viewController.currentItemViewController.addContent()
self.content = nil
self.origContentFrameInGallery = nil
self.origControlsVisible = nil
}
cancelAnimator!.startAnimation()
} else {
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 {
return itemVC.shouldBeginInteractiveDismiss()
}
}
}

View File

@ -1,677 +0,0 @@
//
// GalleryItemViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
import AVFoundation
@MainActor
protocol GalleryItemViewControllerDelegate: AnyObject {
func isGalleryBeingPresented() -> Bool
func isGalleryBeingDismissed() -> 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?
private var skipScrollViewZoomUpdate = false
var showShareButton: Bool = true {
didSet {
shareButton?.isHidden = !showShareButton
}
}
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()
// Need to use the keyboard layout guide in some way in this VC,
// otherwise the keyboardLayoutGuide inside the bottom controls accessory view doesn't animate
_ = view.keyboardLayoutGuide
scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self
// We calculate zoom/position ignoring the safe area, so content insets need to not incorporate it either.
// Otherwise, content that fills the screen (extending into the safe area) may still end up scrollable
// (this is readily observable with tall images on a landscape iPad).
// Even if the content is not being shown below the safe area, we still set this to make the calculations more consistent/straightforward.
scrollView.contentInsetAdjustmentBehavior = .never
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.isHidden = !showShareButton
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)
if content.insetBottomControlsAccessoryViewControllerToSafeArea {
// 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)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
@objc private func keyboardWillUpdate() {
updateScrollView(resetZoom: true)
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
updateScrollView(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
updateScrollView(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
updateScrollView(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 !content.showBelowSafeArea {
skipScrollViewZoomUpdate = true
updateScrollView(resetZoom: abs(scrollView.zoomScale - scrollView.minimumZoomScale) < 0.01)
skipScrollViewZoomUpdate = false
scrollView.layoutIfNeeded()
}
}
if animated {
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
animator.addAnimations(updateControlsViews)
animator.startAnimation()
} else {
updateControlsViews()
}
setNeedsUpdateOfHomeIndicatorAutoHidden()
}
func updateScrollView(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 = availableHeightForContent() / 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))
}
let bottomInset: CGFloat
let bottomIndicatorInset: CGFloat
if !content.showBelowSafeArea,
controlsVisible,
let bottomControlsView {
bottomInset = bottomControlsView.bounds.height + view.safeAreaInsets.top
bottomIndicatorInset = bottomControlsView.safeAreaLayoutGuide.layoutFrame.height
} else {
bottomInset = 0
bottomIndicatorInset = 0
}
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: bottomIndicatorInset, right: 0)
let contentBottomControlsInset: CGFloat
if content.showBelowSafeArea,
controlsVisible,
let bottomControlsView {
let contentFrameInSelf = content.view.convert(content.view.bounds, to: view)
contentBottomControlsInset = max(0, contentFrameInSelf.maxY - bottomControlsView.frame.minY - view.safeAreaInsets.bottom)
} else {
contentBottomControlsInset = 0
}
content.setInsetForBottomControls(contentBottomControlsInset)
centerContent()
}
private func centerContent() {
guard scrollAndZoomEnabled else {
return
}
let additionalYOffset = content.showBelowSafeArea ? 0 : view.safeAreaInsets.top
// 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, (availableHeightForContent() - content.view.frame.height) / 2)
contentViewTopConstraint!.constant = yOffset + additionalYOffset
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
contentViewLeadingConstraint!.constant = xOffset
}
private func availableHeightForContent() -> CGFloat {
var availableHeight: CGFloat
if content.showBelowSafeArea {
availableHeight = view.bounds.height
} else {
availableHeight = view.safeAreaLayoutGuide.layoutFrame.height
if controlsVisible {
availableHeight -= bottomControlsView?.safeAreaLayoutGuide.layoutFrame.height ?? 0
}
}
return availableHeight
}
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() {
guard delegate?.isGalleryBeingDismissed() != true else {
return
}
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
]
let topInset: CGFloat
switch view.window?.windowScene?.interfaceOrientation {
case .portraitUpsideDown:
topInset = view.safeAreaInsets.bottom
case .landscapeLeft:
topInset = view.safeAreaInsets.right
case .landscapeRight:
topInset = view.safeAreaInsets.left
default:
topInset = view.safeAreaInsets.top
}
if notchedDeviceTopInsets.contains(topInset) {
// 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 topInset == 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 {
if content.shouldHideControls() {
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)
}
func shouldBeginInteractiveDismiss() -> Bool {
if scrollView.zoomScale > scrollView.minimumZoomScale {
false
} else if !scrollAndZoomEnabled {
false
} else {
content.galleryShouldBeginInteractiveDismiss()
}
}
}
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() {
updateScrollView(resetZoom: true)
updateShareButton()
updateCaptionTextView()
}
func disableGalleryScrollAndZoom() {
scrollAndZoomEnabled = false
updateScrollView(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) {
guard !skipScrollViewZoomUpdate else {
return
}
if scrollView.zoomScale <= scrollView.minimumZoomScale {
setControlsVisible(true, animated: true, dueToUserInteraction: true)
} else {
if content.hideControlsOnZoom {
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
}
}
}

View File

@ -1,221 +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.presentationAnimation == .fade || UIAccessibility.prefersCrossFadeTransitions {
animateCrossFadeTransition(using: transitionContext)
return
}
// Try to effectively "fade out" anything that's on top of the source view.
// The 0.1 duration makes this happen faster than the rest of the animation,
// and so less noticeable.
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
nil
} else {
sourceView.snapshotView(afterScreenUpdates: false)
}
if let sourceSnapshot {
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
snapshotContainer.addSubview(sourceSnapshot)
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
sourceSnapshot.frame = sourceFrameInShapshotContainer
sourceSnapshot.transform = sourceView.transform
sourceSnapshot.layer.opacity = 0
UIView.animate(withDuration: 0.1) {
sourceSnapshot.layer.opacity = 1
}
}
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.updateScrollView(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
}
// Grab these before taking the content out and changing the transform.
let origContentTransform = itemViewController.content.view.transform
let origContentFrame = itemViewController.content.view.frame
// The content container provides the clipping for the content view,
// which, in case the source/dest aspect ratios don't match, makes
// it look like the content is expanding out from the source rect.
let contentContainer = UIView()
contentContainer.layer.masksToBounds = true
container.insertSubview(contentContainer, belowSubview: to.view)
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.transform = .identity
// The fade-in makes the aspect ratio handling look a little bit worse,
// but papers over the z-index change and potential corner radius change.
content.view.layer.opacity = 0
contentContainer.addSubview(content.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: contentContainer)
to.view.backgroundColor = nil
to.view.layer.opacity = 0
contentContainer.frame = sourceFrameInContainer
let sourceAspectRatio: CGFloat = if sourceFrameInContainer.height > 0 {
sourceFrameInContainer.width / sourceFrameInContainer.height
} else {
0
}
let destAspectRatio: CGFloat = if destFrameInContainer.height > 0 {
destFrameInContainer.width / destFrameInContainer.height
} else {
0
}
let sourceSizeWithDestAspectRatioCenteredInContentContainer: CGRect
if 0.001 < abs(sourceAspectRatio - destAspectRatio) {
// asepct ratios are effectively equal
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(origin: .zero, size: sourceFrameInContainer.size)
} else if sourceAspectRatio < destAspectRatio {
// source aspect ratio is narrow/taller than dest
let width = sourceFrameInContainer.height * destAspectRatio
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
x: -(width - sourceFrameInContainer.width) / 2,
y: 0,
width: width,
height: sourceFrameInContainer.height
)
} else {
// source aspect ratio is wider/shorter than dest
let height = sourceFrameInContainer.width / destAspectRatio
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
x: 0,
y: -(height - sourceFrameInContainer.height) / 2,
width: sourceFrameInContainer.width,
height: height
)
}
content.view.frame = sourceSizeWithDestAspectRatioCenteredInContentContainer
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)
// less bounce on bigger screens
let spring = if UIDevice.current.userInterfaceIdiom == .pad {
// roughly equivalent to duration: 0.35, bounce: 0.2
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 28, initialVelocity: .zero)
} else {
// roughly equivalent to duration: 0.35, bounce: 0.3
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
contentContainer.frame = destFrameInContainer
content.view.frame = contentContainer.bounds
content.view.layer.opacity = 1
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
if let sourceToDestTransform {
sourceSnapshot?.transform = sourceToDestTransform
self.sourceView.transform = sourceToDestTransform
}
}
animator.addCompletion { _ in
sourceSnapshot?.removeFromSuperview()
self.sourceView.layer.opacity = 1
if sourceToDestTransform != nil {
self.sourceView.transform = origSourceTransform
}
contentContainer.removeFromSuperview()
dimmingView.removeFromSuperview()
to.view.backgroundColor = .black
// Reset the properties we changed before re-adding the content to the scroll view.
// (I would expect UIScrollView to effectively do this itself, but w/e.)
content.view.transform = origContentTransform
content.view.frame = origContentFrame
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()
}
}

View File

@ -1,203 +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] = []
public var showShareButton: Bool = true {
didSet {
if viewControllers?.isEmpty == false {
currentItemViewController.showShareButton = showShareButton
}
}
}
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)
let itemVC = GalleryItemViewController(delegate: self, itemIndex: index, content: content)
itemVC.showShareButton = showShareButton
return itemVC
}
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 isGalleryBeingDismissed() -> Bool {
isBeingDismissed
}
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
}
}

View File

@ -1,27 +0,0 @@
//
// UIView+Utilities.swift
// GalleryVC
//
// Created by Shadowfacts on 11/24/24.
//
import UIKit
extension UIView {
var ancestorForInsertingSnapshot: UIView {
var view = self
while let superview = view.superview {
if superview.layer.masksToBounds {
return superview
} else if let scrollView = superview as? UIScrollView,
scrollView.isScrollEnabled {
return self
} else {
view = superview
}
}
return view
}
}

View File

@ -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
}
}

View File

@ -1,9 +0,0 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -1,23 +0,0 @@
{
"pins" : [
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
"version" : "1.2.1"
}
},
{
"identity" : "swift-url",
"kind" : "remoteSourceControl",
"location" : "https://github.com/karwa/swift-url.git",
"state" : {
"branch" : "main",
"revision" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6"
}
}
],
"version" : 2
}

View File

@ -1,37 +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: "InstanceFeatures",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "InstanceFeatures",
targets: ["InstanceFeatures"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(path: "../Pachyderm"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "InstanceFeatures",
dependencies: ["Pachyderm"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget(
name: "InstanceFeaturesTests",
dependencies: ["InstanceFeatures"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
]
)

View File

@ -1,3 +0,0 @@
# InstanceFeatures
A description of this package.

View File

@ -1,397 +0,0 @@
//
// InstanceFeatures.swift
// Tusker
//
// Created by Shadowfacts on 1/23/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Combine
import Pachyderm
public final class InstanceFeatures: ObservableObject {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; (pleroma|akkoma) (.*)\\)", options: .caseInsensitive)
private let _featuresUpdated = PassthroughSubject<Void, Never>()
public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
@Published @_spi(InstanceType) public private(set) var instanceType: InstanceType = .mastodon(.vanilla, nil)
@Published public private(set) var maxStatusChars = 500
@Published public private(set) var charsReservedPerURL = 23
@Published public private(set) var maxPollOptionChars: 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 {
switch instanceType {
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
return true
case .pleroma(.akkoma(_)):
return true
default:
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 {
instanceType.isMastodon
}
public var pollsAndAttachments: Bool {
instanceType.isPleroma
}
public var boostToOriginalAudience: Bool {
instanceType.isPleroma || instanceType.isMastodon
}
public var profilePinnedStatuses: Bool {
switch instanceType {
case .pixelfed:
return false
default:
return true
}
}
public var trends: Bool {
instanceType.isMastodon
}
public var profileSuggestions: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 4, 0)
}
public var trendingStatusesAndLinks: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 5, 0)
}
public var reblogVisibility: Bool {
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
}
public var probablySupportsMarkdown: Bool {
switch instanceType {
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .firefish(_):
return true
default:
return false
}
}
public var needsLocalOnlyEmojiHack: Bool {
if case .mastodon(.glitch, _) = instanceType {
return true
} else {
return false
}
}
public var needsWideColorGamutHack: Bool {
if case .mastodon(_, let version) = instanceType {
return version < Version(4, 0, 0)
} else {
return true
}
}
public var canFollowHashtags: Bool {
if case .mastodon(_, let version) = instanceType {
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 {
hasMastodonVersion(4, 0, 0)
}
public var notificationsAllowedTypes: Bool {
hasMastodonVersion(3, 5, 0)
}
public var pollVotersCount: Bool {
instanceType.isMastodon
}
public var createStatusWithLanguage: Bool {
instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil))
}
public var editStatuses: Bool {
switch instanceType {
case .mastodon(_, let v) where v >= Version(3, 5, 0):
return true
case .pleroma(.vanilla(let v)) where v >= Version(2, 5, 0):
return true
case .pleroma(.akkoma(_)):
return true
default:
return false
}
}
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 {
instanceType.isPleroma
}
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 func update(instance: InstanceInfo, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased()
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
if ver.contains("glitch") {
instanceType = .mastodon(.glitch, Version(string: ver))
} else if nodeInfo?.software.name == "mastodon" {
instanceType = .mastodon(.vanilla, Version(string: ver))
} else if nodeInfo?.software.name == "hometown" {
var mastoVersion: Version?
var hometownVersion: Version?
let parts = ver.split(separator: "+")
if parts.count == 2,
let first = Version(string: String(parts[0])) {
if first > Version(1, 0, 8) {
// like 3.5.5+hometown-1.0.9
mastoVersion = first
if parts[1].starts(with: "hometown-") {
hometownVersion = Version(string: String(parts[1][parts[1].index(parts[1].startIndex, offsetBy: "hometown-".count + 1)...]))
}
} else {
// like "1.0.6+3.5.2"
hometownVersion = first
mastoVersion = Version(string: String(parts[1]))
}
} else {
mastoVersion = Version(string: ver)
}
instanceType = .mastodon(.hometown(hometownVersion), mastoVersion)
} else if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
var pleromaVersion: Version?
let type = (ver as NSString).substring(with: match.range(at: 1))
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 2)))
if type == "akkoma" {
instanceType = .pleroma(.akkoma(pleromaVersion))
} else {
instanceType = .pleroma(.vanilla(pleromaVersion))
}
} else if ver.contains("pixelfed") {
instanceType = .pixelfed
} else if nodeInfo?.software.name == "gotosocial" {
instanceType = .gotosocial
} else if ver.contains("firefish") || ver.contains("iceshrimp") || ver.contains("calckey") {
instanceType = .firefish(nodeInfo?.software.version)
} else {
instanceType = .mastodon(.vanilla, Version(string: ver))
}
maxStatusChars = instance.maxStatusCharacters ?? 500
charsReservedPerURL = instance.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
if let pollsConfig = instance.pollsConfiguration {
maxPollOptionChars = pollsConfig.maxCharactersPerOption
maxPollOptionsCount = pollsConfig.maxOptions
}
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
translation = instance.translation
_featuresUpdated.send()
}
public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if case .mastodon(_, let version) = instanceType {
return version >= Version(major, minor, patch)
} else {
return false
}
}
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
switch instanceType {
case .pleroma(.vanilla(let version)), .pleroma(.akkoma(let version)):
return version >= Version(major, minor, patch)
default:
return false
}
}
}
extension InstanceFeatures {
@_spi(InstanceType) public enum InstanceType {
case mastodon(MastodonType, Version?)
case pleroma(PleromaType)
case pixelfed
case gotosocial
case firefish(String?)
var isMastodon: Bool {
if case .mastodon(_, _) = self {
return true
} else {
return false
}
}
func isMastodon(_ subtype: MastodonType) -> Bool {
if case .mastodon(let t, _) = self,
t.equalsIgnoreVersion(subtype) {
return true
} else {
return false
}
}
var isPleroma: Bool {
if case .pleroma(_) = self {
return true
} else {
return false
}
}
func isPleroma(_ subtype: PleromaType) -> Bool {
if case .pleroma(let t) = self,
t.equalsIgnoreVersion(subtype) {
return true
} else {
return false
}
}
var isPixelfed: Bool {
if case .pixelfed = self {
return true
} else {
return false
}
}
}
@_spi(InstanceType) public enum MastodonType {
case vanilla
case hometown(Version?)
case glitch
func equalsIgnoreVersion(_ other: MastodonType) -> Bool {
switch (self, other) {
case (.vanilla, .vanilla):
return true
case (.hometown(_), .hometown(_)):
return true
case (.glitch, .glitch):
return true
default:
return false
}
}
}
@_spi(InstanceType) public enum PleromaType {
case vanilla(Version?)
case akkoma(Version?)
func equalsIgnoreVersion(_ other: PleromaType) -> Bool {
switch (self, other) {
case (.vanilla(_), .vanilla(_)):
return true
case (.akkoma(_), .akkoma(_)):
return true
default:
return false
}
}
}
}

View File

@ -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
}
}

View File

@ -1,80 +0,0 @@
//
// Version.swift
// InstanceFeatures
//
// Created by Shadowfacts on 5/14/23.
//
import Foundation
@_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
let major: Int
let minor: Int
let patch: Int
init(_ major: Int, _ minor: Int, _ patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}
init?(string: String) {
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
match.numberOfRanges == 4 else {
return nil
}
let majorStr = (string as NSString).substring(with: match.range(at: 1))
let minorStr = (string as NSString).substring(with: match.range(at: 2))
let patchStr = (string as NSString).substring(with: match.range(at: 3))
guard let major = Int(majorStr),
let minor = Int(minorStr),
let patch = Int(patchStr) else {
return nil
}
self.major = major
self.minor = minor
self.patch = patch
}
public var description: String {
"\(major).\(minor).\(patch)"
}
public static func ==(lhs: Version, rhs: Version) -> Bool {
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
}
public static func <(lhs: Version, rhs: Version) -> Bool {
if lhs.major < rhs.major {
return true
} else if lhs.major > rhs.major {
return false
} else if lhs.minor < rhs.minor {
return true
} else if lhs.minor > rhs.minor {
return false
} else if lhs.patch < rhs.patch {
return true
} else {
return false
}
}
}
func <(lhs: Version?, rhs: Version) -> Bool {
guard let lhs else {
// nil is less than or equal to everything
return true
}
return lhs < rhs
}
func >=(lhs: Version?, rhs: Version) -> Bool {
guard let lhs else {
// nil is less than or equal to everything
return false
}
return lhs >= rhs
}

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0
// swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "Pachyderm",
platforms: [
.iOS(.v15),
.iOS(.v14),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
@ -16,7 +16,7 @@ let package = Package(
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"),
.package(url: "https://github.com/karwa/swift-url.git", branch: "main"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
@ -26,15 +26,9 @@ let package = Package(
dependencies: [
.product(name: "WebURL", package: "swift-url"),
.product(name: "WebURLFoundationExtras", package: "swift-url"),
],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget(
name: "PachydermTests",
dependencies: ["Pachyderm"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
dependencies: ["Pachyderm"]),
]
)

View File

@ -7,12 +7,11 @@
//
import Foundation
import WebURL
/**
The base Mastodon API client.
*/
public struct Client: Sendable {
public class Client {
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
@ -20,35 +19,34 @@ public struct Client: Sendable {
let session: URLSession
public var accessToken: String?
public var appID: String?
public var clientID: String?
public var clientSecret: String?
public var timeoutInterval: TimeInterval = 60
private static let dateFormatter: DateFormatter = {
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
private static let iso8601Formatter = ISO8601DateFormatter()
private static func decodeDate(string: String) -> Date? {
// for the next time mastodon accidentally changes date formats >.>
return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string)
}
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
let iso8601 = ISO8601DateFormatter()
decoder.dateDecodingStrategy = .custom({ (decoder) in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
if let date = Self.decodeDate(string: str) {
// for the next time mastodon accidentally changes date formats >.>
if let date = formatter.date(from: str) {
return date
} else if let date = iso8601.date(from: str) {
return date
} else {
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
}
})
return decoder
}()
@ -62,11 +60,9 @@ public struct Client: Sendable {
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.accessToken = accessToken
self.clientID = clientID
self.clientSecret = clientSecret
self.session = session
}
@ -87,7 +83,7 @@ public struct Client: Sendable {
completion(.failure(Error(request: request, type: .invalidResponse)))
return
}
guard response.statusCode == 200 || request.additionalAcceptableHTTPCodes.contains(response.statusCode) else {
guard response.statusCode == 200 else {
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(Error(request: request, type: type)))
@ -108,30 +104,6 @@ public struct Client: Sendable {
return task
}
private func error(from response: HTTPURLResponse) -> ErrorType {
if response.statusCode == 429,
let date = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Self.decodeDate) {
return .rateLimited(date)
} else {
return .unexpectedStatus(response.statusCode)
}
}
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws(Error) -> (Result, Pagination?) {
let response = await withCheckedContinuation { continuation in
run(request) { response in
continuation.resume(returning: response)
}
}
switch response {
case .failure(let error):
throw error
case .success(let result, let pagination):
return (result, pagination)
}
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.endpoint.path
@ -140,17 +112,11 @@ public struct Client: Sendable {
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
urlRequest.httpMethod = request.method.name
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 {
urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
}
if let accessToken = accessToken {
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
// We consider authenticated requests to be user-initiated.
urlRequest.attribution = .user
}
return urlRequest
}
@ -163,52 +129,47 @@ public struct Client: Sendable {
"scopes" => scopes.scopeString,
"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, completion: @escaping Callback<LoginSettings>) {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
"client_id" => clientID,
"client_secret" => clientSecret,
"grant_type" => "authorization_code",
"code" => authorizationCode,
"redirect_uri" => redirectURI,
"scope" => scopes.scopeString,
"redirect_uri" => redirectURI
]))
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 {
guard let accessToken else {
return
}
let request = Request<Empty>(method: .post, path: "/oauth/revoke", body: ParametersBody([
"token" => accessToken,
"client_id" => clientID!,
"client_secret" => clientSecret!,
]))
return try await withCheckedThrowingContinuation({ continuation in
self.run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(_, _):
continuation.resume()
public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
run(wellKnown) { result in
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 components = URLComponents(string: url.href),
components.host == self.baseURL.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: components.path))
self.run(nodeInfo, completion: completion)
}
}
})
}
public func nodeInfo() async throws -> NodeInfo {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
let wellKnownResults = try await run(wellKnown).0
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let href = WebURL(url.href),
href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
return try await run(nodeInfo).0
} else {
throw NodeInfoError.noWellKnownLink
}
}
@ -217,32 +178,22 @@ public struct Client: Sendable {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
}
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
request.range = range
return request
public static func getFavourites() -> Request<[Status]> {
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
}
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
}
public static func getInstanceV1() -> Request<InstanceV1> {
return Request<InstanceV1>(method: .get, path: "/api/v1/instance")
}
public static func getInstanceV2() -> Request<InstanceV2> {
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
public static func getInstance() -> Request<Instance> {
return Request<Instance>(method: .get, path: "/api/v1/instance")
}
public static func getCustomEmoji() -> Request<[Emoji]> {
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
}
public static func getPreferences() -> Request<Preferences> {
return Request(method: .get, path: "/api/v1/preferences")
}
// MARK: - Accounts
public static func getAccount(id: String) -> Request<Account> {
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
@ -339,13 +290,6 @@ public struct Client: Sendable {
], attachment))
}
public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request<Attachment> {
return Request(method: .put, path: "/api/v1/media/\(id)", body: FormDataBody([
"description" => description,
"focus" => focus
], nil))
}
// MARK: - Mutes
public static func getMutes(range: RequestRange) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
@ -354,10 +298,6 @@ public struct Client: Sendable {
}
// MARK: - Notifications
public static func getNotification(id: String) -> Request<Notification> {
return Request(method: .get, path: "/api/v1/notifications/\(id)")
}
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
"types" => allowedTypes.map { $0.rawValue }
@ -417,110 +357,69 @@ public struct Client: Sendable {
public static func createStatus(text: String,
contentType: StatusContentType = .plain,
inReplyTo: String? = nil,
mediaIDs: [String]? = nil,
media: [Attachment]? = nil,
sensitive: Bool? = nil,
spoilerText: String? = nil,
visibility: String? = nil,
language: String? = nil, // language supported by mastodon and akkoma
visibility: Status.Visibility? = nil,
language: String? = nil,
pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil,
pollMultiple: Bool? = nil,
localOnly: Bool? = nil, /* hometown only, not glitch */
idempotencyKey: String) -> Request<Status> {
var req = Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
"status" => text,
"content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo,
"sensitive" => sensitive,
"spoiler_text" => spoilerText,
"visibility" => visibility,
"language" => language,
"poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple,
"local_only" => localOnly,
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
req.headers["Idempotency-Key"] = idempotencyKey
return req
}
public static func editStatus(
id: String,
text: String,
contentType: StatusContentType = .plain,
spoilerText: String?,
sensitive: Bool,
language: String?,
mediaIDs: [String],
mediaAttributes: [EditStatusMediaAttributes],
poll: EditPollParameters?
) -> Request<Status> {
let params = EditStatusParameters(
id: id,
text: text,
contentType: contentType,
spoilerText: spoilerText,
sensitive: sensitive,
language: language,
mediaIDs: mediaIDs,
mediaAttributes: mediaAttributes,
poll: poll
)
return Request(method: .put, path: "/api/v1/statuses/\(id)", body: JsonBody(params))
localOnly: Bool? = nil /* hometown only, not glitch */) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
"status" => text,
"content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo,
"sensitive" => sensitive,
"spoiler_text" => spoilerText,
"visibility" => visibility?.rawValue,
"language" => language,
"poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple,
"local_only" => localOnly,
] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions))
}
// MARK: - Timelines
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
return timeline.request(range: range)
}
// MARK: - Bookmarks
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
request.range = range
return request
}
// MARK: - Instance
public static func getTrendingHashtagsDeprecated(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
public static func getTrendingHashtags(limit: Int? = nil) -> Request<[Hashtag]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
}
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
}
public static func getTrendingHashtags(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
}
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>]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
public static func getTrendingStatuses(limit: Int? = nil) -> Request<[Status]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
}
return Request(method: .get, path: "/api/v1/trends/statuses", queryParameters: parameters)
}
public static func getTrendingLinks(limit: Int? = nil, offset: Int? = nil) -> Request<[Card]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
public static func getTrendingLinks(limit: Int? = nil) -> Request<[Card]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
}
return Request(method: .get, path: "/api/v1/trends/links", queryParameters: parameters)
}
@ -545,16 +444,10 @@ public struct Client: Sendable {
])
}
// MARK: - Hashtags
/// Requires Mastodon 4.0.0+
public static func getHashtag(name: String) -> Request<Hashtag> {
return Request(method: .get, path: "/api/v1/tags/\(name)")
}
}
extension Client {
public struct Error: LocalizedError, Sendable {
public struct Error: LocalizedError {
public let requestMethod: Method
public let requestEndpoint: Endpoint
public let type: ErrorType
@ -569,15 +462,13 @@ extension Client {
self.type = type
}
public var errorDescription: String? {
public var localizedDescription: String {
switch type {
case .networkError(let error):
return "Network Error: \(error.localizedDescription)"
// todo: support more status codes
case .unexpectedStatus(413):
return "HTTP 413: Payload Too Large"
case .unexpectedStatus(429):
return "HTTP 429: Rate Limit Exceeded"
case .unexpectedStatus(let code):
return "HTTP Code \(code)"
case .invalidRequest:
@ -588,29 +479,15 @@ extension Client {
return "Invalid Model"
case .mastodonError(let code, let error):
return "Server Error (\(code)): \(error)"
case .rateLimited(let reset):
return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))"
}
}
}
public enum ErrorType: LocalizedError, Sendable {
public enum ErrorType: LocalizedError {
case networkError(Swift.Error)
case unexpectedStatus(Int)
case invalidRequest
case invalidResponse
case invalidModel(Swift.Error)
case mastodonError(Int, String)
case rateLimited(Date)
}
enum NodeInfoError: LocalizedError {
case noWellKnownLink
var errorDescription: String? {
switch self {
case .noWellKnownLink:
return "No well-known link"
}
}
}
}

View File

@ -8,7 +8,7 @@
import Foundation
public final class Account: AccountProtocol, Decodable, Sendable {
public final class Account: AccountProtocol, Decodable {
public let id: String
public let username: String
public let acct: String
@ -25,7 +25,7 @@ public final class Account: AccountProtocol, Decodable, Sendable {
public let avatarStatic: URL?
public let header: URL?
public let headerStatic: URL?
public let emojis: [Emoji]
public private(set) var emojis: [Emoji]
public let moved: Bool?
public let movedTo: Account?
public let fields: [Field]
@ -40,9 +40,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
self.displayName = try container.decode(String.self, forKey: .displayName)
self.locked = try container.decode(Bool.self, forKey: .locked)
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
self.note = try container.decode(String.self, forKey: .note)
self.url = try container.decode(URL.self, forKey: .url)
@ -71,12 +70,12 @@ public final class Account: AccountProtocol, Decodable, Sendable {
}
}
public static func authorizeFollowRequest(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize")
public static func authorizeFollowRequest(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
}
public static func rejectFollowRequest(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject")
public static func rejectFollowRequest(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject")
}
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {
@ -95,8 +94,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
return request
}
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
"only_media" => onlyMedia,
"pinned" => pinned,
"exclude_replies" => excludeReplies,
@ -110,12 +109,6 @@ public final class Account: AccountProtocol, Decodable, Sendable {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/follow")
}
public static func setShowReblogs(_ accountID: String, showReblogs: Bool) -> Request<Relationship> {
return Request(method: .post, path: "/api/v1/accounts/\(accountID)/follow", body: ParametersBody([
"reblogs" => showReblogs
]))
}
public static func unfollow(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
}
@ -172,7 +165,7 @@ extension Account: CustomDebugStringConvertible {
}
extension Account {
public struct Field: Codable, Equatable, Sendable {
public struct Field: Codable {
public let name: String
public let value: String
public let verifiedAt: Date?

View File

@ -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"
}
}
}

View File

@ -8,11 +8,11 @@
import Foundation
public struct Application: Decodable, Sendable {
public class Application: Decodable {
public let name: String
public let website: URL?
public init(from decoder: Decoder) throws {
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)

View File

@ -8,7 +8,7 @@
import Foundation
public struct Attachment: Codable, Sendable {
public class Attachment: Codable {
public let id: String
public let kind: Kind
public let url: URL
@ -25,18 +25,7 @@ public struct Attachment: Codable, Sendable {
], nil))
}
public init(id: String, kind: Attachment.Kind, url: URL, remoteURL: URL? = nil, previewURL: URL? = nil, meta: Attachment.Metadata? = nil, description: String? = nil, blurHash: String? = nil) {
self.id = id
self.kind = kind
self.url = url
self.remoteURL = remoteURL
self.previewURL = previewURL
self.meta = meta
self.description = description
self.blurHash = blurHash
}
public init(from decoder: Decoder) throws {
required public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.kind = try container.decode(Kind.self, forKey: .kind)
@ -61,7 +50,7 @@ public struct Attachment: Codable, Sendable {
}
extension Attachment {
public enum Kind: String, Codable, Sendable {
public enum Kind: String, Codable {
case image
case video
case gifv
@ -88,7 +77,7 @@ extension Attachment {
}
extension Attachment {
public struct Metadata: Codable, Sendable {
public struct Metadata: Codable {
public let length: String?
public let duration: Float?
public let audioEncoding: String?
@ -119,7 +108,7 @@ extension Attachment {
}
}
public struct ImageMetadata: Codable, Sendable {
public struct ImageMetadata: Codable {
public let width: Int?
public let height: Int?
public let size: String?

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public struct Card: Codable, Sendable {
public class Card: Codable {
public let url: WebURL
public let title: String
public let description: String
@ -26,39 +26,7 @@ public struct Card: Codable, Sendable {
/// Only present when returned from the trending links endpoint
public let history: [History]?
public init(
url: WebURL,
title: String,
description: String,
image: WebURL? = nil,
kind: Card.Kind,
authorName: String? = nil,
authorURL: WebURL? = nil,
providerName: String? = nil,
providerURL: WebURL? = nil,
html: String? = nil,
width: Int? = nil,
height: Int? = nil,
blurhash: String? = nil,
history: [History]? = nil
) {
self.url = url
self.title = title
self.description = description
self.image = image
self.kind = kind
self.authorName = authorName
self.authorURL = authorURL
self.providerName = providerName
self.providerURL = providerURL
self.html = html
self.width = width
self.height = height
self.blurhash = blurhash
self.history = history
}
public init(from decoder: Decoder) throws {
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.url = try container.decode(WebURL.self, forKey: .url)
@ -107,7 +75,7 @@ public struct Card: Codable, Sendable {
}
extension Card {
public enum Kind: String, Codable, Sendable {
public enum Kind: String, Codable {
case link
case photo
case video

View File

@ -8,7 +8,7 @@
import Foundation
public struct ConversationContext: Decodable, Sendable {
public class ConversationContext: Decodable {
public let ancestors: [Status]
public let descendants: [Status]

View File

@ -8,7 +8,7 @@
import Foundation
public enum DirectoryOrder: String, CaseIterable, Sendable {
public enum DirectoryOrder: String, CaseIterable {
case active
case new
}

View File

@ -1,97 +0,0 @@
//
// EditStatusParameters.swift
// Pachyderm
//
// Created by Shadowfacts on 5/10/23.
//
import Foundation
struct EditStatusParameters: Encodable, Sendable {
let id: String
let text: String
let contentType: StatusContentType
let spoilerText: String?
let sensitive: Bool
let language: String?
let mediaIDs: [String]
let mediaAttributes: [EditStatusMediaAttributes]
let poll: EditPollParameters?
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.text, forKey: .text)
try container.encode(self.contentType.mimeType, forKey: .contentType)
try container.encodeIfPresent(self.spoilerText, forKey: .spoilerText)
try container.encode(self.sensitive, forKey: .sensitive)
try container.encodeIfPresent(self.language, forKey: .language)
try container.encode(self.mediaIDs, forKey: .mediaIDs)
try container.encode(self.mediaAttributes, forKey: .mediaAttributes)
try container.encodeIfPresent(self.poll, forKey: .poll)
}
enum CodingKeys: String, CodingKey {
case id
case text = "status"
case contentType = "content_type"
case spoilerText = "spoiler_text"
case sensitive
case language
case mediaIDs = "media_ids"
case mediaAttributes = "media_attributes"
case poll
}
}
public struct EditPollParameters: Encodable, Sendable {
let options: [String]
let expiresIn: Int
let multiple: Bool
public init(options: [String], expiresIn: Int, multiple: Bool) {
self.options = options
self.expiresIn = expiresIn
self.multiple = multiple
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.options, forKey: .options)
try container.encode(self.expiresIn, forKey: .expiresIn)
try container.encode(self.multiple, forKey: .multiple)
}
enum CodingKeys: String, CodingKey {
case options
case expiresIn = "expires_in"
case multiple
}
}
public struct EditStatusMediaAttributes: Encodable, Sendable {
let id: String
let description: String
let focus: (Float, Float)?
public init(id: String, description: String, focus: (Float, Float)?) {
self.id = id
self.description = description
self.focus = focus
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(description, forKey: .description)
if let focus {
try container.encode("\(focus.0),\(focus.1)", forKey: .focus)
}
}
enum CodingKeys: String, CodingKey {
case id
case description
case focus
}
}

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public struct Emoji: Codable, Sendable {
public class Emoji: Codable {
public let shortcode: String
// these shouldn't need to be WebURLs as they're not external resources,
// but some instances (pleroma?) has emoji urls that Foundation considers malformed so we use WebURL to be more lenient
@ -18,16 +18,11 @@ public struct Emoji: Codable, Sendable {
public let visibleInPicker: Bool
public let category: String?
public init(from decoder: Decoder) throws {
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.shortcode = try container.decode(String.self, forKey: .shortcode)
do {
self.url = try container.decode(WebURL.self, forKey: .url)
} catch {
let s = try? container.decode(String.self, forKey: .url)
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
}
self.url = try container.decode(WebURL.self, forKey: .url)
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
self.category = try container.decodeIfPresent(String.self, forKey: .category)
@ -48,13 +43,8 @@ extension Emoji: CustomDebugStringConvertible {
}
}
extension Emoji: Equatable, Hashable {
extension Emoji: Equatable {
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
}
public func hash(into hasher: inout Hasher) {
hasher.combine(shortcode)
hasher.combine(url)
}
}

Some files were not shown because too many files have changed in this diff Show More