Compare commits
3 Commits
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 28332ef448 | |
Shadowfacts | b9edf13b92 | |
Shadowfacts | c36a239f46 |
|
@ -1,5 +1,3 @@
|
|||
Dist.xcconfig
|
||||
Tusker.xcconfig
|
||||
.DS_Store
|
||||
MyPlayground.playground/
|
||||
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
[submodule "Gifu"]
|
||||
path = Gifu
|
||||
url = git://github.com/kaishin/Gifu.git
|
||||
[submodule "Embassy"]
|
||||
path = Embassy
|
||||
url = https://github.com/envoy/Embassy.git
|
||||
|
|
|
@ -1,157 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
sodipodi:docname="Tusker.svg"
|
||||
inkscape:version="1.0beta2 (2b71d25, 2019-12-03)"
|
||||
inkscape:export-ydpi="11.52"
|
||||
inkscape:export-xdpi="11.52"
|
||||
inkscape:export-filename="/Users/shadowfacts/Desktop/60x60@2x.png"
|
||||
id="svg8"
|
||||
version="1.1"
|
||||
viewBox="0 0 264.58333 264.58333"
|
||||
height="1000"
|
||||
width="1000">
|
||||
<defs
|
||||
id="defs2">
|
||||
<inkscape:path-effect
|
||||
bendpath1-nodetypes="cc"
|
||||
bendpath4="M 27.271345,85.808468 V 178.94843"
|
||||
bendpath3="M 27.271345,178.94843 H 242.39013"
|
||||
bendpath2="M 242.39013,85.808468 V 178.94843"
|
||||
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
|
||||
xx="true"
|
||||
yy="true"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect1345"
|
||||
effect="envelope" />
|
||||
<inkscape:path-effect
|
||||
allow_transforms="true"
|
||||
css_properties=""
|
||||
attributes=""
|
||||
method="d"
|
||||
linkeditem=""
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect38"
|
||||
effect="clone_original" />
|
||||
<inkscape:path-effect
|
||||
scale_y_rel="false"
|
||||
prop_scale="1"
|
||||
strokepath="M0,0 L1,0"
|
||||
endpoint_spacing_variation="0;1"
|
||||
endpoint_edge_variation="0;1"
|
||||
startpoint_spacing_variation="0;1"
|
||||
startpoint_edge_variation="0;1"
|
||||
count="5"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect32"
|
||||
effect="curvestitching" />
|
||||
<filter
|
||||
height="1.3500000000000001"
|
||||
width="1.2"
|
||||
id="filter1277"
|
||||
inkscape:label="Drop Shadow"
|
||||
style="color-interpolation-filters:sRGB;">
|
||||
<feFlood
|
||||
id="feFlood1267"
|
||||
result="flood"
|
||||
flood-color="rgb(0,0,0)"
|
||||
flood-opacity="0.321569" />
|
||||
<feComposite
|
||||
id="feComposite1269"
|
||||
result="composite1"
|
||||
operator="in"
|
||||
in2="SourceGraphic"
|
||||
in="flood" />
|
||||
<feGaussianBlur
|
||||
id="feGaussianBlur1271"
|
||||
result="blur"
|
||||
stdDeviation="5"
|
||||
in="composite1" />
|
||||
<feOffset
|
||||
id="feOffset1273"
|
||||
result="offset"
|
||||
dy="5"
|
||||
dx="-2.5" />
|
||||
<feComposite
|
||||
id="feComposite1275"
|
||||
result="composite2"
|
||||
operator="over"
|
||||
in2="offset"
|
||||
in="SourceGraphic" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="23"
|
||||
inkscape:window-x="1920"
|
||||
inkscape:window-height="1395"
|
||||
inkscape:window-width="1902"
|
||||
units="px"
|
||||
showgrid="false"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:current-layer="layer2"
|
||||
inkscape:document-units="px"
|
||||
inkscape:cy="496.39379"
|
||||
inkscape:cx="442.66632"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
borderopacity="1.0"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff"
|
||||
id="base" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 2"
|
||||
id="layer2"
|
||||
inkscape:groupmode="layer">
|
||||
<rect
|
||||
y="-0.14500916"
|
||||
x="-0.14500916"
|
||||
height="264.87335"
|
||||
width="264.87335"
|
||||
id="rect865"
|
||||
style="fill:#75e04e;fill-opacity:1;stroke:#75e04e;stroke-width:0.239149;stroke-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
style="display:none"
|
||||
id="layer1"
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Layer 1">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||
id="path28" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Layer 1 copy"
|
||||
inkscape:groupmode="layer"
|
||||
id="g1343">
|
||||
<path
|
||||
id="path1341"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.56578600000000001px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 8.1 KiB |
|
@ -1,153 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
sodipodi:docname="Tusker transparent.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
inkscape:export-ydpi="98.304001"
|
||||
inkscape:export-xdpi="98.304001"
|
||||
inkscape:export-filename="../Desktop/1024x1024-dark@1x.png"
|
||||
id="svg8"
|
||||
version="1.1"
|
||||
viewBox="0 0 264.58333 264.58333"
|
||||
height="1000"
|
||||
width="1000"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2">
|
||||
<inkscape:path-effect
|
||||
bendpath1-nodetypes="cc"
|
||||
bendpath4="M 27.271345,85.808468 V 178.94843"
|
||||
bendpath3="M 27.271345,178.94843 H 242.39013"
|
||||
bendpath2="M 242.39013,85.808468 V 178.94843"
|
||||
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
|
||||
xx="true"
|
||||
yy="true"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect1345"
|
||||
effect="envelope" />
|
||||
<inkscape:path-effect
|
||||
allow_transforms="true"
|
||||
css_properties=""
|
||||
attributes=""
|
||||
method="d"
|
||||
linkeditem=""
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect38"
|
||||
effect="clone_original" />
|
||||
<inkscape:path-effect
|
||||
scale_y_rel="false"
|
||||
prop_scale="1"
|
||||
strokepath="M0,0 L1,0"
|
||||
endpoint_spacing_variation="0;1"
|
||||
endpoint_edge_variation="0;1"
|
||||
startpoint_spacing_variation="0;1"
|
||||
startpoint_edge_variation="0;1"
|
||||
count="5"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect32"
|
||||
effect="curvestitching" />
|
||||
<filter
|
||||
height="1.317445"
|
||||
width="1.1258237"
|
||||
id="filter1277"
|
||||
inkscape:label="Drop Shadow"
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
x="-0.068723437"
|
||||
y="-0.1318855">
|
||||
<feFlood
|
||||
id="feFlood1267"
|
||||
result="flood"
|
||||
flood-color="rgb(0,0,0)"
|
||||
flood-opacity="0.321569" />
|
||||
<feComposite
|
||||
id="feComposite1269"
|
||||
result="composite1"
|
||||
operator="in"
|
||||
in2="SourceGraphic"
|
||||
in="flood" />
|
||||
<feGaussianBlur
|
||||
id="feGaussianBlur1271"
|
||||
result="blur"
|
||||
stdDeviation="5"
|
||||
in="composite1" />
|
||||
<feOffset
|
||||
id="feOffset1273"
|
||||
result="offset"
|
||||
dy="5"
|
||||
dx="-2.5" />
|
||||
<feComposite
|
||||
id="feComposite1275"
|
||||
result="composite2"
|
||||
operator="over"
|
||||
in2="offset"
|
||||
in="SourceGraphic" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-x="1280"
|
||||
inkscape:window-height="1387"
|
||||
inkscape:window-width="1280"
|
||||
units="px"
|
||||
showgrid="false"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:current-layer="layer2"
|
||||
inkscape:document-units="px"
|
||||
inkscape:cy="404.46507"
|
||||
inkscape:cx="442.29528"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
borderopacity="1.0"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff"
|
||||
id="base"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 2"
|
||||
id="layer2"
|
||||
inkscape:groupmode="layer" />
|
||||
<g
|
||||
style="display:none"
|
||||
id="layer1"
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Layer 1">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||
id="path28" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Layer 1 copy"
|
||||
inkscape:groupmode="layer"
|
||||
id="g1343">
|
||||
<path
|
||||
id="path1341"
|
||||
style="fill:#75e04e;fill-opacity:1;stroke:#74e04d;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 7.9 KiB |
|
@ -1,162 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
sodipodi:docname="Tusker.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
inkscape:export-ydpi="98.304001"
|
||||
inkscape:export-xdpi="98.304001"
|
||||
inkscape:export-filename="../Desktop/1024x1024@1x.png"
|
||||
id="svg8"
|
||||
version="1.1"
|
||||
viewBox="0 0 264.58333 264.58333"
|
||||
height="1000"
|
||||
width="1000"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2">
|
||||
<inkscape:path-effect
|
||||
bendpath1-nodetypes="cc"
|
||||
bendpath4="M 27.271345,85.808468 V 178.94843"
|
||||
bendpath3="M 27.271345,178.94843 H 242.39013"
|
||||
bendpath2="M 242.39013,85.808468 V 178.94843"
|
||||
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
|
||||
xx="true"
|
||||
yy="true"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect1345"
|
||||
effect="envelope" />
|
||||
<inkscape:path-effect
|
||||
allow_transforms="true"
|
||||
css_properties=""
|
||||
attributes=""
|
||||
method="d"
|
||||
linkeditem=""
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect38"
|
||||
effect="clone_original" />
|
||||
<inkscape:path-effect
|
||||
scale_y_rel="false"
|
||||
prop_scale="1"
|
||||
strokepath="M0,0 L1,0"
|
||||
endpoint_spacing_variation="0;1"
|
||||
endpoint_edge_variation="0;1"
|
||||
startpoint_spacing_variation="0;1"
|
||||
startpoint_edge_variation="0;1"
|
||||
count="5"
|
||||
lpeversion="1"
|
||||
is_visible="true"
|
||||
id="path-effect32"
|
||||
effect="curvestitching" />
|
||||
<filter
|
||||
height="1.317445"
|
||||
width="1.1258237"
|
||||
id="filter1277"
|
||||
inkscape:label="Drop Shadow"
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
x="-0.068723437"
|
||||
y="-0.1318855">
|
||||
<feFlood
|
||||
id="feFlood1267"
|
||||
result="flood"
|
||||
flood-color="rgb(0,0,0)"
|
||||
flood-opacity="0.321569" />
|
||||
<feComposite
|
||||
id="feComposite1269"
|
||||
result="composite1"
|
||||
operator="in"
|
||||
in2="SourceGraphic"
|
||||
in="flood" />
|
||||
<feGaussianBlur
|
||||
id="feGaussianBlur1271"
|
||||
result="blur"
|
||||
stdDeviation="5"
|
||||
in="composite1" />
|
||||
<feOffset
|
||||
id="feOffset1273"
|
||||
result="offset"
|
||||
dy="5"
|
||||
dx="-2.5" />
|
||||
<feComposite
|
||||
id="feComposite1275"
|
||||
result="composite2"
|
||||
operator="over"
|
||||
in2="offset"
|
||||
in="SourceGraphic" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-x="1280"
|
||||
inkscape:window-height="1387"
|
||||
inkscape:window-width="1280"
|
||||
units="px"
|
||||
showgrid="false"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:current-layer="layer2"
|
||||
inkscape:document-units="px"
|
||||
inkscape:cy="496.38895"
|
||||
inkscape:cx="442.29528"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
borderopacity="1.0"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff"
|
||||
id="base"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 2"
|
||||
id="layer2"
|
||||
inkscape:groupmode="layer">
|
||||
<rect
|
||||
y="-0.14500916"
|
||||
x="-0.14500916"
|
||||
height="264.87335"
|
||||
width="264.87335"
|
||||
id="rect865"
|
||||
style="fill:#75e04e;fill-opacity:1;stroke:#75e04e;stroke-width:0.239149;stroke-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
style="display:none"
|
||||
id="layer1"
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Layer 1">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||
id="path28" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Layer 1 copy"
|
||||
inkscape:groupmode="layer"
|
||||
id="g1343">
|
||||
<path
|
||||
id="path1341"
|
||||
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.56578600000000001px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
|
||||
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 8.2 KiB |
|
@ -1,268 +0,0 @@
|
|||
## 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
|
1171
CHANGELOG.md
1171
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
|
@ -1,7 +0,0 @@
|
|||
# Haptic Feedback
|
||||
|
||||
## Selection changed
|
||||
`UISelectionFeedbackGenerator`
|
||||
|
||||
## Actions
|
||||
On success, `UIImpactFeedbackGenerator` with the `.light` style. On error, `UINotificationFeedbackGenerator` with the `.error` type.
|
|
@ -0,0 +1,355 @@
|
|||
# X-Callback-URLs in Tusker
|
||||
|
||||
Tusker supports inter-app-communication using the [X-Callback-URL standard](http://x-callback-url.com/).
|
||||
|
||||
In short, requests are performed by opening the URL `tusker://x-callback-url/[request]` (where `[request]` is one of the requests listed below) with a variety of parameters.
|
||||
|
||||
## Callbacks
|
||||
|
||||
X-Callback-URLs support three types of callbacks: on success, on cancellation, and on error. Callbacks are specified as query parameters whose keys identify which callback (`x-success`, `x-cancel`, and `x-error`) and whose values are other URLs that should be opened to run the callback.
|
||||
|
||||
Data is passed to callbacks by adding additional query parameters to the callback URL. The `x-error` callback always returns a description of the error in the `error` parameter. Other data is provided depending on the request.
|
||||
|
||||
### JSON Responses
|
||||
|
||||
By default, callback data is included in URL query parameters of the callback URL. If the `json=true` parameter is provided, the response data will be encoded as JSON, converted to [Base64](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding), and provided in the `response` query parameter of the callback.
|
||||
|
||||
## Silent Requests
|
||||
|
||||
Tusker X-Callback-URL requests can be performed silently, without user confirmation. Each source app requires user permission on the first attempted silent action.
|
||||
|
||||
To perform a silent request:
|
||||
|
||||
1. Provide the `silent=true` URL query parameter in the request.
|
||||
|
||||
2. Specify the `x-source` parameter. It must be a (human interpretable) name of the source application/service. If `x-source` is not specified, the error callback will be invoked with the error message:
|
||||
|
||||
```
|
||||
Cannot perform silent action without source app, x-source parameter must be specified.
|
||||
```
|
||||
|
||||
3. Depending on the current permission state of the source app, one of several things will happen:
|
||||
1. If the permission is **undecided** (i.e. the user has neither accepted nor rejected the silent action request), an alert will be displayed notifying the user that the source app has requested permission to silently perform actions. After the user either accepts or rejects the request, execution will continue with that permission state.
|
||||
2. **Accepted**: the request will be carried out silently and the appropriate callback executed.
|
||||
3. **Rejected**: the request will be performed with the confirmation UI, as if the `silent` parameter had been false/unprovided.
|
||||
|
||||
The silent actions permission state of a given source app is not exposed in the callback.
|
||||
|
||||
## Other Notes
|
||||
|
||||
#### Instance-Local IDs
|
||||
|
||||
Instance-local IDs are provided for many responses and accept in place of URLs/URIs/qualified names in many requests. When possible, instance-local IDs should be preferred requests using them can often be performed faster because there's no need to perform a search query or make requests to remote instances.
|
||||
|
||||
#### Qualified Usernames
|
||||
|
||||
Qualified username refers to the domain-qualified identifier of an account. For example, `shadowfacts@social.shadowfacts.net`. They do not include a leading `@`.
|
||||
|
||||
#### Dates
|
||||
|
||||
Dates in responses are encoded as Unix timestamps.
|
||||
|
||||
## Requests
|
||||
|
||||
- [Accounts](#accounts)
|
||||
- [`showAccount`](#showaccount)
|
||||
- [`getCurrentUser`](#getcurrentuser)
|
||||
- [`getAccount`](#getaccount)
|
||||
- [`followUser`](#followuser)
|
||||
- [Statuses](#statuses)
|
||||
- [`showStatus`](#showstatus)
|
||||
- [`getStatus`](#getstatus)
|
||||
- [`postStatus`](#poststatus)
|
||||
- [`favoriteStatus`](#favoritestatus)
|
||||
- [`reblogStatus`](#reblogstatus)
|
||||
- [Notifications](#notifications)
|
||||
- [`getNotification`](#getnotification)
|
||||
- [`getNotifications`](#getnotifications)
|
||||
- [`dismissNotification`](#dismissnotification)
|
||||
- [`dismissAllNotifications`](#dismissallnotifications)
|
||||
- [Instances](#instances)
|
||||
- [`getCurrentInstance`](#getcurrentinstance)
|
||||
- [Misc](#misc)
|
||||
- [`search`](#search)
|
||||
|
||||
### Accounts
|
||||
|
||||
#### `showAccount`
|
||||
|
||||
Presents the given account in Tusker.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `accountID` (string) | The instance-local ID of the account | Yes |
|
||||
| `accountURL` (URL) | The URL of the remote account | Yes |
|
||||
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
No data if successful.
|
||||
|
||||
#### `getCurrentUser`
|
||||
|
||||
Retrieves the currently logged-in user.
|
||||
|
||||
##### Request
|
||||
|
||||
No parameters.
|
||||
|
||||
##### Response:
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------------- | --------------------------------------------- | -------- |
|
||||
| `username` (string) | The [qualified username](#qualifiedusernames) | No |
|
||||
| `displayName` (string) | The display name | No |
|
||||
| `locked` (bool) | Whether the user's account is locked | No |
|
||||
| `followers` (int) | The number of followers the user has | No |
|
||||
| `following` (int) | The number of accounts user is following | No |
|
||||
| `url` (URL) | The URL of the user's account | No |
|
||||
| `avatarURL` (URL) | The URL of the user's avatar image | No |
|
||||
| `headerURL` (URL) | The URL of the user's header image | No |
|
||||
|
||||
#### `getAccount`
|
||||
|
||||
Retrieves the given account details. One of `accountID`, `accountURL`, or `acct` must be provided.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `accountID` (string) | The instance-local ID of the account | Yes |
|
||||
| `accountURL` (URL) | The URL/URI of the account | Yes |
|
||||
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------------- | ------------------------------------------- | -------- |
|
||||
| `username` (string) | The qualified username | No |
|
||||
| `displayName` (string) | The display name | No |
|
||||
| `locked` (bool) | Whether the account is locked | No |
|
||||
| `followers` (int) | The number of followers the account has | No |
|
||||
| `following` (int) | The number of accounts account is following | No |
|
||||
| `url` (URL) | The URL of the account | No |
|
||||
| `avatarURL` (URL) | The URL of the account's avatar image | No |
|
||||
| `headerURL` (URL) | The URL of the account's header image | No |
|
||||
|
||||
#### `followUser`
|
||||
|
||||
Follows the given account from the logged-in user's account. One of `accountID`, `accountURL`, or `acct` must be provided.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `accountID` (string) | The instance-local ID of the account | Yes |
|
||||
| `accountURL` (URL) | The URL/URI of the account | Yes |
|
||||
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------- | ------------------------------- | -------- |
|
||||
| `url` (URL) | The URL of the followed account | No |
|
||||
|
||||
### Statuses
|
||||
|
||||
#### `showStatus`
|
||||
|
||||
Presents the given status in Tusker.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ----------------------------------- | -------- |
|
||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
||||
| `statusURL` (URL) | The URL of a remote status | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
No data if successful.
|
||||
|
||||
#### `getStatus`
|
||||
|
||||
Retrieves the given status details. One of `statusID` or `statusURL` must be provided.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
||||
| `statusURL` (URL) | The URL/URI of the status | Yes |
|
||||
| `html` (bool) | Whether to return the content as HTML or plain-text only. Default: `false` (plain-text). | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `url` (URL) | The URL of the status | Yes |
|
||||
| `uri` (string) | The URI of the status | No |
|
||||
| `id` (string) | The instance-local ID of the status | |
|
||||
| `account` (string) | The [qualified username](#qualifiedusernames) of the account that posted (or reblogged if `reblog` is present) the status | No |
|
||||
| `inReplyTo` (string) | The instance-local ID of the status that this status is a reply to | Yes |
|
||||
| `posted` (date) | The date the status was posted | No |
|
||||
| `content` (string) | The content of the status (HTML if the `html` parameter was true, plain-text otherwise) | No |
|
||||
| `reblog` (string) | The **instance-local** ID of the status that this is a reblog of. If not present, this status was not a reblog. | Yes |
|
||||
|
||||
#### `postStatus`
|
||||
|
||||
Posts a status from the logged-in user's account.
|
||||
|
||||
Can be performed silently.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `mentioning` (bool) | The [qualified username](#qualifiedusernames) to mention in the status | Yes |
|
||||
| `text` (string) | The text to post/pre-fill the status text field with | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ---------------------------- | -------- |
|
||||
| `statusURL` (URL) | The URL of the posted status | Yes |
|
||||
| `statusURI` (string) | The URI of the posted status | No |
|
||||
|
||||
#### `favoriteStatus`
|
||||
|
||||
Favorites the given status from the logged-in user's account. One of `statusID` or `statusURL` must be provided.
|
||||
|
||||
Can be performed silently.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ----------------------------------- | -------- |
|
||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
||||
| `statusURL` (URL) | The URL/URI of a status | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------- | -------- |
|
||||
| `statusURL` (URL) | The URL of the favorited status | Yes |
|
||||
| `statusURI` (string) | The URI of the favorited status | No |
|
||||
|
||||
#### `reblogStatus`
|
||||
|
||||
Reblogs the given status from the logged-in user's account. One of `statusID` or `statusURL` must be provided.
|
||||
|
||||
Can be performed silently.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------- | ----------------------------------- | -------- |
|
||||
| `statusID` (string) | The instance-local ID of the status | Yes |
|
||||
| `statusURL` (URL) | The URL/URI of a status | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------- | -------- |
|
||||
| `statusURL` (URL) | The URL of the reblogged status | Yes |
|
||||
| `statusURI` (string) | The URI of the reblogged status | No |
|
||||
|
||||
### Notifications
|
||||
|
||||
#### `getNotification`
|
||||
|
||||
Retrieves the given notification details.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------------- | ----------------------------------------- | -------- |
|
||||
| `notificationID` (string) | The instance-local ID of the notification | No |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| -------------------- | ------------------------------------------------------------ | -------- |
|
||||
| `kind` (string) | One of `mention`, `reblog`, `favourite`, or `follow` | No |
|
||||
| `date` (date) | The date the notification was created. | No |
|
||||
| `accountID` (string) | The instance-local ID of the account that sent the notification | No |
|
||||
| `statusID` (string) | The instance-local ID of the status associated with the notification. Not applicable for `kind=follow`. | Yes |
|
||||
|
||||
#### `getNotifications`
|
||||
|
||||
Retrieves the most recent notifications.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------- | ---------------------------------------------------- | -------- |
|
||||
| `count` (int) | The number of notifications to retrieve. Default: 20 | Yes |
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------------ | ---------------------------------------------------------- | -------- |
|
||||
| `notifications` (string) | A comma-delimited array of instance-local notification IDs | No |
|
||||
|
||||
#### `dismissNotification`
|
||||
|
||||
Dismisses the given notification.
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ----------------------- | ----------------------------------------- | -------- |
|
||||
| `notification` (string) | The instance-local ID of the notification | No |
|
||||
|
||||
##### Response
|
||||
|
||||
No response data if successful.
|
||||
|
||||
#### `dismissAllNotifications`
|
||||
|
||||
Dismisses all notifications.
|
||||
|
||||
##### Request
|
||||
|
||||
No parameters.
|
||||
|
||||
##### Response
|
||||
|
||||
No data if successful.
|
||||
|
||||
### Instances
|
||||
|
||||
#### `getCurrentInstance`
|
||||
|
||||
Retrieves the current instance details.
|
||||
|
||||
##### Request
|
||||
|
||||
No parameters.
|
||||
|
||||
##### Response
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ------------------------- | ------------------------------------------------------- | -------- |
|
||||
| `uri` (string) | The instance URI | No |
|
||||
| `name` (string) | The instance name | No |
|
||||
| `description` (string) | The instance description | No |
|
||||
| `contactAccount` (string) | The instance-local ID of the instance's contact account | No |
|
||||
|
||||
|
||||
### Misc
|
||||
|
||||
#### `search`
|
||||
Performs a search in Tusker with the given query
|
||||
|
||||
##### Request
|
||||
|
||||
| Parameter (type) | Description | Optional |
|
||||
| ---------------- | ------------------------ |--------- |
|
||||
| `query` (string) | The search query to use. | No |
|
||||
|
||||
##### Response
|
||||
|
||||
No data if successful.
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007
|
|
@ -1,22 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>TuskerInfo</key>
|
||||
<dict>
|
||||
<key>PushProxyHost</key>
|
||||
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
|
||||
<key>PushProxyScheme</key>
|
||||
<string>$(TUSKER_PUSH_PROXY_SCHEME)</string>
|
||||
<key>SentryDSN</key>
|
||||
<string>$(SENTRY_DSN)</string>
|
||||
</dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
|
@ -1,14 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.$(BUNDLE_ID_PREFIX).Tusker</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
|
@ -1,397 +0,0 @@
|
|||
//
|
||||
// NotificationService.swift
|
||||
// NotificationExtension
|
||||
//
|
||||
// Created by Shadowfacts on 4/9/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UserNotifications
|
||||
import UserAccounts
|
||||
import PushNotifications
|
||||
import CryptoKit
|
||||
import OSLog
|
||||
import Pachyderm
|
||||
import Intents
|
||||
import HTMLStreamer
|
||||
import WebURL
|
||||
import UIKit
|
||||
import TuskerPreferences
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
|
||||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self)
|
||||
|
||||
private var pendingRequest: (UNMutableNotificationContent, (UNNotificationContent) -> Void, Task<Void, Never>)?
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
logger.error("Couldn't get mutable content")
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
guard request.content.userInfo["v"] as? Int == 1,
|
||||
let accountID = (request.content.userInfo["ctx"] as? String).flatMap(\.removingPercentEncoding),
|
||||
let account = UserAccountsManager.shared.getAccount(id: accountID),
|
||||
let subscription = getSubscription(account: account),
|
||||
let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }),
|
||||
let salt = (request.content.userInfo["salt"] as? String).flatMap(decodeBase64URL(_:)),
|
||||
let serverPublicKeyData = (request.content.userInfo["pk"] as? String).flatMap(decodeBase64URL(_:)) else {
|
||||
logger.error("Missing info from push notification")
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
guard let body = decryptNotification(subscription: subscription, serverPublicKeyData: serverPublicKeyData, salt: salt, encryptedBody: encryptedBody) else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
let withoutPadding = body.dropFirst(2)
|
||||
|
||||
let notification: PushNotification
|
||||
do {
|
||||
notification = try JSONDecoder().decode(PushNotification.self, from: withoutPadding)
|
||||
} catch {
|
||||
logger.error("Unable to decode push payload: \(String(describing: error))")
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
mutableContent.title = notification.title
|
||||
mutableContent.body = notification.body
|
||||
mutableContent.userInfo["notificationID"] = notification.notificationID
|
||||
mutableContent.userInfo["accountID"] = accountID
|
||||
mutableContent.targetContentIdentifier = accountID
|
||||
|
||||
let task = Task {
|
||||
await updateNotificationContent(mutableContent, account: account, push: notification)
|
||||
if !Task.isCancelled {
|
||||
contentHandler(pendingRequest?.0 ?? mutableContent)
|
||||
pendingRequest = nil
|
||||
}
|
||||
}
|
||||
pendingRequest = (mutableContent, contentHandler, task)
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
if let pendingRequest {
|
||||
logger.debug("Expiring with pending request")
|
||||
pendingRequest.2.cancel()
|
||||
pendingRequest.1(pendingRequest.0)
|
||||
self.pendingRequest = nil
|
||||
} else {
|
||||
logger.debug("Expiring without pending request")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNotificationContent(_ content: UNMutableNotificationContent, account: UserAccountInfo, push: PushNotification) async {
|
||||
let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken)
|
||||
let notification: Pachyderm.Notification
|
||||
do {
|
||||
notification = try await client.run(Client.getNotification(id: push.notificationID)).0
|
||||
} catch {
|
||||
logger.error("Error fetching notification: \(String(describing: error))")
|
||||
return
|
||||
}
|
||||
|
||||
let kindStr: String?
|
||||
switch notification.kind {
|
||||
case .reblog:
|
||||
kindStr = "🔁 Reblogged"
|
||||
case .favourite:
|
||||
kindStr = "⭐️ Favorited"
|
||||
case .follow:
|
||||
kindStr = "👤 Followed by @\(notification.account.acct)"
|
||||
case .followRequest:
|
||||
kindStr = "👤 Asked to follow by @\(notification.account.acct)"
|
||||
case .poll:
|
||||
kindStr = "📊 Poll finished"
|
||||
case .update:
|
||||
kindStr = "✏️ Edited"
|
||||
case .emojiReaction:
|
||||
if let emoji = notification.emoji {
|
||||
kindStr = "\(emoji) Reacted"
|
||||
} else {
|
||||
kindStr = nil
|
||||
}
|
||||
default:
|
||||
kindStr = nil
|
||||
}
|
||||
|
||||
let notificationContent: String?
|
||||
if let status = notification.status {
|
||||
if notification.kind == .mention || notification.kind == .status,
|
||||
!status.spoilerText.isEmpty {
|
||||
notificationContent = "⚠️ \(status.spoilerText)"
|
||||
} else {
|
||||
notificationContent = NotificationService.textConverter.convert(html: status.content)
|
||||
}
|
||||
} else if notification.kind == .follow || notification.kind == .followRequest {
|
||||
notificationContent = nil
|
||||
} else {
|
||||
notificationContent = push.body
|
||||
}
|
||||
|
||||
content.body = [kindStr, notificationContent].compactMap { $0 }.joined(separator: "\n")
|
||||
|
||||
let attachmentDataTask: Task<URL?, Never>?
|
||||
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
|
||||
// because we risk just fetching the same thing a bunch of times for many senders.
|
||||
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
|
||||
let status = notification.status,
|
||||
!status.sensitive,
|
||||
let attachment = status.attachments.first {
|
||||
let url = attachment.previewURL ?? attachment.url
|
||||
attachmentDataTask = Task {
|
||||
do {
|
||||
let data = try await URLSession.shared.data(from: url).0
|
||||
let localAttachmentURL = FileManager.default.temporaryDirectory.appendingPathComponent("attachment_\(attachment.id)").appendingPathExtension(url.pathExtension)
|
||||
try data.write(to: localAttachmentURL)
|
||||
return localAttachmentURL
|
||||
} catch {
|
||||
logger.error("Error setting notification attachments: \(String(describing: error))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
attachmentDataTask = nil
|
||||
}
|
||||
|
||||
let conversationIdentifier: String?
|
||||
if let status = notification.status {
|
||||
if let context = status.pleromaExtras?.context {
|
||||
conversationIdentifier = "context:\(context)"
|
||||
} else if [Notification.Kind.reblog, .favourite, .poll, .update].contains(notification.kind) {
|
||||
conversationIdentifier = "status:\(status.id)"
|
||||
} else {
|
||||
conversationIdentifier = nil
|
||||
}
|
||||
} else {
|
||||
conversationIdentifier = nil
|
||||
}
|
||||
|
||||
let account: Account?
|
||||
switch notification.kind {
|
||||
case .mention, .status:
|
||||
account = notification.status?.account
|
||||
default:
|
||||
account = notification.account
|
||||
}
|
||||
let sender: INPerson?
|
||||
if let account {
|
||||
let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown)
|
||||
let image: INImage?
|
||||
if let avatar = account.avatar,
|
||||
let (data, resp) = try? await URLSession.shared.data(from: avatar),
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode,
|
||||
(200...299).contains(code) {
|
||||
image = INImage(imageData: data)
|
||||
} else {
|
||||
image = nil
|
||||
}
|
||||
sender = INPerson(
|
||||
personHandle: handle,
|
||||
nameComponents: nil,
|
||||
displayName: account.displayName,
|
||||
image: image,
|
||||
contactIdentifier: nil,
|
||||
customIdentifier: account.id
|
||||
)
|
||||
} else {
|
||||
sender = nil
|
||||
}
|
||||
|
||||
let intent = INSendMessageIntent(
|
||||
recipients: nil,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: notificationContent,
|
||||
speakableGroupName: nil,
|
||||
conversationIdentifier: conversationIdentifier,
|
||||
serviceName: nil,
|
||||
sender: sender,
|
||||
attachments: nil
|
||||
)
|
||||
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
interaction.direction = .incoming
|
||||
|
||||
do {
|
||||
try await interaction.donate()
|
||||
} catch {
|
||||
logger.error("Error donating interaction: \(String(describing: error))")
|
||||
return
|
||||
}
|
||||
|
||||
let updatedContent: UNMutableNotificationContent
|
||||
|
||||
let contentProviding: any UNNotificationContentProviding
|
||||
if #available(iOS 18.0, visionOS 2.0, *),
|
||||
await Preferences.shared.hasFeatureFlag(.pushNotifCustomEmoji) {
|
||||
let attributedString = NSMutableAttributedString(string: content.body)
|
||||
|
||||
for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() {
|
||||
let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
|
||||
guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }),
|
||||
let url = URL(emoji.url),
|
||||
let (data, _) = try? await URLSession.shared.data(from: url),
|
||||
let image = UIImage(data: data) else {
|
||||
continue
|
||||
}
|
||||
let attachment = NSTextAttachment(image: image)
|
||||
let attachmentStr = NSAttributedString(attachment: attachment)
|
||||
attributedString.replaceCharacters(in: match.range, with: attachmentStr)
|
||||
}
|
||||
|
||||
let attributedCtx = UNNotificationAttributedMessageContext(sendMessageIntent: intent, attributedContent: attributedString)
|
||||
contentProviding = attributedCtx
|
||||
} else {
|
||||
contentProviding = intent
|
||||
}
|
||||
|
||||
do {
|
||||
let newContent = try content.updating(from: contentProviding)
|
||||
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
|
||||
pendingRequest?.0 = newMutableContent
|
||||
updatedContent = newMutableContent
|
||||
} else {
|
||||
updatedContent = content
|
||||
}
|
||||
} catch {
|
||||
logger.error("Error updating notification from intent: \(String(describing: error))")
|
||||
updatedContent = content
|
||||
}
|
||||
|
||||
if let localAttachmentURL = await attachmentDataTask?.value,
|
||||
let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) {
|
||||
updatedContent.attachments = [
|
||||
attachment
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
|
||||
DispatchQueue.main.sync {
|
||||
// this is necessary because of a swift bug: https://github.com/apple/swift/pull/72507
|
||||
MainActor.runUnsafely {
|
||||
PushManager.shared.pushSubscription(account: account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func decryptNotification(subscription: PushNotifications.PushSubscription, serverPublicKeyData: Data, salt: Data, encryptedBody: Data) -> Data? {
|
||||
// See https://github.com/ClearlyClaire/webpush/blob/f14a4d52e201128b1b00245d11b6de80d6cfdcd9/lib/webpush/encryption.rb
|
||||
|
||||
var context = Data()
|
||||
context.append(0)
|
||||
let clientPublicKey = subscription.secretKey.publicKey.x963Representation
|
||||
let clientPublicKeyLength = UInt16(clientPublicKey.count)
|
||||
context.append(UInt8((clientPublicKeyLength >> 8) & 0xFF))
|
||||
context.append(UInt8(clientPublicKeyLength & 0xFF))
|
||||
context.append(clientPublicKey)
|
||||
let serverPublicKeyLength = UInt16(serverPublicKeyData.count)
|
||||
context.append(UInt8((serverPublicKeyLength >> 8) & 0xFF))
|
||||
context.append(UInt8(serverPublicKeyLength & 0xFF))
|
||||
context.append(serverPublicKeyData)
|
||||
|
||||
func info(encoding: String) -> Data {
|
||||
var info = Data("Content-Encoding: \(encoding)\0P-256".utf8)
|
||||
info.append(context)
|
||||
return info
|
||||
}
|
||||
|
||||
let sharedSecret: SharedSecret
|
||||
do {
|
||||
let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData)
|
||||
sharedSecret = try subscription.secretKey.sharedSecretFromKeyAgreement(with: serverPublicKey)
|
||||
} catch {
|
||||
logger.error("Error getting shared secret: \(String(describing: error))")
|
||||
return nil
|
||||
}
|
||||
|
||||
let sharedInfo = Data("Content-Encoding: auth\0".utf8)
|
||||
let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: subscription.authSecret, sharedInfo: sharedInfo, outputByteCount: 32)
|
||||
let contentEncryptionKeyInfo = info(encoding: "aesgcm")
|
||||
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: contentEncryptionKeyInfo, outputByteCount: 16)
|
||||
let nonceInfo = info(encoding: "nonce")
|
||||
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: nonceInfo, outputByteCount: 12)
|
||||
|
||||
let nonceAndEncryptedBody = nonce.withUnsafeBytes { noncePtr in
|
||||
var data = Data(buffer: noncePtr.bindMemory(to: UInt8.self))
|
||||
data.append(encryptedBody)
|
||||
return data
|
||||
}
|
||||
do {
|
||||
let sealedBox = try AES.GCM.SealedBox(combined: nonceAndEncryptedBody)
|
||||
let decrypted = try AES.GCM.open(sealedBox, using: contentEncryptionKey)
|
||||
return decrypted
|
||||
} catch {
|
||||
logger.error("Error decrypting push: \(String(describing: error))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MainActor {
|
||||
@_unavailableFromAsync
|
||||
@available(macOS, obsoleted: 14.0)
|
||||
@available(iOS, obsoleted: 17.0)
|
||||
@available(watchOS, obsoleted: 10.0)
|
||||
@available(tvOS, obsoleted: 17.0)
|
||||
@available(visionOS 1.0, *)
|
||||
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
||||
return try MainActor.assumeIsolated(body)
|
||||
}
|
||||
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
return try withoutActuallyEscaping(body) { fn in
|
||||
try unsafeBitCast(fn, to: (() throws -> T).self)()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeBase64URL(_ s: String) -> Data? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
if str.count % 4 != 0 {
|
||||
str.append(String(repeating: "=", count: 4 - str.count % 4))
|
||||
}
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
// copied from HTMLConverter.Callbacks, blergh
|
||||
private struct HTMLCallbacks: HTMLConversionCallbacks {
|
||||
static func makeURL(string: String) -> URL? {
|
||||
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||
// serializing the WebURL as a string and then having Foundation parse it again),
|
||||
// so, if available, use the system parser which doesn't require another round trip.
|
||||
if let url = try? URL.ParseStrategy().parse(string) {
|
||||
url
|
||||
} else if let web = WebURL(string),
|
||||
let url = URL(web) {
|
||||
url
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||
guard name == "span" else {
|
||||
return .default
|
||||
}
|
||||
let clazz = attributes.attributeValue(for: "class")
|
||||
if clazz == "invisible" {
|
||||
return .skip
|
||||
} else if clazz == "ellipsis" {
|
||||
return .append("…")
|
||||
} else {
|
||||
return .default
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import UIKit
|
||||
import 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)
|
||||
|
|
|
@ -24,8 +24,8 @@
|
|||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionServiceRoleType</key>
|
||||
<string>NSExtensionServiceRoleTypeViewer</string>
|
||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||
<string>Action</string>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
||||
|
@ -35,8 +35,6 @@
|
|||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||
<string>Action</string>
|
||||
<key>NSExtensionServiceAllowsFinderPreviewItem</key>
|
||||
<true/>
|
||||
<key>NSExtensionServiceAllowsTouchBarItem</key>
|
||||
|
|
|
@ -0,0 +1,408 @@
|
|||
//
|
||||
// Client.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
The base Mastodon API client.
|
||||
*/
|
||||
public class Client {
|
||||
|
||||
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
|
||||
|
||||
let baseURL: URL
|
||||
let session: URLSession
|
||||
|
||||
public var accessToken: String?
|
||||
|
||||
public var appID: String?
|
||||
public var clientID: String?
|
||||
public var clientSecret: String?
|
||||
|
||||
public var timeoutInterval: TimeInterval = 60
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
let iso8601 = ISO8601DateFormatter()
|
||||
decoder.dateDecodingStrategy = .custom({ (decoder) in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let str = try container.decode(String.self)
|
||||
// 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"))
|
||||
}
|
||||
})
|
||||
|
||||
return decoder
|
||||
}()
|
||||
|
||||
static let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
encoder.dateEncodingStrategy = .formatted(formatter)
|
||||
return encoder
|
||||
}()
|
||||
|
||||
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.accessToken = accessToken
|
||||
self.session = session
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> URLSessionTask? {
|
||||
guard let request = createURLRequest(request: request) else {
|
||||
completion(.failure(Error.invalidRequest))
|
||||
return nil
|
||||
}
|
||||
|
||||
let task = session.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
completion(.failure(.networkError(error)))
|
||||
return
|
||||
}
|
||||
guard let data = data,
|
||||
let response = response as? HTTPURLResponse else {
|
||||
completion(.failure(.invalidResponse))
|
||||
return
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
||||
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
|
||||
completion(.failure(.invalidModel))
|
||||
return
|
||||
}
|
||||
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
||||
|
||||
completion(.success(result, pagination))
|
||||
}
|
||||
task.resume()
|
||||
return task
|
||||
}
|
||||
|
||||
public func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
run(request) { response in
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: error)
|
||||
case let .success(result, pagination):
|
||||
continuation.resume(returning: (result, pagination))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
||||
components.path = request.path
|
||||
components.queryItems = request.queryParameters.isEmpty ? nil : request.queryParameters.queryItems
|
||||
guard let url = components.url else { return nil }
|
||||
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
||||
urlRequest.httpMethod = request.method.name
|
||||
urlRequest.httpBody = request.body.data
|
||||
urlRequest.setValue(request.body.mimeType, forHTTPHeaderField: "Content-Type")
|
||||
if let accessToken = accessToken {
|
||||
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
return urlRequest
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil) async throws -> RegisteredApplication {
|
||||
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
|
||||
"client_name" => name,
|
||||
"redirect_uris" => redirectURI,
|
||||
"scopes" => scopes.scopeString,
|
||||
"website" => website?.absoluteString
|
||||
]))
|
||||
let (application, _) = try await run(request)
|
||||
self.appID = application.id
|
||||
self.clientID = application.clientID
|
||||
self.clientSecret = application.clientSecret
|
||||
return application
|
||||
}
|
||||
|
||||
public func getAccessToken(authorizationCode: String, redirectURI: String) async throws -> 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
|
||||
]))
|
||||
let (settings, _) = try await run(request)
|
||||
self.accessToken = settings.accessToken
|
||||
return settings
|
||||
}
|
||||
|
||||
// MARK: - Self
|
||||
public static func getSelfAccount() -> Request<Account> {
|
||||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||
}
|
||||
|
||||
public static func getFavourites() -> Request<[Status]> {
|
||||
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||
}
|
||||
|
||||
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
|
||||
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
||||
}
|
||||
|
||||
public static func getInstance() -> Request<Instance> {
|
||||
return Request<Instance>(method: .get, path: "/api/v1/instance")
|
||||
}
|
||||
|
||||
public static func getCustomEmoji() -> Request<[Emoji]> {
|
||||
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
||||
}
|
||||
|
||||
// MARK: - Accounts
|
||||
public static func getAccount(id: String) -> Request<Account> {
|
||||
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
||||
}
|
||||
|
||||
public static func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil) -> Request<[Account]> {
|
||||
return Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [
|
||||
"q" => query,
|
||||
"limit" => limit,
|
||||
"following" => following
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Blocks
|
||||
public static func getBlocks() -> Request<[Account]> {
|
||||
return Request<[Account]>(method: .get, path: "/api/v1/blocks")
|
||||
}
|
||||
|
||||
public static func getDomainBlocks() -> Request<[String]> {
|
||||
return Request<[String]>(method: .get, path: "api/v1/domain_blocks")
|
||||
}
|
||||
|
||||
public static func block(domain: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||
"domain" => domain
|
||||
]))
|
||||
}
|
||||
|
||||
public static func unblock(domain: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||
"domain" => domain
|
||||
]))
|
||||
}
|
||||
|
||||
// MARK: - Filters
|
||||
public static func getFilters() -> Request<[Filter]> {
|
||||
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
|
||||
}
|
||||
|
||||
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
||||
"phrase" => phrase,
|
||||
"irreversible" => irreversible,
|
||||
"whole_word" => wholeWord,
|
||||
"expires_at" => expiresAt
|
||||
] + "context" => context.contextStrings))
|
||||
}
|
||||
|
||||
public static func getFilter(id: String) -> Request<Filter> {
|
||||
return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Follows
|
||||
public static func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func getFollowSuggestions() -> Request<[Account]> {
|
||||
return Request<[Account]>(method: .get, path: "/api/v1/suggestions")
|
||||
}
|
||||
|
||||
public static func followRemote(acct: String) -> Request<Account> {
|
||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
||||
}
|
||||
|
||||
// MARK: - Lists
|
||||
public static func getLists() -> Request<[List]> {
|
||||
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
||||
}
|
||||
|
||||
public static func getList(id: String) -> Request<List> {
|
||||
return Request<List>(method: .get, path: "/api/v1/lists/\(id)")
|
||||
}
|
||||
|
||||
public static func createList(title: String) -> Request<List> {
|
||||
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
|
||||
}
|
||||
|
||||
// MARK: - Media
|
||||
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
||||
return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
|
||||
"description" => description,
|
||||
"focus" => focus
|
||||
], attachment))
|
||||
}
|
||||
|
||||
// MARK: - Mutes
|
||||
public static func getMutes(range: RequestRange) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
public static func getNotifications(excludeTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||
"exclude_types" => excludeTypes.map { $0.rawValue }
|
||||
)
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func clearNotifications() -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
|
||||
}
|
||||
|
||||
// MARK: - Reports
|
||||
public static func getReports() -> Request<[Report]> {
|
||||
return Request<[Report]>(method: .get, path: "/api/v1/reports")
|
||||
}
|
||||
|
||||
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
|
||||
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
|
||||
"account_id" => account.id,
|
||||
"comment" => comment
|
||||
] + "status_ids" => statuses.map { $0.id }))
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
|
||||
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
||||
"q" => query,
|
||||
"resolve" => resolve,
|
||||
"limit" => limit,
|
||||
] + "types" => types?.map { $0.rawValue })
|
||||
}
|
||||
|
||||
// MARK: - Statuses
|
||||
public static func getStatus(id: String) -> Request<Status> {
|
||||
return Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
|
||||
}
|
||||
|
||||
public static func createStatus(text: String,
|
||||
contentType: StatusContentType = .plain,
|
||||
inReplyTo: String? = nil,
|
||||
media: [Attachment]? = nil,
|
||||
sensitive: Bool? = nil,
|
||||
spoilerText: String? = nil,
|
||||
visibility: Status.Visibility? = nil,
|
||||
language: String? = nil,
|
||||
pollOptions: [String]? = nil,
|
||||
pollExpiresIn: Int? = nil,
|
||||
pollMultiple: Bool? = nil) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
||||
"status" => text,
|
||||
"content_type" => contentType.mimeType,
|
||||
"in_reply_to_id" => inReplyTo,
|
||||
"sensitive" => sensitive,
|
||||
"spoiler_text" => spoilerText,
|
||||
"visibility" => visibility?.rawValue,
|
||||
"language" => language,
|
||||
"poll[expires_in]" => pollExpiresIn,
|
||||
"poll[multiple]" => pollMultiple,
|
||||
] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions))
|
||||
}
|
||||
|
||||
// MARK: - Timelines
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
||||
return timeline.request(range: range)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Bookmarks
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
// MARK: - Instance
|
||||
public static func getTrends(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 getFeaturedProfiles(local: Bool, order: DirectoryOrder, offset: Int? = nil, limit: Int? = nil) -> Request<[Account]> {
|
||||
var parameters = [
|
||||
"order" => order.rawValue,
|
||||
"local" => local,
|
||||
]
|
||||
if let offset = offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
if let limit = limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
return Request<[Account]>(method: .get, path: "/api/v1/directory", queryParameters: parameters)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Client {
|
||||
public enum Error: LocalizedError {
|
||||
case networkError(Swift.Error)
|
||||
case unexpectedStatus(Int)
|
||||
case invalidRequest
|
||||
case invalidResponse
|
||||
case invalidModel
|
||||
case mastodonError(String)
|
||||
|
||||
public var localizedDescription: String {
|
||||
switch self {
|
||||
case .networkError(let error):
|
||||
return "Network Error: \(error.localizedDescription)"
|
||||
// todo: support more status codes
|
||||
case .unexpectedStatus(413):
|
||||
return "HTTP 413: Payload Too Large"
|
||||
case .unexpectedStatus(let code):
|
||||
return "HTTP Code \(code)"
|
||||
case .invalidRequest:
|
||||
return "Invalid Request"
|
||||
case .invalidResponse:
|
||||
return "Invalid Response"
|
||||
case .invalidModel:
|
||||
return "Invalid Model"
|
||||
case .mastodonError(let error):
|
||||
return "Server Error: \(error)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
|
@ -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
|
||||
|
@ -20,12 +20,11 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
|||
public let statusesCount: Int
|
||||
public let note: String
|
||||
public let url: URL
|
||||
// required on mastodon, but optional on gotosocial
|
||||
public let avatar: URL?
|
||||
public let avatarStatic: URL?
|
||||
public let avatar: URL
|
||||
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,22 +39,16 @@ 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)
|
||||
self.avatar = try? container.decode(URL.self, forKey: .avatar)
|
||||
self.avatarStatic = try? container.decode(URL.self, forKey: .avatarStatic)
|
||||
self.avatar = try container.decode(URL.self, forKey: .avatar)
|
||||
self.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic)
|
||||
self.header = try? container.decode(URL.self, forKey: .header)
|
||||
self.headerStatic = try? container.decode(URL.self, forKey: .headerStatic)
|
||||
// even up-to-date pixelfed instances sometimes lack this, for reasons unclear
|
||||
if let emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) {
|
||||
self.emojis = emojis
|
||||
} else {
|
||||
self.emojis = []
|
||||
}
|
||||
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
|
||||
self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? []
|
||||
self.bot = try? container.decode(Bool.self, forKey: .bot)
|
||||
|
||||
|
@ -71,36 +64,35 @@ 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> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
|
||||
}
|
||||
|
||||
public static func getFollowers(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/followers")
|
||||
public static func getFollowers(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/followers")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func getFollowing(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/following")
|
||||
public static func getFollowing(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/following")
|
||||
request.range = range
|
||||
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) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
"only_media" => onlyMedia,
|
||||
"pinned" => pinned,
|
||||
"exclude_replies" => excludeReplies,
|
||||
"exclude_reblogs" => excludeReblogs,
|
||||
"exclude_replies" => excludeReplies
|
||||
])
|
||||
request.range = range
|
||||
return request
|
||||
|
@ -110,32 +102,26 @@ 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")
|
||||
}
|
||||
|
||||
public static func block(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/block")
|
||||
public static func block(_ account: Account) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/block")
|
||||
}
|
||||
|
||||
public static func unblock(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unblock")
|
||||
public static func unblock(_ account: Account) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unblock")
|
||||
}
|
||||
|
||||
public static func mute(_ accountID: String, notifications: Bool? = nil) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/mute", body: ParametersBody([
|
||||
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([
|
||||
"notifications" => notifications
|
||||
]))
|
||||
}
|
||||
|
||||
public static func unmute(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unmute")
|
||||
public static func unmute(_ account: Account) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unmute")
|
||||
}
|
||||
|
||||
public static func getLists(_ account: Account) -> Request<[List]> {
|
||||
|
@ -172,15 +158,8 @@ 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?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case value
|
||||
case verifiedAt = "verified_at"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -8,12 +8,13 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Attachment: Codable, Sendable {
|
||||
public class Attachment: Codable {
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let url: URL
|
||||
public let remoteURL: URL?
|
||||
public let previewURL: URL?
|
||||
public let textURL: URL?
|
||||
public let meta: Metadata?
|
||||
public let description: String?
|
||||
public let blurHash: String?
|
||||
|
@ -25,24 +26,14 @@ 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)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL)
|
||||
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL)
|
||||
self.textURL = try? container.decode(URL?.self, forKey: .textURL)
|
||||
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
|
||||
self.description = try? container.decode(String?.self, forKey: .description)
|
||||
self.blurHash = try? container.decode(String?.self, forKey: .blurHash)
|
||||
|
@ -54,6 +45,7 @@ public struct Attachment: Codable, Sendable {
|
|||
case url
|
||||
case remoteURL = "remote_url"
|
||||
case previewURL = "preview_url"
|
||||
case textURL = "text_url"
|
||||
case meta
|
||||
case description
|
||||
case blurHash = "blurhash"
|
||||
|
@ -61,34 +53,17 @@ public struct Attachment: Codable, Sendable {
|
|||
}
|
||||
|
||||
extension Attachment {
|
||||
public enum Kind: String, Codable, Sendable {
|
||||
public enum Kind: String, Codable {
|
||||
case image
|
||||
case video
|
||||
case gifv
|
||||
case audio
|
||||
case unknown
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
switch try container.decode(String.self) {
|
||||
// gotosocial uses "gif" for gif images
|
||||
case "image", "gif":
|
||||
self = .image
|
||||
case "video":
|
||||
self = .video
|
||||
case "gifv":
|
||||
self = .gifv
|
||||
case "audio":
|
||||
self = .audio
|
||||
default:
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +94,7 @@ extension Attachment {
|
|||
}
|
||||
}
|
||||
|
||||
public struct ImageMetadata: Codable, Sendable {
|
||||
public struct ImageMetadata: Codable {
|
||||
public let width: Int?
|
||||
public let height: Int?
|
||||
public let size: String?
|
|
@ -7,74 +7,38 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Card: Codable, Sendable {
|
||||
public let url: WebURL
|
||||
public class Card: Codable {
|
||||
public let url: URL
|
||||
public let title: String
|
||||
public let description: String
|
||||
public let image: WebURL?
|
||||
public let image: URL?
|
||||
public let kind: Kind
|
||||
public let authorName: String?
|
||||
public let authorURL: WebURL?
|
||||
public let authorURL: URL?
|
||||
public let providerName: String?
|
||||
public let providerURL: WebURL?
|
||||
public let providerURL: URL?
|
||||
public let html: String?
|
||||
public let width: Int?
|
||||
public let height: Int?
|
||||
public let blurhash: String?
|
||||
/// 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)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
self.description = try container.decode(String.self, forKey: .description)
|
||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||
self.image = try? container.decodeIfPresent(WebURL.self, forKey: .image)
|
||||
self.image = try? container.decodeIfPresent(URL.self, forKey: .image)
|
||||
self.authorName = try? container.decodeIfPresent(String.self, forKey: .authorName)
|
||||
self.authorURL = try? container.decodeIfPresent(WebURL.self, forKey: .authorURL)
|
||||
self.authorURL = try? container.decodeIfPresent(URL.self, forKey: .authorURL)
|
||||
self.providerName = try? container.decodeIfPresent(String.self, forKey: .providerName)
|
||||
self.providerURL = try? container.decodeIfPresent(WebURL.self, forKey: .providerURL)
|
||||
self.providerURL = try? container.decodeIfPresent(URL.self, forKey: .providerURL)
|
||||
self.html = try? container.decodeIfPresent(String.self, forKey: .html)
|
||||
self.width = try? container.decodeIfPresent(Int.self, forKey: .width)
|
||||
self.height = try? container.decodeIfPresent(Int.self, forKey: .height)
|
||||
self.blurhash = try? container.decodeIfPresent(String.self, forKey: .blurhash)
|
||||
self.history = try? container.decodeIfPresent([History].self, forKey: .history)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
|
@ -102,15 +66,13 @@ public struct Card: Codable, Sendable {
|
|||
case width
|
||||
case height
|
||||
case blurhash
|
||||
case history
|
||||
}
|
||||
}
|
||||
|
||||
extension Card {
|
||||
public enum Kind: String, Codable, Sendable {
|
||||
public enum Kind: String, Codable {
|
||||
case link
|
||||
case photo
|
||||
case video
|
||||
case rich
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct ConversationContext: Decodable, Sendable {
|
||||
public class ConversationContext: Decodable {
|
||||
public let ancestors: [Status]
|
||||
public let descendants: [Status]
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum DirectoryOrder: String, CaseIterable, Sendable {
|
||||
public enum DirectoryOrder: String {
|
||||
case active
|
||||
case new
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// Emoji.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Emoji: Codable {
|
||||
public let shortcode: String
|
||||
public let url: URL
|
||||
public let staticURL: URL
|
||||
public let visibleInPicker: Bool
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.shortcode = try container.decode(String.self, forKey: .shortcode)
|
||||
if let url = try? container.decode(URL.self, forKey: .url) {
|
||||
self.url = url
|
||||
} else {
|
||||
let str = try container.decode(String.self, forKey: .url)
|
||||
self.url = URL(string: str.replacingOccurrences(of: " ", with: "%20"))!
|
||||
}
|
||||
if let url = try? container.decode(URL.self, forKey: .staticURL) {
|
||||
self.staticURL = url
|
||||
} else {
|
||||
let staticStr = try container.decode(String.self, forKey: .staticURL)
|
||||
self.staticURL = URL(string: staticStr.replacingOccurrences(of: " ", with: "%20"))!
|
||||
}
|
||||
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case shortcode
|
||||
case url
|
||||
case staticURL = "static_url"
|
||||
case visibleInPicker = "visible_in_picker"
|
||||
}
|
||||
}
|
||||
|
||||
extension Emoji: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
return ":\(shortcode):"
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// FilterV1.swift
|
||||
// Filter.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct FilterV1: Decodable, Sendable {
|
||||
public class Filter: Decodable {
|
||||
public let id: String
|
||||
public let phrase: String
|
||||
private let context: [String]
|
||||
|
@ -22,16 +22,17 @@ public struct FilterV1: Decodable, Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
public static func update(_ filterID: String, phrase: String, context: [Context], irreversible: Bool, wholeWord: Bool, expiresIn: TimeInterval?) -> Request<FilterV1> {
|
||||
return Request<FilterV1>(method: .put, path: "/api/v1/filters/\(filterID)", body: ParametersBody([
|
||||
"phrase" => phrase,
|
||||
"whole_word" => wholeWord,
|
||||
"expires_in" => expiresIn,
|
||||
] + "context" => context.contextStrings))
|
||||
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
|
||||
"phrase" => (phrase ?? filter.phrase),
|
||||
"irreversible" => (irreversible ?? filter.irreversible),
|
||||
"whole_word" => (wholeWord ?? filter.wholeWord),
|
||||
"expires_at" => (expiresAt ?? filter.expiresAt)
|
||||
] + "context" => (context?.contextStrings ?? filter.context)))
|
||||
}
|
||||
|
||||
public static func delete(_ filterID: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filterID)")
|
||||
public static func delete(_ filter: Filter) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filter.id)")
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
@ -44,17 +45,16 @@ public struct FilterV1: Decodable, Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
extension FilterV1 {
|
||||
public enum Context: String, Decodable, CaseIterable, Sendable {
|
||||
extension Filter {
|
||||
public enum Context: String, Decodable {
|
||||
case home
|
||||
case notifications
|
||||
case `public`
|
||||
case thread
|
||||
case account
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == FilterV1.Context {
|
||||
extension Array where Element == Filter.Context {
|
||||
var contextStrings: [String] {
|
||||
return map { $0.rawValue }
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
//
|
||||
// Hashtag.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Hashtag: Codable {
|
||||
public let name: String
|
||||
public let url: URL
|
||||
public let history: [History]?
|
||||
|
||||
public init(name: String, url: URL) {
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.history = nil
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case url
|
||||
case history
|
||||
}
|
||||
}
|
||||
|
||||
extension Hashtag {
|
||||
public class History: Codable {
|
||||
public let day: Date
|
||||
public let uses: Int
|
||||
public let accounts: Int
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
if let day = try? container.decode(Date.self, forKey: .day) {
|
||||
self.day = day
|
||||
} else if let unixTimestamp = try? container.decode(Double.self, forKey: .day) {
|
||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
||||
} else if let str = try? container.decode(String.self, forKey: .day),
|
||||
let unixTimestamp = Double(str) {
|
||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .day, in: container, debugDescription: "day must be either date or UNIX timestamp")
|
||||
}
|
||||
|
||||
if let uses = try? container.decode(Int.self, forKey: .uses) {
|
||||
self.uses = uses
|
||||
} else if let str = try? container.decode(String.self, forKey: .uses),
|
||||
let uses = Int(str) {
|
||||
self.uses = uses
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .uses, in: container, debugDescription: "uses must either be int or string containing int")
|
||||
}
|
||||
|
||||
if let accounts = try? container.decode(Int.self, forKey: .accounts) {
|
||||
self.accounts = accounts
|
||||
} else if let str = try? container.decode(String.self, forKey: .accounts),
|
||||
let accounts = Int(str) {
|
||||
self.accounts = accounts
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .accounts, in: container, debugDescription: "accounts must either be int or string containing int")
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case day
|
||||
case uses
|
||||
case accounts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Hashtag: Equatable, Hashable {
|
||||
public static func ==(lhs: Hashtag, rhs: Hashtag) -> Bool {
|
||||
return lhs.name == rhs.name
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
//
|
||||
// Instance.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Instance: Decodable {
|
||||
public let uri: String
|
||||
public let title: String
|
||||
public let description: String
|
||||
public let email: String?
|
||||
public let version: String
|
||||
public let urls: [String: URL]
|
||||
public let thumbnail: URL?
|
||||
public let languages: [String]?
|
||||
public let stats: Stats?
|
||||
|
||||
// pleroma doesn't currently implement these
|
||||
public let contactAccount: Account?
|
||||
|
||||
// MARK: Unofficial additions to the Mastodon API.
|
||||
public let maxStatusCharacters: Int?
|
||||
|
||||
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.uri = try container.decode(String.self, forKey: .uri)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
self.description = try container.decode(String.self, forKey: .description)
|
||||
self.email = try container.decodeIfPresent(String.self, forKey: .email)
|
||||
self.version = try container.decode(String.self, forKey: .version)
|
||||
if let urls = try? container.decodeIfPresent([String: URL].self, forKey: .urls) {
|
||||
self.urls = urls
|
||||
} else {
|
||||
self.urls = [:]
|
||||
}
|
||||
|
||||
self.languages = try? container.decodeIfPresent([String].self, forKey: .languages)
|
||||
self.contactAccount = try? container.decodeIfPresent(Account.self, forKey: .contactAccount)
|
||||
|
||||
self.stats = try? container.decodeIfPresent(Stats.self, forKey: .stats)
|
||||
self.thumbnail = try? container.decodeIfPresent(URL.self, forKey: .thumbnail)
|
||||
if let maxStatusCharacters = try? container.decodeIfPresent(Int.self, forKey: .maxStatusCharacters) {
|
||||
self.maxStatusCharacters = maxStatusCharacters
|
||||
} else if let str = try? container.decodeIfPresent(String.self, forKey: .maxStatusCharacters),
|
||||
let maxStatusCharacters = Int(str, radix: 10) {
|
||||
self.maxStatusCharacters = maxStatusCharacters
|
||||
} else {
|
||||
self.maxStatusCharacters = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case uri
|
||||
case title
|
||||
case description
|
||||
case email
|
||||
case version
|
||||
case urls
|
||||
case thumbnail
|
||||
case languages
|
||||
case stats
|
||||
|
||||
case contactAccount = "contact_account"
|
||||
|
||||
case maxStatusCharacters = "max_toot_chars"
|
||||
}
|
||||
}
|
||||
|
||||
extension Instance {
|
||||
public class Stats: Decodable {
|
||||
public let domainCount: Int?
|
||||
public let statusCount: Int?
|
||||
public let userCount: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case domainCount = "domain_count"
|
||||
case statusCount = "status_count"
|
||||
case userCount = "user_count"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// List.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class List: Decodable, Equatable, Hashable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
|
||||
public var timeline: Timeline {
|
||||
return .list(id: id)
|
||||
}
|
||||
|
||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func update(_ list: List, title: String) -> Request<List> {
|
||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
|
||||
}
|
||||
|
||||
public static func delete(_ list: List) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
|
||||
}
|
||||
|
||||
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
||||
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct LoginSettings: Decodable, Sendable {
|
||||
public class LoginSettings: Decodable {
|
||||
public let accessToken: String
|
||||
private let scope: String?
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
//
|
||||
// MastodonError.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct MastodonError: Decodable, CustomStringConvertible {
|
||||
var description: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case description = "error"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
//
|
||||
// Mention.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Mention: Codable {
|
||||
public let url: URL
|
||||
public let username: String
|
||||
public let acct: String
|
||||
public let id: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url
|
||||
case username
|
||||
case acct
|
||||
case id
|
||||
}
|
||||
}
|
|
@ -7,20 +7,15 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Notification: Decodable, Sendable {
|
||||
public class Notification: Decodable {
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let createdAt: Date
|
||||
public let account: Account
|
||||
public let status: Status?
|
||||
// Only present for pleroma emoji reactions
|
||||
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
|
||||
public let emoji: String?
|
||||
public let emojiURL: WebURL?
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
|
@ -31,13 +26,17 @@ public struct Notification: Decodable, Sendable {
|
|||
}
|
||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.account = try container.decode(Account.self, forKey: .account)
|
||||
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
|
||||
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
|
||||
if container.contains(.status) {
|
||||
self.status = try container.decode(Status.self, forKey: .status)
|
||||
} else {
|
||||
self.status = nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/\(notificationID)/dismiss")
|
||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
|
||||
"id" => notificationID
|
||||
]))
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
@ -46,22 +45,17 @@ public struct Notification: Decodable, Sendable {
|
|||
case createdAt = "created_at"
|
||||
case account
|
||||
case status
|
||||
case emoji
|
||||
case emojiURL = "emoji_url"
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification {
|
||||
public enum Kind: String, Decodable, CaseIterable, Sendable {
|
||||
public enum Kind: String, Decodable, CaseIterable {
|
||||
case mention
|
||||
case reblog
|
||||
case favourite
|
||||
case follow
|
||||
case followRequest = "follow_request"
|
||||
case poll
|
||||
case update
|
||||
case status
|
||||
case emojiReaction = "pleroma:emoji_reaction"
|
||||
case unknown
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Poll: Codable, Sendable {
|
||||
public final class Poll: Codable {
|
||||
public let id: String
|
||||
public let expiresAt: Date?
|
||||
public let expired: Bool
|
||||
|
@ -16,7 +16,7 @@ public struct Poll: Codable, Sendable {
|
|||
public let votesCount: Int
|
||||
public let votersCount: Int?
|
||||
public let voted: Bool?
|
||||
public let ownVotes: [Int?]?
|
||||
public let ownVotes: [Int]?
|
||||
public let options: [Option]
|
||||
public let emojis: [Emoji]
|
||||
|
||||
|
@ -24,20 +24,6 @@ public struct Poll: Codable, Sendable {
|
|||
expired || (expiresAt != nil && expiresAt! < Date())
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt)
|
||||
self.expired = try container.decode(Bool.self, forKey: .expired)
|
||||
self.multiple = try container.decode(Bool.self, forKey: .multiple)
|
||||
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
|
||||
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
|
||||
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
|
||||
self.ownVotes = try container.decodeIfPresent([Int?].self, forKey: .ownVotes)
|
||||
self.options = try container.decode([Poll.Option].self, forKey: .options)
|
||||
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||
}
|
||||
|
||||
public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> {
|
||||
return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil))
|
||||
}
|
||||
|
@ -57,7 +43,7 @@ public struct Poll: Codable, Sendable {
|
|||
}
|
||||
|
||||
extension Poll {
|
||||
public struct Option: Codable, Sendable {
|
||||
public final class Option: Codable {
|
||||
public let title: String
|
||||
public let votesCount: Int?
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
import Foundation
|
||||
|
||||
public protocol AccountProtocol {
|
||||
associatedtype Account: AccountProtocol
|
||||
|
||||
var id: String { get }
|
||||
var username: String { get }
|
||||
|
@ -21,12 +22,12 @@ public protocol AccountProtocol {
|
|||
var statusesCount: Int { get }
|
||||
var note: String { get }
|
||||
var url: URL { get }
|
||||
var avatar: URL? { get }
|
||||
var avatar: URL { get }
|
||||
var header: URL? { get }
|
||||
var moved: Bool? { get }
|
||||
var bot: Bool? { get }
|
||||
|
||||
var movedTo: Self? { get }
|
||||
var movedTo: Account? { get }
|
||||
var emojis: [Emoji] { get }
|
||||
var fields: [Pachyderm.Account.Field] { get }
|
||||
}
|
|
@ -20,12 +20,11 @@ public protocol StatusProtocol {
|
|||
var createdAt: Date { get }
|
||||
var reblogsCount: Int { get }
|
||||
var favouritesCount: Int { get }
|
||||
// pachyderm impl wants Bool, StatusMO wants optional. not sure how to resolve it, but we don't need this currently
|
||||
// var reblogged: Bool { get }
|
||||
// var favourited: Bool { get }
|
||||
var reblogged: Bool { get }
|
||||
var favourited: Bool { get }
|
||||
var sensitive: Bool { get }
|
||||
var spoilerText: String { get }
|
||||
var visibility: Visibility { get }
|
||||
var visibility: Pachyderm.Status.Visibility { get }
|
||||
var applicationName: String? { get }
|
||||
var pinned: Bool? { get }
|
||||
var bookmarked: Bool? { get }
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// PushSubscription.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class PushSubscription: Decodable {
|
||||
public let id: String
|
||||
public let endpoint: URL
|
||||
public let serverKey: String
|
||||
// TODO: WTF is this?
|
||||
// public let alerts
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case endpoint
|
||||
case serverKey = "server_key"
|
||||
// case alerts
|
||||
}
|
||||
}
|
|
@ -8,12 +8,12 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct RegisteredApplication: Decodable, Sendable {
|
||||
public class RegisteredApplication: Decodable {
|
||||
public let id: String
|
||||
public let clientID: String
|
||||
public let clientSecret: String
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
// Pixelfed API returns id/client_id as numbers instead of strings
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// Relationship.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Relationship: Decodable {
|
||||
public let id: String
|
||||
public let following: Bool
|
||||
public let followedBy: Bool
|
||||
public let blocking: Bool
|
||||
public let muting: Bool
|
||||
public let mutingNotifications: Bool
|
||||
public let followRequested: Bool
|
||||
public let domainBlocking: Bool
|
||||
public let showingReblogs: Bool
|
||||
public let endorsed: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case following
|
||||
case followedBy = "followed_by"
|
||||
case blocking
|
||||
case muting
|
||||
case mutingNotifications = "muting_notifications"
|
||||
case followRequested = "requested"
|
||||
case domainBlocking = "domain_blocking"
|
||||
case showingReblogs = "showing_reblogs"
|
||||
case endorsed
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Report: Decodable, Sendable {
|
||||
public class Report: Decodable {
|
||||
public let id: String
|
||||
public let actionTaken: Bool
|
||||
|
|
@ -8,11 +8,10 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum Scope: String, Sendable {
|
||||
public enum Scope: String {
|
||||
case read
|
||||
case write
|
||||
case follow
|
||||
case push
|
||||
}
|
||||
|
||||
extension Array where Element == Scope {
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum SearchResultType: String, Sendable {
|
||||
public enum SearchResultType: String {
|
||||
case accounts
|
||||
case hashtags
|
||||
case statuses
|
|
@ -8,9 +8,9 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct SearchResults: Decodable, Sendable {
|
||||
public class SearchResults: Decodable {
|
||||
public let accounts: [Account]
|
||||
public let statuses: [TryDecode<Status>]
|
||||
public let statuses: [Status]
|
||||
public let hashtags: [Hashtag]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
|
@ -0,0 +1,149 @@
|
|||
//
|
||||
// Status.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class Status: /*StatusProtocol,*/ Decodable {
|
||||
public let id: String
|
||||
public let uri: String
|
||||
public let url: URL?
|
||||
public let account: Account
|
||||
public let inReplyToID: String?
|
||||
public let inReplyToAccountID: String?
|
||||
public let reblog: Status?
|
||||
public let content: String
|
||||
public let createdAt: Date
|
||||
public let emojis: [Emoji]
|
||||
// TODO: missing from pleroma
|
||||
// public let repliesCount: Int
|
||||
public let reblogsCount: Int
|
||||
public let favouritesCount: Int
|
||||
public let reblogged: Bool?
|
||||
public let favourited: Bool?
|
||||
public let muted: Bool?
|
||||
public let sensitive: Bool
|
||||
public let spoilerText: String
|
||||
public let visibility: Visibility
|
||||
public let attachments: [Attachment]
|
||||
public let mentions: [Mention]
|
||||
public let hashtags: [Hashtag]
|
||||
public let application: Application?
|
||||
public let language: String?
|
||||
public let pinned: Bool?
|
||||
public let bookmarked: Bool?
|
||||
public let card: Card?
|
||||
public let poll: Poll?
|
||||
|
||||
public var applicationName: String? { application?.name }
|
||||
|
||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
|
||||
}
|
||||
|
||||
public static func getCard(_ status: Status) -> Request<Card> {
|
||||
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
|
||||
}
|
||||
|
||||
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func delete(_ status: Status) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
|
||||
}
|
||||
|
||||
public static func reblog(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
|
||||
}
|
||||
|
||||
public static func unreblog(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
|
||||
}
|
||||
|
||||
public static func favourite(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
|
||||
}
|
||||
|
||||
public static func unfavourite(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
|
||||
}
|
||||
|
||||
public static func pin(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/pin")
|
||||
}
|
||||
|
||||
public static func unpin(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unpin")
|
||||
}
|
||||
|
||||
public static func bookmark(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/bookmark")
|
||||
}
|
||||
|
||||
public static func unbookmark(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
|
||||
}
|
||||
|
||||
public static func muteConversation(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/mute")
|
||||
}
|
||||
|
||||
public static func unmuteConversation(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case uri
|
||||
case url
|
||||
case account
|
||||
case inReplyToID = "in_reply_to_id"
|
||||
case inReplyToAccountID = "in_reply_to_account_id"
|
||||
case reblog
|
||||
case content
|
||||
case createdAt = "created_at"
|
||||
case emojis
|
||||
// case repliesCount = "replies_count"
|
||||
case reblogsCount = "reblogs_count"
|
||||
case favouritesCount = "favourites_count"
|
||||
case reblogged
|
||||
case favourited
|
||||
case muted
|
||||
case sensitive
|
||||
case spoilerText = "spoiler_text"
|
||||
case visibility
|
||||
case attachments = "media_attachments"
|
||||
case mentions
|
||||
case hashtags = "tags"
|
||||
case application
|
||||
case language
|
||||
case pinned
|
||||
case bookmarked
|
||||
case card
|
||||
case poll
|
||||
}
|
||||
}
|
||||
|
||||
extension Status {
|
||||
public enum Visibility: String, Codable, CaseIterable {
|
||||
case `public`
|
||||
case unlisted
|
||||
case `private`
|
||||
case direct
|
||||
}
|
||||
}
|
||||
|
||||
extension Status: Identifiable {}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum StatusContentType: String, Codable, CaseIterable, Sendable {
|
||||
public enum StatusContentType: String, Codable, CaseIterable {
|
||||
case plain, markdown, html
|
||||
|
||||
var mimeType: String {
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum Timeline: Equatable, Hashable, Sendable {
|
||||
public enum Timeline {
|
||||
case home
|
||||
case `public`(local: Bool)
|
||||
case tag(hashtag: String)
|
||||
|
@ -17,7 +17,7 @@ public enum Timeline: Equatable, Hashable, Sendable {
|
|||
}
|
||||
|
||||
extension Timeline {
|
||||
var endpoint: Endpoint {
|
||||
var endpoint: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "/api/v1/timelines/home"
|
||||
|
@ -32,14 +32,12 @@ extension Timeline {
|
|||
}
|
||||
}
|
||||
|
||||
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
|
||||
func request(range: RequestRange) -> Request<[Status]> {
|
||||
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
|
||||
if case .public(true) = self {
|
||||
request.queryParameters.append("local" => true)
|
||||
}
|
||||
request.range = range
|
||||
// 206 can happen when the timeline is being regenerated and therefore is incomplete
|
||||
request.additionalAcceptableHTTPCodes = [206]
|
||||
return request
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
//
|
||||
// Pachyderm.h
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
//! Project version number for Pachyderm.
|
||||
FOUNDATION_EXPORT double PachydermVersionNumber;
|
||||
|
||||
//! Project version string for Pachyderm.
|
||||
FOUNDATION_EXPORT const unsigned char PachydermVersionString[];
|
||||
|
||||
// In this header, you should import all the public headers of your framework using statements like #import <Pachyderm/PublicHeader.h>
|
||||
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
protocol Body: Sendable {
|
||||
protocol Body {
|
||||
var mimeType: String? { get }
|
||||
var data: Data? { get }
|
||||
}
|
||||
|
@ -76,7 +76,7 @@ struct FormDataBody: Body {
|
|||
}
|
||||
}
|
||||
|
||||
struct JsonBody<T: Encodable & Sendable>: Body {
|
||||
struct JsonBody<T: Encodable>: Body {
|
||||
let value: T
|
||||
|
||||
init(_ value: T) {
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct FormAttachment: Sendable {
|
||||
public struct FormAttachment {
|
||||
let mimeType: String
|
||||
let data: Data
|
||||
let fileName: String
|
|
@ -8,12 +8,12 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum Method: Sendable {
|
||||
enum Method {
|
||||
case get, post, put, patch, delete
|
||||
}
|
||||
|
||||
extension Method {
|
||||
public var name: String {
|
||||
var name: String {
|
||||
switch self {
|
||||
case .get:
|
||||
return "GET"
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
struct Parameter: Sendable {
|
||||
struct Parameter {
|
||||
let name: String
|
||||
let value: String?
|
||||
}
|
||||
|
@ -42,10 +42,6 @@ extension String {
|
|||
}
|
||||
}
|
||||
|
||||
static func =>(name: String, value: TimeInterval?) -> Parameter {
|
||||
return name => (value == nil ? nil : Int(value!))
|
||||
}
|
||||
|
||||
static func =>(name: String, focus: (Float, Float)?) -> Parameter {
|
||||
guard let focus = focus else { return Parameter(name: name, value: nil) }
|
||||
return Parameter(name: name, value: "\(focus.0),\(focus.1)")
|
|
@ -8,17 +8,15 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Request<ResultType: Decodable>: Sendable {
|
||||
public struct Request<ResultType: Decodable> {
|
||||
let method: Method
|
||||
let endpoint: Endpoint
|
||||
let path: String
|
||||
let body: Body
|
||||
var queryParameters: [Parameter]
|
||||
var headers: [String: String] = [:]
|
||||
var additionalAcceptableHTTPCodes: [Int] = []
|
||||
|
||||
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
||||
init(method: Method, path: String, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
||||
self.method = method
|
||||
self.endpoint = path
|
||||
self.path = path
|
||||
self.body = body
|
||||
self.queryParameters = queryParameters
|
||||
}
|
|
@ -8,24 +8,11 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum RequestRange: Sendable {
|
||||
public enum RequestRange {
|
||||
case `default`
|
||||
case count(Int)
|
||||
/// Chronologically immediately before the given ID
|
||||
case before(id: String, count: Int?)
|
||||
/// Chronologically immediately after the given ID
|
||||
case after(id: String, count: Int?)
|
||||
|
||||
public func withCount(_ count: Int) -> Self {
|
||||
switch self {
|
||||
case .default, .count(_):
|
||||
return .count(count)
|
||||
case .before(id: let id, count: _):
|
||||
return .before(id: id, count: count)
|
||||
case .after(id: let id, count: _):
|
||||
return .after(id: id, count: count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RequestRange {
|
|
@ -8,6 +8,6 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Empty: Decodable, Sendable {
|
||||
public struct Empty: Decodable {
|
||||
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Pagination: Sendable {
|
||||
public struct Pagination {
|
||||
public let older: RequestRange?
|
||||
public let newer: RequestRange?
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum Response<Result: Decodable & Sendable>: Sendable {
|
||||
public enum Response<Result: Decodable> {
|
||||
case success(Result, Pagination?)
|
||||
case failure(Client.Error)
|
||||
}
|
|
@ -1,25 +1,24 @@
|
|||
//
|
||||
// CharacterCounter.swift
|
||||
// ComposeUI
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/29/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import InstanceFeatures
|
||||
|
||||
public struct CharacterCounter {
|
||||
|
||||
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
||||
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
||||
|
||||
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
|
||||
public static func count(text: String) -> Int {
|
||||
let mentionsRemoved = removeMentions(in: text)
|
||||
var count = mentionsRemoved.count
|
||||
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
||||
count -= match.range.length
|
||||
count += instanceFeatures.charsReservedPerURL
|
||||
count += 23 // Mastodon link length
|
||||
}
|
||||
return count
|
||||
}
|
|
@ -12,7 +12,7 @@ public class InstanceSelector {
|
|||
|
||||
private static let decoder = JSONDecoder()
|
||||
|
||||
public static func getInstances(category: String?, completion: @escaping (Result<[Instance], Client.ErrorType>) -> Void) {
|
||||
public static func getInstances(category: String?, completion: @escaping Client.Callback<[Instance]>) {
|
||||
let url: URL
|
||||
if let category = category {
|
||||
url = URL(string: "https://api.joinmastodon.org/servers?category=\(category)")!
|
||||
|
@ -34,14 +34,11 @@ public class InstanceSelector {
|
|||
completion(.failure(.unexpectedStatus(response.statusCode)))
|
||||
return
|
||||
}
|
||||
let result: [Instance]
|
||||
do {
|
||||
result = try decoder.decode([Instance].self, from: data)
|
||||
} catch {
|
||||
completion(.failure(.invalidModel(error)))
|
||||
guard let result = try? decoder.decode([Instance].self, from: data) else {
|
||||
completion(.failure(Client.Error.invalidModel))
|
||||
return
|
||||
}
|
||||
completion(.success(result))
|
||||
completion(.success(result, nil))
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
@ -49,7 +46,7 @@ public class InstanceSelector {
|
|||
}
|
||||
|
||||
public extension InstanceSelector {
|
||||
struct Instance: Codable, Sendable {
|
||||
struct Instance: Codable {
|
||||
public let domain: String
|
||||
public let description: String
|
||||
public let proxiedThumbnailURL: URL
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// InstanceType.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/11/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum InstanceType {
|
||||
case mastodon, pleroma
|
||||
}
|
||||
|
||||
public extension Instance {
|
||||
var instanceType: InstanceType {
|
||||
let lowercased = version.lowercased()
|
||||
if lowercased.contains("pleroma") {
|
||||
return .pleroma
|
||||
} else {
|
||||
return .mastodon
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
//
|
||||
// NotificationGroup.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/5/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class NotificationGroup {
|
||||
public let notifications: [Notification]
|
||||
public let id: String
|
||||
public let kind: Notification.Kind
|
||||
public let statusState: StatusState?
|
||||
|
||||
init?(notifications: [Notification]) {
|
||||
guard !notifications.isEmpty else { return nil }
|
||||
self.notifications = notifications
|
||||
self.id = notifications.first!.id
|
||||
self.kind = notifications.first!.kind
|
||||
if kind == .mention {
|
||||
self.statusState = .unknown
|
||||
} else {
|
||||
self.statusState = nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
var groups = [[Notification]]()
|
||||
for notification in notifications {
|
||||
if allowedTypes.contains(notification.kind) {
|
||||
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
|
||||
groups[groups.count - 1].append(notification)
|
||||
continue
|
||||
} else if groups.count >= 2 {
|
||||
let secondToLastGroup = groups[groups.count - 2]
|
||||
if allowedTypes.contains(groups[groups.count - 1][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
|
||||
groups[groups.count - 2].append(notification)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.append([notification])
|
||||
}
|
||||
return groups.map {
|
||||
NotificationGroup(notifications: $0)!
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension NotificationGroup: Identifiable {}
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// CollapseState.swift
|
||||
// StatusState.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 11/24/19.
|
||||
|
@ -8,11 +8,9 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public final class CollapseState: Sendable {
|
||||
public class StatusState: Equatable, Hashable {
|
||||
public var collapsible: Bool?
|
||||
public var collapsed: Bool?
|
||||
public var statusPropertiesHash: Int?
|
||||
|
||||
public var unknown: Bool {
|
||||
collapsible == nil || collapsed == nil
|
||||
|
@ -23,10 +21,8 @@ public final class CollapseState: Sendable {
|
|||
self.collapsed = collapsed
|
||||
}
|
||||
|
||||
public func copy() -> CollapseState {
|
||||
let new = CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
|
||||
new.statusPropertiesHash = self.statusPropertiesHash
|
||||
return new
|
||||
public func copy() -> StatusState {
|
||||
return StatusState(collapsible: self.collapsible, collapsed: self.collapsed)
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
|
@ -34,7 +30,11 @@ public final class CollapseState: Sendable {
|
|||
hasher.combine(collapsed)
|
||||
}
|
||||
|
||||
public static var unknown: CollapseState {
|
||||
CollapseState(collapsible: nil, collapsed: nil)
|
||||
public static var unknown: StatusState {
|
||||
StatusState(collapsible: nil, collapsed: nil)
|
||||
}
|
||||
|
||||
public static func == (lhs: StatusState, rhs: StatusState) -> Bool {
|
||||
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
|
||||
}
|
||||
}
|
|
@ -1,14 +1,13 @@
|
|||
//
|
||||
// CharacterCounterTests.swift
|
||||
// ComposeUITests
|
||||
// PachydermTests
|
||||
//
|
||||
// Created by Shadowfacts on 9/29/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import ComposeUI
|
||||
import InstanceFeatures
|
||||
@testable import Pachyderm
|
||||
|
||||
class CharacterCounterTests: XCTestCase {
|
||||
|
||||
|
@ -17,34 +16,32 @@ class CharacterCounterTests: XCTestCase {
|
|||
|
||||
override func tearDown() {
|
||||
}
|
||||
|
||||
let features = InstanceFeatures()
|
||||
|
||||
func testCountEmpty() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: "", for: features), 0)
|
||||
XCTAssertEqual(CharacterCounter.count(text: ""), 0)
|
||||
}
|
||||
|
||||
func testCountPlainText() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message", for: features), 26)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄", for: features), 43)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄", for: features), 7)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message"), 26)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄"), 43)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄"), 7)
|
||||
}
|
||||
|
||||
func testCountLinks() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com", for: features), 55)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com", for: features), 57)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com", for: features), 32)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz", for: features), 55)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com"), 55)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com"), 57)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com"), 32)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz"), 55)
|
||||
}
|
||||
|
||||
func testCountLocalMentions() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example", for: features), 14)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name", for: features), 22)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example"), 14)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name"), 22)
|
||||
}
|
||||
|
||||
func testCountRemoteMentions() {
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social", for: features), 14)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social", for: features), 28)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social"), 14)
|
||||
XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social"), 28)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// PachydermTests.swift
|
||||
// PachydermTests
|
||||
//
|
||||
// Created by Shadowfacts on 9/8/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Pachyderm
|
||||
|
||||
class PachydermTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
func testExample() {
|
||||
// This is an example of a functional test case.
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
func testPerformanceExample() {
|
||||
// This is an example of a performance test case.
|
||||
self.measure {
|
||||
// Put the code you want to measure the time of here.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -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
|
||||
}
|
|
@ -1,40 +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(.v16),
|
||||
],
|
||||
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: "../MatchedGeometryPresentation"),
|
||||
],
|
||||
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", "MatchedGeometryPresentation"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ComposeUITests",
|
||||
dependencies: ["ComposeUI"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
]
|
||||
)
|
|
@ -1,3 +0,0 @@
|
|||
# ComposeUI
|
||||
|
||||
A description of this package.
|
|
@ -1,197 +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
|
||||
class PostService: ObservableObject {
|
||||
private let mastodonController: ComposeMastodonContext
|
||||
private let config: ComposeUIConfig
|
||||
private let draft: Draft
|
||||
|
||||
@Published var currentStep = 1
|
||||
@Published private(set) var totalSteps = 2
|
||||
|
||||
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
|
||||
self.mastodonController = mastodonController
|
||||
self.config = config
|
||||
self.draft = draft
|
||||
}
|
||||
|
||||
func post() async throws {
|
||||
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()
|
||||
}
|
||||
|
||||
request = Client.editStatus(
|
||||
id: editedStatusID,
|
||||
text: textForPosting(),
|
||||
contentType: config.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: draft.poll.map {
|
||||
EditPollParameters(options: $0.pollOptions.map(\.text), expiresIn: Int($0.duration), multiple: $0.multiple)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
request = Client.createStatus(
|
||||
text: textForPosting(),
|
||||
contentType: config.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: draft.poll?.pollOptions.map(\.text),
|
||||
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
||||
pollMultiple: draft.poll?.multiple,
|
||||
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 let error as Client.Error {
|
||||
throw Error.posting(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadAttachments() async throws -> [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 let error as DraftAttachment.ExportError {
|
||||
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 -> (Data, UTType) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
attachment.getData(features: mastodonController.instanceFeatures) { result in
|
||||
switch result {
|
||||
case let .success(res):
|
||||
continuation.resume(returning: res)
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> 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 let error as Client.Error {
|
||||
throw Error.attachmentUpload(index: index, cause: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func textForPosting() -> String {
|
||||
var text = draft.text
|
||||
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
|
||||
// which we want to strip out before actually posting the status
|
||||
text = text.replacingOccurrences(of: "\u{fffc}", with: "")
|
||||
|
||||
if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack {
|
||||
text += " 👁"
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// only needed for akkoma, not used on regular mastodon
|
||||
private func updateEditedAttachments() async {
|
||||
for attachment in draft.draftAttachments {
|
||||
guard let id = attachment.editedAttachmentID else {
|
||||
continue
|
||||
}
|
||||
let req = Client.updateAttachment(id: id, description: attachment.attachmentDescription, focus: nil)
|
||||
_ = try? await mastodonController.run(req)
|
||||
}
|
||||
}
|
||||
|
||||
enum Error: Swift.Error, LocalizedError {
|
||||
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
|
||||
case attachmentMissingMimeType(index: Int, type: UTType)
|
||||
case attachmentUpload(index: Int, cause: Client.Error)
|
||||
case posting(Client.Error)
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case let .attachmentData(index: index, cause: cause):
|
||||
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||
case let .attachmentMissingMimeType(index: index, type: type):
|
||||
return "Attachment \(index + 1): unknown MIME type for \(type.identifier)"
|
||||
case let .attachmentUpload(index: index, cause: cause):
|
||||
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||
case let .posting(error):
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
//
|
||||
// ComposeInput.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
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
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
//
|
||||
// ComposeMastodonContext.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
import InstanceFeatures
|
||||
import UserAccounts
|
||||
|
||||
public protocol ComposeMastodonContext {
|
||||
var accountInfo: UserAccountInfo? { get }
|
||||
var instanceFeatures: InstanceFeatures { get }
|
||||
|
||||
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (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)
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
//
|
||||
// ComposeUIConfig.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import PhotosUI
|
||||
import PencilKit
|
||||
import TuskerComponents
|
||||
|
||||
public struct ComposeUIConfig {
|
||||
// Config
|
||||
public var allowSwitchingDrafts = true
|
||||
public var textSelectionStartsAtBeginning = false
|
||||
|
||||
// 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)
|
||||
public var avatarStyle = AvatarImageView.Style.roundRect
|
||||
|
||||
// Preferences
|
||||
public var useTwitterKeyboard = false
|
||||
public var contentType = StatusContentType.plain
|
||||
public var requireAttachmentDescriptions = false
|
||||
|
||||
// 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 init() {
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeUIConfig {
|
||||
}
|
|
@ -1,223 +0,0 @@
|
|||
//
|
||||
// AttachmentRowController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/12/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TuskerComponents
|
||||
import Vision
|
||||
import MatchedGeometryPresentation
|
||||
|
||||
class AttachmentRowController: ViewController {
|
||||
let parent: ComposeController
|
||||
let attachment: DraftAttachment
|
||||
|
||||
@Published var descriptionMode: DescriptionMode = .allowEntry
|
||||
@Published var textRecognitionError: Error?
|
||||
@Published var focusAttachmentOnTextEditorUnfocus = false
|
||||
|
||||
let thumbnailController: AttachmentThumbnailController
|
||||
|
||||
private var descriptionObservation: NSKeyValueObservation?
|
||||
|
||||
init(parent: ComposeController, attachment: DraftAttachment) {
|
||||
self.parent = parent
|
||||
self.attachment = attachment
|
||||
self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent)
|
||||
|
||||
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
||||
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
|
||||
if attachment.faultingState == 0 {
|
||||
self.updateAttachmentDescriptionState()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func updateAttachmentDescriptionState() {
|
||||
if attachment.attachmentDescription.isEmpty {
|
||||
parent.attachmentsMissingDescriptions.insert(attachment.id)
|
||||
} else {
|
||||
parent.attachmentsMissingDescriptions.remove(attachment.id)
|
||||
}
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AttachmentView(attachment: attachment)
|
||||
}
|
||||
|
||||
private func removeAttachment() {
|
||||
withAnimation {
|
||||
var newAttachments = parent.draft.draftAttachments
|
||||
newAttachments.removeAll(where: { $0.id == attachment.id })
|
||||
parent.draft.attachments = NSMutableOrderedSet(array: newAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
private func editDrawing() {
|
||||
guard case .drawing(let drawing) = attachment.data else {
|
||||
return
|
||||
}
|
||||
parent.config.presentDrawing?(drawing) { newDrawing in
|
||||
self.attachment.drawing = newDrawing
|
||||
}
|
||||
}
|
||||
|
||||
private func focusAttachment() {
|
||||
focusAttachmentOnTextEditorUnfocus = false
|
||||
parent.focusedAttachment = (attachment, thumbnailController)
|
||||
}
|
||||
|
||||
private func recognizeText() {
|
||||
descriptionMode = .recognizingText
|
||||
|
||||
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
||||
DispatchQueue.main.async {
|
||||
let data: Data
|
||||
switch result {
|
||||
case .success((let d, _)):
|
||||
data = d
|
||||
case .failure(let error):
|
||||
self.descriptionMode = .allowEntry
|
||||
self.textRecognitionError = error
|
||||
return
|
||||
}
|
||||
|
||||
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.descriptionMode = .allowEntry
|
||||
}
|
||||
}
|
||||
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.descriptionMode = .allowEntry
|
||||
self.textRecognitionError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentView: View {
|
||||
@ObservedObject private var attachment: DraftAttachment
|
||||
@EnvironmentObject private var controller: AttachmentRowController
|
||||
@FocusState private var textEditorFocused: Bool
|
||||
|
||||
init(attachment: DraftAttachment) {
|
||||
self.attachment = attachment
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ControllerView(controller: { controller.thumbnailController })
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: false))
|
||||
.matchedGeometrySource(id: attachment.id, presentationID: attachment.id)
|
||||
.overlay {
|
||||
thumbnailFocusedOverlay
|
||||
}
|
||||
.frame(width: thumbnailSize, height: thumbnailSize)
|
||||
.onTapGesture {
|
||||
textEditorFocused = false
|
||||
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
|
||||
controller.focusAttachmentOnTextEditorUnfocus = true
|
||||
}
|
||||
.contextMenu {
|
||||
if attachment.drawingData != nil {
|
||||
Button(action: controller.editDrawing) {
|
||||
Label("Edit Drawing", systemImage: "hand.draw")
|
||||
}
|
||||
} else if attachment.type == .image {
|
||||
Button(action: controller.recognizeText) {
|
||||
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive, action: controller.removeAttachment) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} preview: {
|
||||
ControllerView(controller: { controller.thumbnailController })
|
||||
}
|
||||
|
||||
switch controller.descriptionMode {
|
||||
case .allowEntry:
|
||||
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
|
||||
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
|
||||
.focused($textEditorFocused)
|
||||
|
||||
case .recognizingText:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
.alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in
|
||||
Button("OK") {}
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
||||
#if os(visionOS)
|
||||
.onChange(of: textEditorFocused) {
|
||||
if !textEditorFocused && controller.focusAttachmentOnTextEditorUnfocus {
|
||||
controller.focusAttachment()
|
||||
}
|
||||
}
|
||||
#else
|
||||
.onChange(of: textEditorFocused) { newValue in
|
||||
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
|
||||
controller.focusAttachment()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var thumbnailSize: CGFloat {
|
||||
#if os(visionOS)
|
||||
120
|
||||
#else
|
||||
80
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnailFocusedOverlay: some View {
|
||||
Image(systemName: "arrow.up.backward.and.arrow.down.forward")
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
// use .opacity and an animation, because .transition doesn't seem to play nice with @FocusState
|
||||
.opacity(textEditorFocused ? 1 : 0)
|
||||
.animation(.linear(duration: 0.1), value: textEditorFocused)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AttachmentRowController {
|
||||
enum DescriptionMode {
|
||||
case allowEntry, recognizingText
|
||||
}
|
||||
}
|
|
@ -1,201 +0,0 @@
|
|||
//
|
||||
// AttachmentThumbnailController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 11/10/21.
|
||||
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Photos
|
||||
import TuskerComponents
|
||||
|
||||
class AttachmentThumbnailController: ViewController {
|
||||
unowned let parent: ComposeController
|
||||
let attachment: DraftAttachment
|
||||
|
||||
@Published private var image: UIImage?
|
||||
@Published private var gifController: GIFController?
|
||||
@Published private var fullSize: Bool = false
|
||||
|
||||
init(attachment: DraftAttachment, parent: ComposeController) {
|
||||
self.attachment = attachment
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func loadImageIfNecessary(fullSize: Bool) {
|
||||
if (gifController != nil) || (image != nil && self.fullSize) {
|
||||
return
|
||||
}
|
||||
self.fullSize = fullSize
|
||||
|
||||
switch attachment.data {
|
||||
case .editing(_, let kind, let url):
|
||||
switch kind {
|
||||
case .image:
|
||||
Task { @MainActor in
|
||||
self.image = await parent.fetchAttachment(url)
|
||||
}
|
||||
|
||||
case .video, .gifv:
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||
self.image = UIImage(cgImage: cgImage)
|
||||
}
|
||||
#endif
|
||||
|
||||
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(where: { $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.gifController = GIFController(gifData: data)
|
||||
} else {
|
||||
let image = UIImage(data: data)
|
||||
DispatchQueue.main.async {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let size: CGSize
|
||||
if fullSize {
|
||||
size = PHImageManagerMaximumSize
|
||||
} else {
|
||||
// currently only used as thumbnail in ComposeAttachmentRow
|
||||
size = CGSize(width: 80, height: 80)
|
||||
}
|
||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||
DispatchQueue.main.async {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .drawing(let drawing):
|
||||
image = drawing.imageInLightMode(from: drawing.bounds)
|
||||
|
||||
case .file(let url, let type):
|
||||
if type.conforms(to: .movie) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||
self.image = UIImage(cgImage: cgImage)
|
||||
}
|
||||
#endif
|
||||
} else if let data = try? Data(contentsOf: url) {
|
||||
if type == .gif {
|
||||
self.gifController = GIFController(gifData: data)
|
||||
} else if type.conforms(to: .image),
|
||||
let image = UIImage(data: data) {
|
||||
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
|
||||
// crashing share extension. see FB12186346
|
||||
// if fullSize {
|
||||
image.prepareForDisplay { prepared in
|
||||
DispatchQueue.main.async {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
// } else {
|
||||
// image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
|
||||
// DispatchQueue.main.async {
|
||||
// self.image = prepared
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var view: some SwiftUI.View {
|
||||
View()
|
||||
}
|
||||
|
||||
struct View: SwiftUI.View {
|
||||
@EnvironmentObject private var controller: AttachmentThumbnailController
|
||||
@Environment(\.attachmentThumbnailConfiguration) private var config
|
||||
|
||||
var body: some SwiftUI.View {
|
||||
content
|
||||
.onAppear {
|
||||
controller.loadImageIfNecessary(fullSize: config.fullSize)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some SwiftUI.View {
|
||||
if let gifController = controller.gifController {
|
||||
GIFViewWrapper(controller: gifController)
|
||||
} else if let image = controller.image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(config.aspectRatio, contentMode: config.contentMode)
|
||||
} else {
|
||||
Image(systemName: "photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentThumbnailConfiguration {
|
||||
let aspectRatio: CGFloat?
|
||||
let contentMode: ContentMode
|
||||
let fullSize: Bool
|
||||
|
||||
init(aspectRatio: CGFloat? = nil, contentMode: ContentMode = .fit, fullSize: Bool = false) {
|
||||
self.aspectRatio = aspectRatio
|
||||
self.contentMode = contentMode
|
||||
self.fullSize = fullSize
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentThumbnailConfigurationEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue = AttachmentThumbnailConfiguration()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var attachmentThumbnailConfiguration: AttachmentThumbnailConfiguration {
|
||||
get { self[AttachmentThumbnailConfigurationEnvironmentKey.self] }
|
||||
set { self[AttachmentThumbnailConfigurationEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
private 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) {
|
||||
}
|
||||
}
|
|
@ -1,225 +0,0 @@
|
|||
//
|
||||
// AttachmentsListController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/8/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import PencilKit
|
||||
|
||||
class AttachmentsListController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
var draft: Draft { parent.draft }
|
||||
|
||||
var isValid: Bool {
|
||||
!requiresAttachmentDescriptions && validAttachmentCombination
|
||||
}
|
||||
|
||||
private var requiresAttachmentDescriptions: Bool {
|
||||
if parent.config.requireAttachmentDescriptions {
|
||||
if draft.attachments.count == 0 {
|
||||
return false
|
||||
} else {
|
||||
return !parent.attachmentsMissingDescriptions.isEmpty
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var validAttachmentCombination: Bool {
|
||||
if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
return true
|
||||
} else if draft.attachments.count > 1,
|
||||
draft.draftAttachments.contains(where: { $0.type == .video }) {
|
||||
return false
|
||||
} else if draft.attachments.count > 4 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
init(parent: ComposeController) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
var canAddAttachment: Bool {
|
||||
if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var canAddPoll: Bool {
|
||||
if parent.mastodonController.instanceFeatures.pollsAndAttachments {
|
||||
return true
|
||||
} else {
|
||||
return draft.attachments.count == 0
|
||||
}
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AttachmentsList()
|
||||
}
|
||||
|
||||
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
||||
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
|
||||
// results in the order switching back to the previous order and then to the correct one
|
||||
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
|
||||
var array = draft.draftAttachments
|
||||
array.move(fromOffsets: source, toOffset: destination)
|
||||
draft.attachments = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private func deleteAttachments(at indices: IndexSet) {
|
||||
var array = draft.draftAttachments
|
||||
array.remove(atOffsets: indices)
|
||||
draft.attachments = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private func insertAttachments(at offset: 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 { [weak self] in
|
||||
guard let self,
|
||||
self.canAddAttachment else {
|
||||
return
|
||||
}
|
||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addImage() {
|
||||
parent.deleteDraftOnDisappear = false
|
||||
parent.config.presentAssetPicker?({ results in
|
||||
self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider))
|
||||
})
|
||||
}
|
||||
|
||||
private func addDrawing() {
|
||||
parent.deleteDraftOnDisappear = false
|
||||
parent.config.presentDrawing?(PKDrawing()) { drawing in
|
||||
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
||||
attachment.id = UUID()
|
||||
attachment.drawing = drawing
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePoll() {
|
||||
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
|
||||
withAnimation {
|
||||
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentsList: View {
|
||||
private let cellHeight: CGFloat = 80
|
||||
private let cellPadding: CGFloat = 12
|
||||
|
||||
@EnvironmentObject private var controller: AttachmentsListController
|
||||
@EnvironmentObject private var draft: Draft
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
attachmentsList
|
||||
|
||||
Group {
|
||||
if controller.parent.config.presentAssetPicker != nil {
|
||||
addImageButton
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
|
||||
if controller.parent.config.presentDrawing != nil {
|
||||
addDrawingButton
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
|
||||
togglePollButton
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
#if os(visionOS)
|
||||
.buttonStyle(.bordered)
|
||||
.labelStyle(AttachmentButtonLabelStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
private var attachmentsList: some View {
|
||||
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
|
||||
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
.id(attachment.id)
|
||||
}
|
||||
.onMove(perform: controller.moveAttachments)
|
||||
.onDelete(perform: controller.deleteAttachments)
|
||||
.conditionally(controller.canAddAttachment) {
|
||||
$0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in
|
||||
controller.insertAttachments(at: offset, itemProviders: providers)
|
||||
})
|
||||
}
|
||||
// only sort of works, see #240
|
||||
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, isTargeted: nil) { providers in
|
||||
controller.insertAttachments(at: 0, itemProviders: providers)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var addImageButton: some View {
|
||||
Button(action: controller.addImage) {
|
||||
Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo")
|
||||
}
|
||||
.disabled(!controller.canAddAttachment)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(height: cellHeight / 2)
|
||||
}
|
||||
|
||||
private var addDrawingButton: some View {
|
||||
Button(action: controller.addDrawing) {
|
||||
Label("Draw something", systemImage: "hand.draw")
|
||||
}
|
||||
.disabled(!controller.canAddAttachment)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(height: cellHeight / 2)
|
||||
}
|
||||
|
||||
private var togglePollButton: some View {
|
||||
Button(action: controller.togglePoll) {
|
||||
Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
|
||||
}
|
||||
.disabled(!controller.canAddPoll)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(height: cellHeight / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension View {
|
||||
@ViewBuilder
|
||||
func conditionally(_ condition: Bool, body: (Self) -> some View) -> some View {
|
||||
if condition {
|
||||
body(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(visionOS 1.0, *)
|
||||
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
DefaultLabelStyle().makeBody(configuration: configuration)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
//
|
||||
// AutocompleteController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/25/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
class AutocompleteController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
|
||||
@Published var mode: Mode?
|
||||
|
||||
init(parent: ComposeController) {
|
||||
self.parent = parent
|
||||
|
||||
parent.$currentInput
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0.autocompleteStatePublisher }
|
||||
.map {
|
||||
switch $0 {
|
||||
case .mention(_):
|
||||
return Mode.mention
|
||||
case .emoji(_):
|
||||
return Mode.emoji
|
||||
case .hashtag(_):
|
||||
return Mode.hashtag
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.assign(to: &$mode)
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AutocompleteView()
|
||||
}
|
||||
|
||||
struct AutocompleteView: View {
|
||||
@EnvironmentObject private var parent: ComposeController
|
||||
@EnvironmentObject private var controller: AutocompleteController
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
if let mode = controller.mode {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
suggestionsView(mode: mode)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func suggestionsView(mode: Mode) -> some View {
|
||||
switch mode {
|
||||
case .mention:
|
||||
ControllerView(controller: { AutocompleteMentionsController(composeController: parent) })
|
||||
case .emoji:
|
||||
ControllerView(controller: { AutocompleteEmojisController(composeController: parent) })
|
||||
case .hashtag:
|
||||
ControllerView(controller: { AutocompleteHashtagsController(composeController: parent) })
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
Color(white: colorScheme == .light ? 0.98 : 0.15)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
Color(white: colorScheme == .light ? 0.85 : 0.25)
|
||||
}
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
case mention
|
||||
case emoji
|
||||
case hashtag
|
||||
}
|
||||
}
|
|
@ -1,188 +0,0 @@
|
|||
//
|
||||
// AutocompleteEmojisController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/26/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteEmojisController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
||||
|
||||
private var stateCancellable: AnyCancellable?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
@Published var expanded = false
|
||||
@Published var emojis: [Emoji] = []
|
||||
@Published var emojisBySection: [String: [Emoji]] = [:]
|
||||
|
||||
init(composeController: ComposeController) {
|
||||
self.composeController = composeController
|
||||
|
||||
stateCancellable = composeController.$currentInput
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0.autocompleteStatePublisher }
|
||||
.compactMap {
|
||||
if case .emoji(let s) = $0 {
|
||||
return s
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.removeDuplicates()
|
||||
.sink { [unowned self] query in
|
||||
self.searchTask?.cancel()
|
||||
self.searchTask = Task { [weak self] in
|
||||
await self?.queryChanged(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func queryChanged(_ query: String) async {
|
||||
var emojis = await composeController.mastodonController.getCustomEmojis()
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
if !query.isEmpty {
|
||||
emojis =
|
||||
emojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in
|
||||
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
||||
}
|
||||
.filter(\.1.matched)
|
||||
.sorted { $0.1.score > $1.1.score }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
var shortcodes = Set<String>()
|
||||
var newEmojis = [Emoji]()
|
||||
var newEmojisBySection = [String: [Emoji]]()
|
||||
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
||||
newEmojis.append(emoji)
|
||||
shortcodes.insert(emoji.shortcode)
|
||||
|
||||
let category = emoji.category ?? ""
|
||||
if newEmojisBySection.keys.contains(category) {
|
||||
newEmojisBySection[category]!.append(emoji)
|
||||
} else {
|
||||
newEmojisBySection[category] = [emoji]
|
||||
}
|
||||
}
|
||||
self.emojis = newEmojis
|
||||
self.emojisBySection = newEmojisBySection
|
||||
}
|
||||
|
||||
private func toggleExpanded() {
|
||||
withAnimation {
|
||||
expanded.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private func autocomplete(with emoji: Emoji) {
|
||||
guard let input = composeController.currentInput else { return }
|
||||
input.autocomplete(with: ":\(emoji.shortcode):")
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AutocompleteEmojisView()
|
||||
}
|
||||
|
||||
struct AutocompleteEmojisView: View {
|
||||
@EnvironmentObject private var composeController: ComposeController
|
||||
@EnvironmentObject private var controller: AutocompleteEmojisController
|
||||
@ScaledMetric private var emojiSize = 30
|
||||
|
||||
var body: some View {
|
||||
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
|
||||
HStack(alignment: controller.expanded ? .top : .center, spacing: 0) {
|
||||
emojiList
|
||||
.transition(.move(edge: .bottom))
|
||||
|
||||
toggleExpandedButton
|
||||
.padding(.trailing, 8)
|
||||
.padding(.top, controller.expanded ? 8 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var emojiList: some View {
|
||||
if controller.expanded {
|
||||
verticalGrid
|
||||
.frame(height: 150)
|
||||
} else {
|
||||
horizontalScrollView
|
||||
}
|
||||
}
|
||||
|
||||
private var verticalGrid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
||||
ForEach(controller.emojisBySection.keys.sorted(), id: \.self) { section in
|
||||
Section {
|
||||
ForEach(controller.emojisBySection[section]!, id: \.shortcode) { emoji in
|
||||
Button(action: { controller.autocomplete(with: emoji) }) {
|
||||
composeController.emojiImageView(emoji)
|
||||
.frame(height: emojiSize)
|
||||
}
|
||||
.accessibilityLabel(emoji.shortcode)
|
||||
}
|
||||
} header: {
|
||||
if !section.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section)
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.all, 8)
|
||||
// the spacing between the grid sections doesn't seem to be taken into account by the ScrollView?
|
||||
.padding(.bottom, CGFloat(controller.emojisBySection.keys.count) * 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var horizontalScrollView: some View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 8) {
|
||||
ForEach(controller.emojis, id: \.shortcode) { emoji in
|
||||
Button(action: { controller.autocomplete(with: emoji) }) {
|
||||
HStack(spacing: 4) {
|
||||
composeController.emojiImageView(emoji)
|
||||
.frame(height: emojiSize)
|
||||
Text(verbatim: ":\(emoji.shortcode):")
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(emoji.shortcode)
|
||||
.frame(height: emojiSize)
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: controller.emojis)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: emojiSize + 16)
|
||||
}
|
||||
}
|
||||
|
||||
private var toggleExpandedButton: some View {
|
||||
Button(action: controller.toggleExpanded) {
|
||||
Image(systemName: "chevron.down")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.rotationEffect(controller.expanded ? .zero : .degrees(180))
|
||||
}
|
||||
.accessibilityLabel(controller.expanded ? "Collapse" : "Expand")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,125 +0,0 @@
|
|||
//
|
||||
// AutocompleteHashtagsController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/1/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteHashtagsController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
||||
|
||||
private var stateCancellable: AnyCancellable?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
@Published var hashtags: [Hashtag] = []
|
||||
|
||||
init(composeController: ComposeController) {
|
||||
self.composeController = composeController
|
||||
|
||||
stateCancellable = composeController.$currentInput
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0.autocompleteStatePublisher }
|
||||
.compactMap {
|
||||
if case .hashtag(let s) = $0 {
|
||||
return s
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
||||
.sink { [unowned self] query in
|
||||
self.searchTask?.cancel()
|
||||
self.searchTask = Task { [weak self] in
|
||||
await self?.queryChanged(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func queryChanged(_ query: String) async {
|
||||
guard !query.isEmpty else {
|
||||
hashtags = []
|
||||
return
|
||||
}
|
||||
|
||||
let localHashtags = mastodonController.searchCachedHashtags(query: query)
|
||||
|
||||
var onlyLocalTagsTask: Task<Void, any Error>?
|
||||
if !localHashtags.isEmpty {
|
||||
onlyLocalTagsTask = Task {
|
||||
// we only want to do the local-only search if the trends API call takes more than .25sec or it fails
|
||||
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
|
||||
self.updateHashtags(searchResults: [], trendingTags: [], localHashtags: localHashtags, query: query)
|
||||
}
|
||||
}
|
||||
|
||||
async let trendingTags = try? mastodonController.run(Client.getTrendingHashtags()).0
|
||||
async let searchResults = try? mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])).0.hashtags
|
||||
|
||||
let trends = await trendingTags ?? []
|
||||
let search = await searchResults ?? []
|
||||
|
||||
onlyLocalTagsTask?.cancel()
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
updateHashtags(searchResults: search, trendingTags: trends, localHashtags: localHashtags, query: query)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], localHashtags: [Hashtag], query: String) {
|
||||
var addedHashtags = Set<String>()
|
||||
var hashtags = [(Hashtag, Int)]()
|
||||
for group in [searchResults, trendingTags, localHashtags] {
|
||||
for tag in group where !addedHashtags.contains(tag.name) {
|
||||
let (matched, score) = FuzzyMatcher.match(pattern: query, str: tag.name)
|
||||
if matched {
|
||||
hashtags.append((tag, score))
|
||||
addedHashtags.insert(tag.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.hashtags = hashtags
|
||||
.sorted { $0.1 > $1.1 }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private func autocomplete(with hashtag: Hashtag) {
|
||||
guard let currentInput = composeController.currentInput else { return }
|
||||
currentInput.autocomplete(with: "#\(hashtag.name)")
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AutocompleteHashtagsView()
|
||||
}
|
||||
|
||||
struct AutocompleteHashtagsView: View {
|
||||
@EnvironmentObject private var controller: AutocompleteHashtagsController
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(controller.hashtags, id: \.name) { hashtag in
|
||||
Button(action: { controller.autocomplete(with: hashtag) }) {
|
||||
Text(verbatim: "#\(hashtag.name)")
|
||||
.foregroundColor(Color(uiColor: .label))
|
||||
}
|
||||
.frame(height: 30)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.animation(.linear(duration: 0.2), value: controller.hashtags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -1,179 +0,0 @@
|
|||
//
|
||||
// AutocompleteMentionsController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/25/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteMentionsController: ViewController {
|
||||
|
||||
unowned let composeController: ComposeController
|
||||
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
||||
|
||||
private var stateCancellable: AnyCancellable?
|
||||
|
||||
@Published private var accounts: [AnyAccount] = []
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
init(composeController: ComposeController) {
|
||||
self.composeController = composeController
|
||||
|
||||
stateCancellable = composeController.$currentInput
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0.autocompleteStatePublisher }
|
||||
.compactMap {
|
||||
if case .mention(let s) = $0 {
|
||||
return s
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
||||
.sink { [unowned self] query in
|
||||
self.searchTask?.cancel()
|
||||
// weak in case the autocomplete controller is dealloc'd racing with the task starting
|
||||
self.searchTask = Task { [weak self] in
|
||||
await self?.queryChanged(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func queryChanged(_ query: String) async {
|
||||
guard !query.isEmpty else {
|
||||
accounts = []
|
||||
return
|
||||
}
|
||||
|
||||
let localSearchTask = Task {
|
||||
// we only want to search locally if the search API call takes more than .25sec or it fails
|
||||
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
|
||||
|
||||
let results = self.mastodonController.searchCachedAccounts(query: query)
|
||||
try Task.checkCancellation()
|
||||
|
||||
if !results.isEmpty {
|
||||
self.loadAccounts(results.map { .init(value: $0) }, query: query)
|
||||
}
|
||||
}
|
||||
|
||||
let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0
|
||||
guard let accounts,
|
||||
!Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
localSearchTask.cancel()
|
||||
|
||||
loadAccounts(accounts.map { .init(value: $0) }, query: query)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadAccounts(_ accounts: [AnyAccount], query: String) {
|
||||
guard case .mention(query) = composeController.currentInput?.autocompleteState else {
|
||||
return
|
||||
}
|
||||
|
||||
// when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
|
||||
let ignoreDomain = !query.contains("@")
|
||||
|
||||
self.accounts =
|
||||
accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
|
||||
let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
|
||||
let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
|
||||
return res
|
||||
}
|
||||
.filter(\.1.matched)
|
||||
.map { (account, res) -> (AnyAccount, Int) in
|
||||
// give higher weight to accounts that the user follows or is followed by
|
||||
var score = res.score
|
||||
if let relationship = mastodonController.cachedRelationship(for: account.value.id) {
|
||||
if relationship.following {
|
||||
score += 3
|
||||
}
|
||||
if relationship.followedBy {
|
||||
score += 2
|
||||
}
|
||||
}
|
||||
return (account, score)
|
||||
}
|
||||
.sorted { $0.1 > $1.1 }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private func autocomplete(with account: AnyAccount) {
|
||||
guard let input = composeController.currentInput else {
|
||||
return
|
||||
}
|
||||
input.autocomplete(with: "@\(account.value.acct)")
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
AutocompleteMentionsView()
|
||||
}
|
||||
|
||||
struct AutocompleteMentionsView: View {
|
||||
@EnvironmentObject private var controller: AutocompleteMentionsController
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(controller.accounts) { account in
|
||||
AutocompleteMentionButton(account: account)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.animation(.linear(duration: 0.2), value: controller.accounts)
|
||||
}
|
||||
.onDisappear {
|
||||
controller.searchTask?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AutocompleteMentionButton: View {
|
||||
@EnvironmentObject private var composeController: ComposeController
|
||||
@EnvironmentObject private var controller: AutocompleteMentionsController
|
||||
let account: AnyAccount
|
||||
|
||||
var body: some View {
|
||||
Button(action: { controller.autocomplete(with: account) }) {
|
||||
HStack(spacing: 4) {
|
||||
AvatarImageView(
|
||||
url: account.value.avatar,
|
||||
size: 30,
|
||||
style: composeController.config.avatarStyle,
|
||||
fetchAvatar: composeController.fetchAvatar
|
||||
)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
controller.composeController.displayNameLabel(account.value, .subheadline, 14)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(verbatim: "@\(account.value.acct)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 30)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct AnyAccount: Equatable, Identifiable {
|
||||
let value: any AccountProtocol
|
||||
|
||||
var id: String { value.id }
|
||||
|
||||
static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
|
||||
return lhs.value.id == rhs.value.id
|
||||
}
|
||||
}
|
|
@ -1,502 +0,0 @@
|
|||
//
|
||||
// ComposeController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
import MatchedGeometryPresentation
|
||||
import CoreData
|
||||
|
||||
public final class ComposeController: ViewController {
|
||||
public typealias FetchAttachment = (URL) async -> UIImage?
|
||||
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
||||
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
|
||||
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
|
||||
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
|
||||
public typealias EmojiImageView = (Emoji) -> AnyView
|
||||
|
||||
@Published public private(set) var draft: Draft {
|
||||
didSet {
|
||||
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
|
||||
}
|
||||
}
|
||||
@Published public var config: ComposeUIConfig
|
||||
@Published public var mastodonController: ComposeMastodonContext
|
||||
let fetchAvatar: AvatarImageView.FetchAvatar
|
||||
let fetchAttachment: FetchAttachment
|
||||
let fetchStatus: FetchStatus
|
||||
let displayNameLabel: DisplayNameLabel
|
||||
let currentAccountContainerView: CurrentAccountContainerView
|
||||
let replyContentView: ReplyContentView
|
||||
let emojiImageView: EmojiImageView
|
||||
|
||||
@Published public var currentAccount: (any AccountProtocol)?
|
||||
@Published public var showToolbar = true
|
||||
@Published public var deleteDraftOnDisappear = true
|
||||
|
||||
@Published var autocompleteController: AutocompleteController!
|
||||
@Published var toolbarController: ToolbarController!
|
||||
@Published var attachmentsListController: AttachmentsListController!
|
||||
|
||||
// this property is here rather than on the AttachmentsListController so that the ComposeView
|
||||
// updates when it changes, because changes to it may alter postButtonEnabled
|
||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||
@Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)?
|
||||
let scrollToAttachment = PassthroughSubject<UUID, Never>()
|
||||
@Published var contentWarningBecomeFirstResponder = false
|
||||
@Published var mainComposeTextViewBecomeFirstResponder = false
|
||||
@Published var currentInput: (any ComposeInput)? = nil
|
||||
@Published var shouldEmojiAutocompletionBeginExpanded = false
|
||||
@Published var isShowingSaveDraftSheet = false
|
||||
@Published var isShowingDraftsList = false
|
||||
@Published var poster: PostService?
|
||||
@Published var postError: PostService.Error?
|
||||
@Published public private(set) var didPostSuccessfully = false
|
||||
@Published var hasChangedLanguageSelection = false
|
||||
|
||||
private var isDisappearing = false
|
||||
private var userConfirmedDelete = false
|
||||
|
||||
public var isPosting: Bool {
|
||||
poster != nil
|
||||
}
|
||||
|
||||
var charactersRemaining: Int {
|
||||
let instanceFeatures = mastodonController.instanceFeatures
|
||||
let limit = instanceFeatures.maxStatusChars
|
||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
||||
}
|
||||
|
||||
var postButtonEnabled: Bool {
|
||||
draft.editedStatusID != nil ||
|
||||
(draft.hasContent
|
||||
&& charactersRemaining >= 0
|
||||
&& !isPosting
|
||||
&& attachmentsListController.isValid
|
||||
&& isPollValid)
|
||||
}
|
||||
|
||||
private var isPollValid: Bool {
|
||||
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
|
||||
}
|
||||
|
||||
public var navigationTitle: String {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = fetchStatus(id) {
|
||||
return "Reply to @\(status.account.acct)"
|
||||
} else if draft.editedStatusID != nil {
|
||||
return "Edit Post"
|
||||
} else {
|
||||
return "New Post"
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
draft: Draft,
|
||||
config: ComposeUIConfig,
|
||||
mastodonController: ComposeMastodonContext,
|
||||
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
|
||||
fetchAttachment: @escaping FetchAttachment,
|
||||
fetchStatus: @escaping FetchStatus,
|
||||
displayNameLabel: @escaping DisplayNameLabel,
|
||||
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
|
||||
replyContentView: @escaping ReplyContentView,
|
||||
emojiImageView: @escaping EmojiImageView
|
||||
) {
|
||||
self.draft = draft
|
||||
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
|
||||
self.config = config
|
||||
self.mastodonController = mastodonController
|
||||
self.fetchAvatar = fetchAvatar
|
||||
self.fetchAttachment = fetchAttachment
|
||||
self.fetchStatus = fetchStatus
|
||||
self.displayNameLabel = displayNameLabel
|
||||
self.currentAccountContainerView = currentAccountContainerView
|
||||
self.replyContentView = replyContentView
|
||||
self.emojiImageView = emojiImageView
|
||||
|
||||
self.autocompleteController = AutocompleteController(parent: self)
|
||||
self.toolbarController = ToolbarController(parent: self)
|
||||
self.attachmentsListController = AttachmentsListController(parent: self)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
|
||||
}
|
||||
|
||||
public var view: some View {
|
||||
ComposeView(poster: poster)
|
||||
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||
.environmentObject(draft)
|
||||
.environmentObject(mastodonController.instanceFeatures)
|
||||
.environment(\.composeUIConfig, config)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
||||
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
|
||||
deleted.contains(where: { $0.objectID == self.draft.objectID }),
|
||||
!isDisappearing {
|
||||
self.config.dismiss(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
|
||||
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
|
||||
return false
|
||||
}
|
||||
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
if draft.draftAttachments.allSatisfy({ $0.type == .image }) {
|
||||
// if providers are videos, this technically allows invalid video/image combinations
|
||||
return itemProviders.count + draft.attachments.count <= 4
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public func paste(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 {
|
||||
guard self.attachmentsListController.canAddAttachment else { return }
|
||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func cancel() {
|
||||
if draft.hasContent {
|
||||
isShowingSaveDraftSheet = true
|
||||
} else {
|
||||
deleteDraftOnDisappear = true
|
||||
config.dismiss(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func cancel(deleteDraft: Bool) {
|
||||
deleteDraftOnDisappear = true
|
||||
userConfirmedDelete = deleteDraft
|
||||
config.dismiss(.cancel)
|
||||
}
|
||||
|
||||
func postStatus() {
|
||||
guard !isPosting,
|
||||
draft.editedStatusID != nil || draft.hasContent else {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
|
||||
self.poster = poster
|
||||
|
||||
// try to resign the first responder, if there is one.
|
||||
// otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide
|
||||
// and the first responder to change during a view update, which in turn triggers a bunch of state changes
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
|
||||
do {
|
||||
try await poster.post()
|
||||
|
||||
deleteDraftOnDisappear = true
|
||||
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 let error as PostService.Error {
|
||||
self.postError = error
|
||||
self.poster = nil
|
||||
} catch {
|
||||
fatalError("unreachable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showDrafts() {
|
||||
isShowingDraftsList = true
|
||||
}
|
||||
|
||||
func selectDraft(_ newDraft: Draft) {
|
||||
let oldDraft = self.draft
|
||||
self.draft = newDraft
|
||||
|
||||
if !oldDraft.hasContent {
|
||||
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
|
||||
}
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
func onDisappear() {
|
||||
isDisappearing = true
|
||||
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) {
|
||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||
}
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
func toggleContentWarning() {
|
||||
draft.contentWarningEnabled.toggle()
|
||||
if draft.contentWarningEnabled {
|
||||
contentWarningBecomeFirstResponder = true
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@objc private func currentInputModeChanged() {
|
||||
guard let mode = currentInput?.textInputMode,
|
||||
let code = LanguagePicker.codeFromInputMode(mode),
|
||||
!hasChangedLanguageSelection && !draft.hasContent else {
|
||||
return
|
||||
}
|
||||
draft.language = code.identifier
|
||||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
@OptionalObservedObject var poster: PostService?
|
||||
@EnvironmentObject var controller: ComposeController
|
||||
@EnvironmentObject var draft: Draft
|
||||
#if !os(visionOS)
|
||||
@StateObject private var keyboardReader = KeyboardReader()
|
||||
#endif
|
||||
@State private var globalFrameOutsideList = CGRect.zero
|
||||
|
||||
init(poster: PostService?) {
|
||||
self.poster = poster
|
||||
}
|
||||
|
||||
var config: ComposeUIConfig {
|
||||
controller.config
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
navRoot
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
|
||||
private var navRoot: some View {
|
||||
ZStack(alignment: .top) {
|
||||
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
|
||||
config.backgroundColor
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
mainList
|
||||
.onReceive(controller.scrollToAttachment) { id in
|
||||
proxy.scrollTo(id, anchor: .center)
|
||||
}
|
||||
}
|
||||
|
||||
if let poster = poster {
|
||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if controller.showToolbar {
|
||||
VStack(spacing: 0) {
|
||||
ControllerView(controller: { controller.autocompleteController })
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.default, value: controller.currentInput?.autocompleteState)
|
||||
|
||||
#if !os(visionOS)
|
||||
ControllerView(controller: { controller.toolbarController })
|
||||
#endif
|
||||
}
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||
#if targetEnvironment(macCatalyst)
|
||||
ToolbarItem(placement: .topBarTrailing) { draftsButton }
|
||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||
#else
|
||||
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
|
||||
#endif
|
||||
#if os(visionOS)
|
||||
ToolbarItem(placement: .bottomOrnament) {
|
||||
ControllerView(controller: { controller.toolbarController })
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
|
||||
.onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in
|
||||
globalFrameOutsideList = newValue
|
||||
}
|
||||
})
|
||||
.sheet(isPresented: $controller.isShowingDraftsList) {
|
||||
ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) })
|
||||
}
|
||||
.alertWithData("Error Posting", data: $controller.postError, actions: { _ in
|
||||
Button("OK") {}
|
||||
}, message: { error in
|
||||
Text(error.localizedDescription)
|
||||
})
|
||||
.matchedGeometryPresentation(id: Binding(get: { () -> UUID?? in
|
||||
let id = controller.focusedAttachment?.0.id
|
||||
// this needs to be a double optional, since the type used for for the presentationID in the geom source is a UUID?
|
||||
return id.map { Optional.some($0) }
|
||||
}, set: {
|
||||
if $0 == nil {
|
||||
controller.focusedAttachment = nil
|
||||
} else {
|
||||
fatalError()
|
||||
}
|
||||
}), backgroundColor: .black) {
|
||||
ControllerView(controller: {
|
||||
FocusedAttachmentController(
|
||||
parent: controller,
|
||||
attachment: controller.focusedAttachment!.0,
|
||||
thumbnailController: controller.focusedAttachment!.1
|
||||
)
|
||||
})
|
||||
}
|
||||
.onDisappear(perform: controller.onDisappear)
|
||||
.navigationTitle(controller.navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var mainList: some View {
|
||||
List {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = controller.fetchStatus(id) {
|
||||
ReplyStatusView(
|
||||
status: status,
|
||||
rowTopInset: 8,
|
||||
globalFrameOutsideList: globalFrameOutsideList
|
||||
)
|
||||
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
|
||||
.id(id)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
|
||||
HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining)
|
||||
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
|
||||
if draft.contentWarningEnabled {
|
||||
EmojiTextField(
|
||||
text: $draft.contentWarning,
|
||||
placeholder: "Write your warning here",
|
||||
maxLength: nil,
|
||||
becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
|
||||
focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
|
||||
MainTextView()
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
|
||||
if let poll = draft.poll {
|
||||
ControllerView(controller: { PollController(parent: controller, poll: poll) })
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
|
||||
ControllerView(controller: { controller.attachmentsListController })
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.disabled(controller.isPosting)
|
||||
}
|
||||
|
||||
private var cancelButton: some View {
|
||||
Button(action: controller.cancel) {
|
||||
Text("Cancel")
|
||||
// otherwise all Buttons in the nav bar are made semibold
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
}
|
||||
.disabled(controller.isPosting)
|
||||
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
||||
// edit drafts can't be saved
|
||||
if draft.editedStatusID == nil {
|
||||
Button(action: { controller.cancel(deleteDraft: false) }) {
|
||||
Text("Save Draft")
|
||||
}
|
||||
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||
Text("Delete Draft")
|
||||
}
|
||||
} else {
|
||||
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||
Text("Cancel Edit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var postOrDraftsButton: some View {
|
||||
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||
postButton
|
||||
} else {
|
||||
draftsButton
|
||||
}
|
||||
}
|
||||
|
||||
private var draftsButton: some View {
|
||||
Button(action: controller.showDrafts) {
|
||||
Text("Drafts")
|
||||
}
|
||||
}
|
||||
|
||||
private var postButton: some View {
|
||||
Button(action: controller.postStatus) {
|
||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(!controller.postButtonEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
|
||||
static var defaultValue: CGRect = .zero
|
||||
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue = ComposeUIConfig()
|
||||
}
|
||||
extension EnvironmentValues {
|
||||
var composeUIConfig: ComposeUIConfig {
|
||||
get { self[ComposeUIConfigEnvironmentKey.self] }
|
||||
set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
|
@ -1,174 +0,0 @@
|
|||
//
|
||||
// DraftsController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/7/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TuskerComponents
|
||||
import CoreData
|
||||
|
||||
class DraftsController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
@Published var draftForDifferentReply: Draft?
|
||||
|
||||
init(parent: ComposeController, isPresented: Binding<Bool>) {
|
||||
self.parent = parent
|
||||
self._isPresented = isPresented
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
DraftsRepresentable()
|
||||
}
|
||||
|
||||
func maybeSelectDraft(_ draft: Draft) {
|
||||
if draft.inReplyToID != parent.draft.inReplyToID,
|
||||
parent.draft.hasContent {
|
||||
draftForDifferentReply = draft
|
||||
} else {
|
||||
confirmSelectDraft(draft)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelSelectingDraft() {
|
||||
draftForDifferentReply = nil
|
||||
}
|
||||
|
||||
func confirmSelectDraft(_ draft: Draft) {
|
||||
parent.selectDraft(draft)
|
||||
closeDrafts()
|
||||
}
|
||||
|
||||
func deleteDraft(_ draft: Draft) {
|
||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||
}
|
||||
|
||||
func closeDrafts() {
|
||||
isPresented = false
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
struct DraftsRepresentable: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = UIHostingController<DraftsView>
|
||||
|
||||
func makeUIViewController(context: Context) -> UIHostingController<DraftsController.DraftsView> {
|
||||
return UIHostingController(rootView: DraftsView())
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIHostingController<DraftsController.DraftsView>, context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
struct DraftsView: View {
|
||||
@EnvironmentObject private var controller: DraftsController
|
||||
@EnvironmentObject private var currentDraft: Draft
|
||||
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
ForEach(drafts) { draft in
|
||||
Button(action: { controller.maybeSelectDraft(draft) }) {
|
||||
DraftRow(draft: draft)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive, action: { controller.deleteDraft(draft) }) {
|
||||
Label("Delete Draft", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.ifLet(controller.parent.config.userActivityForDraft(draft), modify: { view, activity in
|
||||
view.onDrag { activity }
|
||||
})
|
||||
}
|
||||
.onDelete { indices in
|
||||
indices.map { drafts[$0] }.forEach(controller.deleteDraft)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Drafts")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||
}
|
||||
}
|
||||
.alertWithData("Different Reply", data: $controller.draftForDifferentReply) { draft in
|
||||
Button(role: .cancel, action: controller.cancelSelectingDraft) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(action: { controller.confirmSelectDraft(draft) }) {
|
||||
Text("Restore Draft")
|
||||
}
|
||||
} message: { _ in
|
||||
Text("The selected draft is a reply to a different post, do you wish to use it?")
|
||||
}
|
||||
.onAppear {
|
||||
drafts.nsPredicate = NSPredicate(format: "accountID == %@ AND id != %@ AND lastModified != nil", controller.parent.mastodonController.accountInfo!.id, currentDraft.id as NSUUID)
|
||||
}
|
||||
}
|
||||
|
||||
private var cancelButton: some View {
|
||||
Button(action: controller.closeDrafts) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DraftRow: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@EnvironmentObject private var controller: DraftsController
|
||||
|
||||
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())
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
|
||||
if draft.contentWarningEnabled {
|
||||
Text(draft.contentWarning)
|
||||
.font(.body.bold())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text(draft.text)
|
||||
.font(.body)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(draft.draftAttachments) { attachment in
|
||||
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment, parent: controller.parent) })
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
.frame(height: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let lastModified = draft.lastModified {
|
||||
Text(lastModified.formatted(.abbreviatedTimeAgo))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func ifLet<T, V: View>(_ value: T?, modify: (Self, T) -> V) -> some View {
|
||||
if let value {
|
||||
modify(self, value)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,119 +0,0 @@
|
|||
//
|
||||
// FocusedAttachmentController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 4/29/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MatchedGeometryPresentation
|
||||
import AVKit
|
||||
|
||||
class FocusedAttachmentController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
let attachment: DraftAttachment
|
||||
let thumbnailController: AttachmentThumbnailController
|
||||
private let player: AVPlayer?
|
||||
|
||||
init(parent: ComposeController, attachment: DraftAttachment, thumbnailController: AttachmentThumbnailController) {
|
||||
self.parent = parent
|
||||
self.attachment = attachment
|
||||
self.thumbnailController = thumbnailController
|
||||
|
||||
if case let .file(url, type) = attachment.data,
|
||||
type.conforms(to: .movie) {
|
||||
self.player = AVPlayer(url: url)
|
||||
self.player!.isMuted = true
|
||||
} else {
|
||||
self.player = nil
|
||||
}
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
FocusedAttachmentView(attachment: attachment)
|
||||
}
|
||||
|
||||
struct FocusedAttachmentView: View {
|
||||
@ObservedObject var attachment: DraftAttachment
|
||||
@EnvironmentObject private var controller: FocusedAttachmentController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var textEditorFocused: Bool
|
||||
@EnvironmentObject private var matchedGeomState: MatchedGeometryState
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if let player = controller.player {
|
||||
VideoPlayer(player: player)
|
||||
.matchedGeometryDestination(id: attachment.id)
|
||||
.onAppear {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
ZoomableScrollView {
|
||||
attachmentView
|
||||
.matchedGeometryDestination(id: attachment.id)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
FocusedAttachmentDescriptionView(attachment: attachment)
|
||||
.environment(\.colorScheme, .dark)
|
||||
.matchedGeometryDestination(id: AttachmentDescriptionTextViewID(attachment))
|
||||
.frame(height: 150)
|
||||
.focused($textEditorFocused)
|
||||
}
|
||||
.background(.black)
|
||||
.overlay(alignment: .topLeading, content: {
|
||||
Button {
|
||||
// set the mode to dismissing immediately, so that layout changes due to the keyboard hiding
|
||||
// (which happens before the dismiss animation controller starts running) don't alter the destination frames
|
||||
if textEditorFocused {
|
||||
matchedGeomState.mode = .dismissing
|
||||
}
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "arrow.down.forward.and.arrow.up.backward")
|
||||
}
|
||||
.buttonStyle(DismissFocusedAttachmentButtonStyle())
|
||||
.padding([.top, .leading], 4)
|
||||
})
|
||||
}
|
||||
|
||||
private var attachmentView: some View {
|
||||
ControllerView(controller: { controller.thumbnailController })
|
||||
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DismissFocusedAttachmentButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(.black.opacity(0.5))
|
||||
|
||||
configuration.label
|
||||
.foregroundColor(.white)
|
||||
.imageScale(.large)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentDescriptionTextViewID: Hashable {
|
||||
let attachmentID: UUID!
|
||||
|
||||
init(_ attachment: DraftAttachment) {
|
||||
self.attachmentID = attachment.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(attachmentID)
|
||||
hasher.combine("descriptionTextView")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
//
|
||||
// PlaceholderController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/6/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||
|
||||
private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView()
|
||||
|
||||
static func makePlaceholderView() -> some View {
|
||||
let components = Calendar.current.dateComponents([.month, .day], from: Date())
|
||||
if components.month == 3 && components.day == 14,
|
||||
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||
Text("Happy π day!")
|
||||
} else if components.month == 4 && components.day == 1 {
|
||||
Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
|
||||
} else if components.month == 9 && components.day == 5 {
|
||||
// https://weirder.earth/@noracodes/109276419847254552
|
||||
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
||||
Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
|
||||
} else if components.month == 9 && components.day == 21 {
|
||||
Text("Do you remember?")
|
||||
} else if components.month == 10 && components.day == 31 {
|
||||
if .random() {
|
||||
Text("Post something spooky!")
|
||||
} else {
|
||||
Text("Any questions?")
|
||||
}
|
||||
} else {
|
||||
Text("What's on your mind?")
|
||||
}
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
placeholderView
|
||||
}
|
||||
}
|
||||
|
||||
// exists to provide access to the type alias since the @State property needs it to be explicit
|
||||
private protocol PlaceholderViewProvider {
|
||||
associatedtype PlaceholderView: View
|
||||
@ViewBuilder
|
||||
static func makePlaceholderView() -> PlaceholderView
|
||||
}
|
|
@ -1,195 +0,0 @@
|
|||
//
|
||||
// PollController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/25/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TuskerComponents
|
||||
|
||||
class PollController: ViewController {
|
||||
|
||||
unowned let parent: ComposeController
|
||||
var draft: Draft { parent.draft }
|
||||
let poll: Poll
|
||||
|
||||
@Published var duration: Duration
|
||||
|
||||
init(parent: ComposeController, poll: Poll) {
|
||||
self.parent = parent
|
||||
self.poll = poll
|
||||
self.duration = .fromTimeInterval(poll.duration) ?? .oneDay
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
PollView()
|
||||
.environmentObject(poll)
|
||||
}
|
||||
|
||||
private func removePoll() {
|
||||
withAnimation {
|
||||
draft.poll = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func moveOptions(indices: IndexSet, newIndex: Int) {
|
||||
// see AttachmentsListController.moveAttachments
|
||||
var array = poll.pollOptions
|
||||
array.move(fromOffsets: indices, toOffset: newIndex)
|
||||
poll.options = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private func removeOption(_ option: PollOption) {
|
||||
var array = poll.pollOptions
|
||||
array.remove(at: poll.options.index(of: option))
|
||||
poll.options = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private var canAddOption: Bool {
|
||||
if let max = parent.mastodonController.instanceFeatures.maxPollOptionsCount {
|
||||
return poll.options.count < max
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func addOption() {
|
||||
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
|
||||
option.poll = poll
|
||||
poll.options.add(option)
|
||||
}
|
||||
|
||||
struct PollView: View {
|
||||
@EnvironmentObject private var controller: PollController
|
||||
@EnvironmentObject private var poll: Poll
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Poll")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: controller.removePoll) {
|
||||
Image(systemName: "xmark")
|
||||
.imageScale(.small)
|
||||
.padding(4)
|
||||
}
|
||||
.accessibilityLabel("Remove poll")
|
||||
.buttonStyle(.plain)
|
||||
.accentColor(buttonForegroundColor)
|
||||
.background(Circle().foregroundColor(buttonBackgroundColor))
|
||||
.hoverEffect()
|
||||
}
|
||||
|
||||
List {
|
||||
ForEach($poll.pollOptions) { $option in
|
||||
PollOptionView(option: option, remove: { controller.removeOption(option) })
|
||||
.frame(height: 36)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.onMove(perform: controller.moveOptions)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollDisabled(true)
|
||||
.frame(height: 44 * CGFloat(poll.options.count))
|
||||
|
||||
Button(action: controller.addOption) {
|
||||
Label {
|
||||
Text("Add Option")
|
||||
} icon: {
|
||||
Image(systemName: "plus")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(!controller.canAddOption)
|
||||
|
||||
HStack {
|
||||
MenuPicker(selection: $poll.multiple, options: [
|
||||
.init(value: true, title: "Allow multiple"),
|
||||
.init(value: false, title: "Single choice"),
|
||||
])
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
MenuPicker(selection: $controller.duration, options: Duration.allCases.map {
|
||||
.init(value: $0, title: Duration.formatter.string(from: $0.timeInterval)!)
|
||||
})
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.foregroundColor(backgroundColor)
|
||||
)
|
||||
#if os(visionOS)
|
||||
.onChange(of: controller.duration) {
|
||||
poll.duration = controller.duration.timeInterval
|
||||
}
|
||||
#else
|
||||
.onChange(of: controller.duration) { newValue in
|
||||
poll.duration = newValue.timeInterval
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
// in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
|
||||
colorScheme == .dark ? controller.parent.config.fillColor : Color(white: 0.95)
|
||||
}
|
||||
|
||||
private var buttonForegroundColor: Color {
|
||||
Color(uiColor: .label)
|
||||
}
|
||||
|
||||
private var buttonBackgroundColor: Color {
|
||||
Color(white: colorScheme == .dark ? 0.1 : 0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PollController {
|
||||
enum Duration: 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) -> Duration? {
|
||||
for it in allCases where it.timeInterval == ti {
|
||||
return it
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var timeInterval: TimeInterval {
|
||||
switch self {
|
||||
case .fiveMinutes:
|
||||
return 5 * 60
|
||||
case .thirtyMinutes:
|
||||
return 30 * 60
|
||||
case .oneHour:
|
||||
return 60 * 60
|
||||
case .sixHours:
|
||||
return 6 * 60 * 60
|
||||
case .oneDay:
|
||||
return 24 * 60 * 60
|
||||
case .threeDays:
|
||||
return 3 * 24 * 60 * 60
|
||||
case .sevenDays:
|
||||
return 7 * 24 * 60 * 60
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,200 +0,0 @@
|
|||
//
|
||||
// ToolbarController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/7/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class ToolbarController: ViewController {
|
||||
static let height: CGFloat = 44
|
||||
|
||||
unowned let parent: ComposeController
|
||||
|
||||
@Published var minWidth: CGFloat?
|
||||
@Published var realWidth: CGFloat?
|
||||
|
||||
init(parent: ComposeController) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
var view: some View {
|
||||
ToolbarView()
|
||||
}
|
||||
|
||||
func showEmojiPicker() {
|
||||
guard parent.currentInput?.autocompleteState == nil else {
|
||||
return
|
||||
}
|
||||
parent.shouldEmojiAutocompletionBeginExpanded = true
|
||||
parent.currentInput?.beginAutocompletingEmoji()
|
||||
}
|
||||
|
||||
func formatAction(_ format: StatusFormat) -> () -> Void {
|
||||
{ [weak self] in
|
||||
self?.parent.currentInput?.applyFormat(format)
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolbarView: View {
|
||||
@EnvironmentObject private var draft: Draft
|
||||
@EnvironmentObject private var controller: ToolbarController
|
||||
@EnvironmentObject private var composeController: ComposeController
|
||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||
|
||||
#if !os(visionOS)
|
||||
@State private var minWidth: CGFloat?
|
||||
@State private var realWidth: CGFloat?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(visionOS)
|
||||
buttons
|
||||
#else
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
buttons
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: minWidth)
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||
realWidth = width
|
||||
}
|
||||
})
|
||||
}
|
||||
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
|
||||
.frame(height: ToolbarController.height)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||
.overlay(alignment: .top) {
|
||||
Divider()
|
||||
.edgesIgnoringSafeArea([.leading, .trailing])
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||
minWidth = width
|
||||
}
|
||||
})
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var buttons: some View {
|
||||
HStack(spacing: 0) {
|
||||
cwButton
|
||||
|
||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
||||
|
||||
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
||||
localOnlyPicker
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.padding(.leading, 4)
|
||||
#elseif !os(visionOS)
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
}
|
||||
|
||||
if let currentInput = composeController.currentInput,
|
||||
currentInput.toolbarElements.contains(.emojiPicker) {
|
||||
customEmojiButton
|
||||
}
|
||||
|
||||
if let currentInput = composeController.currentInput,
|
||||
currentInput.toolbarElements.contains(.formattingButtons),
|
||||
composeController.config.contentType != .plain {
|
||||
|
||||
Spacer()
|
||||
formatButtons
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cwButton: some View {
|
||||
Button("CW", action: controller.parent.toggleContentWarning)
|
||||
.accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
}
|
||||
|
||||
private var visibilityBinding: Binding<Pachyderm.Visibility> {
|
||||
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
|
||||
// changing the visibility when local-only.
|
||||
if draft.localOnly,
|
||||
composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility {
|
||||
return .constant(.public)
|
||||
} else {
|
||||
return $draft.visibility
|
||||
}
|
||||
}
|
||||
|
||||
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
|
||||
let visibilities: [Pachyderm.Visibility]
|
||||
if !composeController.mastodonController.instanceFeatures.composeDirectStatuses {
|
||||
visibilities = [.public, .unlisted, .private]
|
||||
} else {
|
||||
visibilities = Pachyderm.Visibility.allCases
|
||||
}
|
||||
return visibilities.map { vis in
|
||||
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
private var localOnlyPicker: some View {
|
||||
let domain = composeController.mastodonController.accountInfo!.instanceURL.host!
|
||||
return MenuPicker(selection: $draft.localOnly, options: [
|
||||
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
|
||||
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
|
||||
], buttonStyle: .iconOnly)
|
||||
}
|
||||
|
||||
private var customEmojiButton: some View {
|
||||
Button(action: controller.showEmojiPicker) {
|
||||
Label("Insert custom emoji", systemImage: "face.smiling")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: imageSize))
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||
}
|
||||
|
||||
private var formatButtons: some View {
|
||||
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
||||
Button(action: controller.formatAction(format)) {
|
||||
Image(systemName: format.imageName)
|
||||
.font(.system(size: imageSize))
|
||||
}
|
||||
.accessibilityLabel(format.accessibilityLabel)
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolbarWidthPrefKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat? = nil
|
||||
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
|
@ -1,73 +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 public var text: String
|
||||
@NSManaged private var visibilityStr: String
|
||||
|
||||
@NSManaged internal var attachments: NSMutableOrderedSet
|
||||
@NSManaged public var poll: Poll?
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Draft {
|
||||
public var hasContent: Bool {
|
||||
(!text.isEmpty && text != initialText) ||
|
||||
(contentWarningEnabled && !contentWarning.isEmpty && contentWarning != initialContentWarning) ||
|
||||
attachments.count > 0 ||
|
||||
poll?.hasContent == true
|
||||
}
|
||||
}
|
|
@ -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 internal 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<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="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>
|
|
@ -1,217 +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 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 = PollController.Duration.allCases.max(by: {
|
||||
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
|
||||
})!.timeInterval
|
||||
} else {
|
||||
poll.duration = PollController.Duration.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 allAttachmentsReq = DraftAttachment.fetchRequest()
|
||||
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
|
||||
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
|
||||
return
|
||||
}
|
||||
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
||||
for url in orphaned {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
logger.error("Failed to remove orphaned attachment: \(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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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.allSatisfy { !$0.text.isEmpty }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
//
|
||||
// KeyboardReader.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/7/23.
|
||||
//
|
||||
|
||||
#if !os(visionOS)
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
class KeyboardReader: ObservableObject {
|
||||
// @Published var isVisible = false
|
||||
@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
|
||||
// isVisible = endFrame.height > 72
|
||||
keyboardHeight = endFrame.height
|
||||
}
|
||||
|
||||
@objc func willHide() {
|
||||
// sometimes willHide is called during a SwiftUI view update
|
||||
DispatchQueue.main.async {
|
||||
// self.isVisible = false
|
||||
self.keyboardHeight = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -1,12 +0,0 @@
|
|||
//
|
||||
// DismissMode.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/7/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum DismissMode {
|
||||
case cancel, post
|
||||
}
|
|
@ -1,33 +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
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [unowned self] _ in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@StateObject private var republisher = Republisher()
|
||||
var wrappedValue: T?
|
||||
|
||||
func update() {
|
||||
republisher.wrapped = wrappedValue
|
||||
}
|
||||
}
|
|
@ -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 == "_"
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
//
|
||||
// ViewController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public protocol ViewController: ObservableObject {
|
||||
associatedtype ContentView: View
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue