Compare commits
4 Commits
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 5821a16ca7 | |
Shadowfacts | bd81c81500 | |
Shadowfacts | a2a2feef58 | |
Shadowfacts | a38f9df3af |
|
@ -1,5 +1,3 @@
|
||||||
Dist.xcconfig
|
|
||||||
Tusker.xcconfig
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
MyPlayground.playground/
|
MyPlayground.playground/
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
[submodule "Embassy"]
|
[submodule "SwiftSoup"]
|
||||||
path = Embassy
|
path = SwiftSoup
|
||||||
url = https://github.com/envoy/Embassy.git
|
url = git://github.com/scinfu/SwiftSoup.git
|
||||||
[submodule "Ambassador"]
|
[submodule "Cache"]
|
||||||
path = Ambassador
|
path = Cache
|
||||||
url = https://github.com/envoy/Ambassador.git
|
url = git@github.com:hyperoslo/Cache.git
|
||||||
|
[submodule "Gifu"]
|
||||||
|
path = Gifu
|
||||||
|
url = git://github.com/kaishin/Gifu.git
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 4fe264af51e0dd7228486c604750909e368241a7
|
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<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:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="210mm"
|
||||||
|
height="297mm"
|
||||||
|
viewBox="0 0 210 297"
|
||||||
|
version="1.1"
|
||||||
|
id="svg4592"
|
||||||
|
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
|
||||||
|
sodipodi:docname="Download.svg">
|
||||||
|
<defs
|
||||||
|
id="defs4586" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="1.979899"
|
||||||
|
inkscape:cx="166.13768"
|
||||||
|
inkscape:cy="136.01503"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1395"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="1"
|
||||||
|
inkscape:window-maximized="1" />
|
||||||
|
<metadata
|
||||||
|
id="metadata4589">
|
||||||
|
<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 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<rect
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.7641871;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="rect5166"
|
||||||
|
width="100"
|
||||||
|
height="10"
|
||||||
|
x="-45"
|
||||||
|
y="302"
|
||||||
|
ry="4.9918189" />
|
||||||
|
<rect
|
||||||
|
transform="matrix(-0.70710679,-0.70710677,-0.70710679,0.70710677,0,0)"
|
||||||
|
ry="4.4665961"
|
||||||
|
y="199.22142"
|
||||||
|
x="-214.47757"
|
||||||
|
height="8.9331923"
|
||||||
|
width="66.99894"
|
||||||
|
id="rect5164"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.46821165;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||||
|
<rect
|
||||||
|
transform="matrix(0,1,1,0,0,0)"
|
||||||
|
ry="4.4665961"
|
||||||
|
y="0"
|
||||||
|
x="212"
|
||||||
|
height="8.9331923"
|
||||||
|
width="85"
|
||||||
|
id="rect5168"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.78008413;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||||
|
<rect
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.46821189;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="rect5162"
|
||||||
|
width="66.99894"
|
||||||
|
height="8.9331923"
|
||||||
|
x="141.1559"
|
||||||
|
y="205.54411"
|
||||||
|
ry="4.4665961"
|
||||||
|
transform="matrix(-0.70710679,0.70710677,0.70710679,0.70710677,0,0)" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.8 KiB |
|
@ -0,0 +1,73 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<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:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="210mm"
|
||||||
|
height="297mm"
|
||||||
|
viewBox="0 0 210 297"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1007"
|
||||||
|
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
|
||||||
|
sodipodi:docname="Favorite.svg">
|
||||||
|
<defs
|
||||||
|
id="defs1001" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="0.98994949"
|
||||||
|
inkscape:cx="293.05888"
|
||||||
|
inkscape:cy="341.92599"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1395"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="1"
|
||||||
|
inkscape:window-maximized="1" />
|
||||||
|
<metadata
|
||||||
|
id="metadata1004">
|
||||||
|
<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 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<path
|
||||||
|
sodipodi:type="star"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.64583325;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill"
|
||||||
|
id="path1571"
|
||||||
|
sodipodi:sides="5"
|
||||||
|
sodipodi:cx="68.784126"
|
||||||
|
sodipodi:cy="238.46798"
|
||||||
|
sodipodi:r1="72.331841"
|
||||||
|
sodipodi:r2="30.379374"
|
||||||
|
sodipodi:arg1="0.94281504"
|
||||||
|
sodipodi:arg2="1.5711336"
|
||||||
|
inkscape:flatsided="false"
|
||||||
|
inkscape:rounded="0"
|
||||||
|
inkscape:randomized="0"
|
||||||
|
d="M 111.27998,297.00001 68.77388,268.84736 26.248805,296.97133 39.888461,247.84598 -6.6077817e-8,216.09302 50.935869,213.88454 68.80852,166.13615 l 17.840442,47.76043 50.934368,2.24284 -39.90987,31.72605 z"
|
||||||
|
inkscape:transform-center-x="-0.0075377331"
|
||||||
|
inkscape:transform-center-y="-6.8999101" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.4 KiB |
|
@ -0,0 +1,78 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<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:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="210mm"
|
||||||
|
height="297mm"
|
||||||
|
viewBox="0 0 210 297"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5214"
|
||||||
|
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
|
||||||
|
sodipodi:docname="Link.svg">
|
||||||
|
<defs
|
||||||
|
id="defs5208" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="2.8"
|
||||||
|
inkscape:cx="166.9267"
|
||||||
|
inkscape:cy="23.876042"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1395"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="1"
|
||||||
|
inkscape:window-maximized="1" />
|
||||||
|
<metadata
|
||||||
|
id="metadata5211">
|
||||||
|
<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 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<ellipse
|
||||||
|
cy="308.6665"
|
||||||
|
cx="68.333336"
|
||||||
|
id="circle5763"
|
||||||
|
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:9;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
rx="24.99999"
|
||||||
|
ry="25.000156" />
|
||||||
|
<circle
|
||||||
|
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:9;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path5218"
|
||||||
|
cx="28.333334"
|
||||||
|
cy="268.66666"
|
||||||
|
r="25" />
|
||||||
|
<rect
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.61034346;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="rect5767"
|
||||||
|
width="65"
|
||||||
|
height="12.5"
|
||||||
|
x="206.42897"
|
||||||
|
y="164.74048"
|
||||||
|
ry="6.25"
|
||||||
|
transform="rotate(45)" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
|
@ -0,0 +1,84 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<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:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="80"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 21.166666 10.583334"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1623"
|
||||||
|
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
|
||||||
|
sodipodi:docname="More.svg">
|
||||||
|
<defs
|
||||||
|
id="defs1617" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="5.6"
|
||||||
|
inkscape:cx="83.737701"
|
||||||
|
inkscape:cy="47.564201"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1395"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="1"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
units="px" />
|
||||||
|
<metadata
|
||||||
|
id="metadata1620">
|
||||||
|
<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 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(0,-286.41665)">
|
||||||
|
<rect
|
||||||
|
style="fill:#000000;fill-opacity:0;stroke:none;stroke-width:2.73260474;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill"
|
||||||
|
id="rect2326"
|
||||||
|
width="21.166668"
|
||||||
|
height="10.583334"
|
||||||
|
x="6.9388939e-18"
|
||||||
|
y="286.41666" />
|
||||||
|
<circle
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.15993595;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill"
|
||||||
|
id="path2187"
|
||||||
|
cx="2.6458333"
|
||||||
|
cy="291.70831"
|
||||||
|
r="2.6458333" />
|
||||||
|
<circle
|
||||||
|
cy="291.70831"
|
||||||
|
cx="-10.583333"
|
||||||
|
id="circle2189"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.15993595;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill"
|
||||||
|
transform="scale(-1,1)"
|
||||||
|
r="2.6458333" />
|
||||||
|
<circle
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.15993595;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill"
|
||||||
|
id="circle2191"
|
||||||
|
cx="18.520834"
|
||||||
|
cy="291.70831"
|
||||||
|
r="2.6458333" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.9 KiB |
|
@ -0,0 +1,115 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<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:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="793.70081"
|
||||||
|
height="1122.5197"
|
||||||
|
viewBox="0 0 210 297"
|
||||||
|
version="1.1"
|
||||||
|
id="svg886"
|
||||||
|
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
|
||||||
|
sodipodi:docname="Reblog.svg"
|
||||||
|
enable-background="new">
|
||||||
|
<defs
|
||||||
|
id="defs880">
|
||||||
|
<filter
|
||||||
|
inkscape:collect="always"
|
||||||
|
style="color-interpolation-filters:sRGB"
|
||||||
|
id="filter2010">
|
||||||
|
<feBlend
|
||||||
|
inkscape:collect="always"
|
||||||
|
mode="multiply"
|
||||||
|
in2="BackgroundImage"
|
||||||
|
id="feBlend2012" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="2.12"
|
||||||
|
inkscape:cx="426.6819"
|
||||||
|
inkscape:cy="222.48454"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false"
|
||||||
|
units="px"
|
||||||
|
inkscape:snap-bbox="true"
|
||||||
|
inkscape:bbox-paths="true"
|
||||||
|
inkscape:bbox-nodes="true"
|
||||||
|
inkscape:snap-bbox-edge-midpoints="true"
|
||||||
|
inkscape:snap-bbox-midpoints="true"
|
||||||
|
showguides="true"
|
||||||
|
inkscape:guide-bbox="true"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1395"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="1"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:lockguides="false"
|
||||||
|
inkscape:pagecheckerboard="false">
|
||||||
|
<sodipodi:guide
|
||||||
|
position="100.46678,110.57587"
|
||||||
|
orientation="0,1"
|
||||||
|
id="guide1767"
|
||||||
|
inkscape:locked="false" />
|
||||||
|
<sodipodi:guide
|
||||||
|
position="39.817226,99.415325"
|
||||||
|
orientation="1,0"
|
||||||
|
id="guide1773"
|
||||||
|
inkscape:locked="false" />
|
||||||
|
<sodipodi:guide
|
||||||
|
position="107.95499,89.359277"
|
||||||
|
orientation="0,1"
|
||||||
|
id="guide1987"
|
||||||
|
inkscape:locked="false" />
|
||||||
|
<sodipodi:guide
|
||||||
|
position="79.634452,79.589293"
|
||||||
|
orientation="-0.70563567,-0.70857484"
|
||||||
|
id="guide1999"
|
||||||
|
inkscape:locked="false" />
|
||||||
|
<sodipodi:guide
|
||||||
|
position="70.763562,8.6713087"
|
||||||
|
orientation="0,1"
|
||||||
|
id="guide904"
|
||||||
|
inkscape:locked="false" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<metadata
|
||||||
|
id="metadata883">
|
||||||
|
<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 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
style="opacity:0.92000002;filter:url(#filter2010)">
|
||||||
|
<path
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.54638195;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill"
|
||||||
|
d="m 159.65664,296.99998 39.81721,-39.65184 h -29.05972 v -49.56596 h -0.005 c 0.003,0 0.005,-0.003 0.005,-0.005 V 186.4343 c 0,-0.003 -0.002,-0.005 -0.005,-0.005 h -54.66849 c -8e-4,0 -0.001,-0.003 -0.003,-0.003 H 75.53562 l 21.441585,21.35219 h 18.756475 c 7.9e-4,0 7.9e-4,0.003 0.002,0.003 h 33.16389 v 49.56617 h -29.05973 z"
|
||||||
|
id="path1592"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path1716"
|
||||||
|
d="M 39.817206,177.75866 0,217.4105 h 29.059724 v 49.56596 h 0.0047 c -0.0026,0 -0.0047,0.003 -0.0047,0.005 v 21.34288 c 0,0.003 0.0021,0.005 0.0047,0.005 h 54.668491 c 7.93e-4,0 0.0013,0.003 0.0026,0.003 H 123.93823 L 102.49665,266.97932 H 83.740169 c -7.94e-4,0 -7.94e-4,-0.003 -0.0016,-0.003 H 50.574691 V 217.4105 h 29.059724 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.54638195;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke markers fill" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 4.2 KiB |
|
@ -0,0 +1,78 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<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:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="793.70081"
|
||||||
|
height="1122.5197"
|
||||||
|
viewBox="0 0 210 297"
|
||||||
|
version="1.1"
|
||||||
|
id="svg8"
|
||||||
|
sodipodi:docname="Reply.svg"
|
||||||
|
inkscape:version="0.92.2 5c3e80d, 2017-08-06">
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="powerstroke"
|
||||||
|
id="path-effect821"
|
||||||
|
is_visible="true"
|
||||||
|
offset_points="0,0.13229166"
|
||||||
|
sort_points="true"
|
||||||
|
interpolator_type="CubicBezierJohan"
|
||||||
|
interpolator_beta="0.2"
|
||||||
|
start_linecap_type="zerowidth"
|
||||||
|
linejoin_type="extrp_arc"
|
||||||
|
miter_limit="4"
|
||||||
|
end_linecap_type="zerowidth" />
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="1"
|
||||||
|
inkscape:cx="134.7614"
|
||||||
|
inkscape:cy="56.711221"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false"
|
||||||
|
units="px"
|
||||||
|
inkscape:measure-start="46.669,115.435"
|
||||||
|
inkscape:measure-end="64.166,149.577"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1395"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="1"
|
||||||
|
inkscape:window-maximized="1" />
|
||||||
|
<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 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<path
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0.26310158px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 0.20308372,275.78816 20.85866428,-18.46405 -4.77e-4,14.33185 c 0,0 41.110218,1.57695 31.919391,25.03042 2.746954,-16.26972 -31.82696,-14.6095 -31.82696,-14.6095 l -0.184082,14.75226 z"
|
||||||
|
id="path825"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
sodipodi:nodetypes="ccccccc" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
|
@ -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
|
|
1432
CHANGELOG.md
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 8c42c575cf28b2ff0e780c9728721e9a8891c92e
|
|
@ -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.
|
1
Embassy
|
@ -1 +0,0 @@
|
||||||
Subproject commit 189436100c00efbf5fb2653fe7972a9371db0a91
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// GMAlbumsViewCell.h
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 22/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <UIKit/UIKit.h>
|
||||||
|
#include <Photos/Photos.h>
|
||||||
|
|
||||||
|
@interface GMAlbumsViewCell : UITableViewCell
|
||||||
|
|
||||||
|
@property (strong) PHFetchResult *assetsFetchResults;
|
||||||
|
@property (strong) PHAssetCollection *assetCollection;
|
||||||
|
|
||||||
|
//The labels
|
||||||
|
@property (nonatomic, strong) UILabel *titleLabel;
|
||||||
|
@property (nonatomic, strong) UILabel *infoLabel;
|
||||||
|
//The imageView
|
||||||
|
@property (nonatomic, strong) UIImageView *imageView1;
|
||||||
|
@property (nonatomic, strong) UIImageView *imageView2;
|
||||||
|
@property (nonatomic, strong) UIImageView *imageView3;
|
||||||
|
//Video additional information
|
||||||
|
@property (nonatomic, strong) UIImageView *videoIcon;
|
||||||
|
@property (nonatomic, strong) UIImageView *slowMoIcon;
|
||||||
|
@property (nonatomic, strong) UIView *gradientView;
|
||||||
|
@property (nonatomic, strong) CAGradientLayer *gradient;
|
||||||
|
//Selection overlay
|
||||||
|
|
||||||
|
- (void)setVideoLayout:(BOOL)isVideo;
|
||||||
|
@end
|
|
@ -0,0 +1,131 @@
|
||||||
|
//
|
||||||
|
// GMAlbumsViewCell.m
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 22/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "GMAlbumsViewCell.h"
|
||||||
|
#import "GMAlbumsViewController.h"
|
||||||
|
#import "GMImagePickerController.h"
|
||||||
|
#import <QuartzCore/QuartzCore.h>
|
||||||
|
|
||||||
|
@implementation GMAlbumsViewCell
|
||||||
|
|
||||||
|
- (void)awakeFromNib
|
||||||
|
{
|
||||||
|
[super awakeFromNib];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
|
||||||
|
{
|
||||||
|
|
||||||
|
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier])
|
||||||
|
{
|
||||||
|
// self.isAccessibilityElement = YES;
|
||||||
|
self.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
|
||||||
|
|
||||||
|
// Border width of 1 pixel:
|
||||||
|
float borderWidth = 1.0/[UIScreen mainScreen].scale;
|
||||||
|
|
||||||
|
// ImageView
|
||||||
|
_imageView3 = [UIImageView new];
|
||||||
|
_imageView3.contentMode = UIViewContentModeScaleAspectFill;
|
||||||
|
_imageView3.frame = CGRectMake(kAlbumLeftToImageSpace+4, 8, kAlbumThumbnailSize3.width, kAlbumThumbnailSize3.height );
|
||||||
|
[_imageView3.layer setBorderColor: [[UIColor whiteColor] CGColor]];
|
||||||
|
[_imageView3.layer setBorderWidth: borderWidth];
|
||||||
|
_imageView3.clipsToBounds = YES;
|
||||||
|
_imageView3.translatesAutoresizingMaskIntoConstraints = YES;
|
||||||
|
_imageView3.autoresizingMask = UIViewAutoresizingFlexibleRightMargin;
|
||||||
|
[self.contentView addSubview:_imageView3];
|
||||||
|
|
||||||
|
// ImageView
|
||||||
|
_imageView2 = [UIImageView new];
|
||||||
|
_imageView2.contentMode = UIViewContentModeScaleAspectFill;
|
||||||
|
_imageView2.frame = CGRectMake(kAlbumLeftToImageSpace+2, 8+2, kAlbumThumbnailSize2.width, kAlbumThumbnailSize2.height );
|
||||||
|
[_imageView2.layer setBorderColor: [[UIColor whiteColor] CGColor]];
|
||||||
|
[_imageView2.layer setBorderWidth: borderWidth];
|
||||||
|
_imageView2.clipsToBounds = YES;
|
||||||
|
_imageView2.translatesAutoresizingMaskIntoConstraints = YES;
|
||||||
|
_imageView2.autoresizingMask = UIViewAutoresizingFlexibleRightMargin;
|
||||||
|
[self.contentView addSubview:_imageView2];
|
||||||
|
|
||||||
|
// ImageView
|
||||||
|
_imageView1 = [UIImageView new];
|
||||||
|
_imageView1.contentMode = UIViewContentModeScaleAspectFill;
|
||||||
|
_imageView1.frame = CGRectMake(kAlbumLeftToImageSpace, 8+4, kAlbumThumbnailSize1.width, kAlbumThumbnailSize1.height );
|
||||||
|
[_imageView1.layer setBorderColor: [[UIColor whiteColor] CGColor]];
|
||||||
|
[_imageView1.layer setBorderWidth: borderWidth];
|
||||||
|
_imageView1.clipsToBounds = YES;
|
||||||
|
_imageView1.translatesAutoresizingMaskIntoConstraints = YES;
|
||||||
|
_imageView1.autoresizingMask = UIViewAutoresizingFlexibleRightMargin;
|
||||||
|
[self.contentView addSubview:_imageView1];
|
||||||
|
|
||||||
|
|
||||||
|
// The video gradient, label & icon
|
||||||
|
UIColor *topGradient = [UIColor colorWithRed:0.00 green:0.00 blue:0.00 alpha:0.0];
|
||||||
|
UIColor *midGradient = [UIColor colorWithRed:0.00 green:0.00 blue:0.00 alpha:0.33];
|
||||||
|
UIColor *botGradient = [UIColor colorWithRed:0.00 green:0.00 blue:0.00 alpha:0.75];
|
||||||
|
_gradientView = [[UIView alloc] initWithFrame: CGRectMake(0.0f, kAlbumThumbnailSize1.height-kAlbumGradientHeight, kAlbumThumbnailSize1.width, kAlbumGradientHeight)];
|
||||||
|
_gradient = [CAGradientLayer layer];
|
||||||
|
_gradient.frame = _gradientView.bounds;
|
||||||
|
_gradient.colors = [NSArray arrayWithObjects:(id)[topGradient CGColor], (id)[midGradient CGColor], (id)[botGradient CGColor], nil];
|
||||||
|
_gradient.locations = @[ @0.0f, @0.5f, @1.0f ];
|
||||||
|
[_gradientView.layer insertSublayer:_gradient atIndex:0];
|
||||||
|
_gradientView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin;
|
||||||
|
_gradientView.translatesAutoresizingMaskIntoConstraints = YES;
|
||||||
|
[self.imageView1 addSubview:_gradientView];
|
||||||
|
_gradientView.hidden = YES;
|
||||||
|
|
||||||
|
// VideoIcon
|
||||||
|
_videoIcon = [UIImageView new];
|
||||||
|
_videoIcon.contentMode = UIViewContentModeScaleAspectFill;
|
||||||
|
_videoIcon.frame = CGRectMake(3,kAlbumThumbnailSize1.height - 4 - 8, 15, 8 );
|
||||||
|
_videoIcon.image = [UIImage imageNamed:@"GMVideoIcon" inBundle:[NSBundle bundleForClass:GMAlbumsViewCell.class] compatibleWithTraitCollection:nil];
|
||||||
|
_videoIcon.clipsToBounds = YES;
|
||||||
|
_videoIcon.translatesAutoresizingMaskIntoConstraints = YES;
|
||||||
|
_videoIcon.autoresizingMask = UIViewAutoresizingFlexibleRightMargin;
|
||||||
|
[self.imageView1 addSubview:_videoIcon];
|
||||||
|
_videoIcon.hidden = NO;
|
||||||
|
|
||||||
|
// TextLabel
|
||||||
|
self.textLabel.font = [UIFont fontWithName:@"HelveticaNeue" size:17.0];
|
||||||
|
self.textLabel.numberOfLines = 1;
|
||||||
|
|
||||||
|
self.detailTextLabel.font = [UIFont fontWithName:@"HelveticaNeue" size:14.0];
|
||||||
|
self.detailTextLabel.numberOfLines = 1;
|
||||||
|
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
-(void)layoutSubviews {
|
||||||
|
[super layoutSubviews];
|
||||||
|
self.textLabel.frame = CGRectMake(kAlbumLeftToImageSpace + kAlbumThumbnailSize1.width + kAlbumImageToTextSpace,self.textLabel.frame.origin.y,self.contentView.frame.size.width - kAlbumLeftToImageSpace - kAlbumThumbnailSize1.width - 8, self.textLabel.frame.size.height);
|
||||||
|
self.detailTextLabel.frame = CGRectMake(kAlbumLeftToImageSpace + kAlbumThumbnailSize1.width + kAlbumImageToTextSpace,self.detailTextLabel.frame.origin.y,self.contentView.frame.size.width - kAlbumLeftToImageSpace - kAlbumThumbnailSize1.width - 8 - kAlbumImageToTextSpace, self.detailTextLabel.frame.size.height);
|
||||||
|
|
||||||
|
}
|
||||||
|
- (void)setVideoLayout:(BOOL)isVideo
|
||||||
|
{
|
||||||
|
// TODO : Add additional icons for slowmo, burst, etc...
|
||||||
|
if (isVideo) {
|
||||||
|
_videoIcon.hidden = NO;
|
||||||
|
_gradientView.hidden = NO;
|
||||||
|
} else {
|
||||||
|
_videoIcon.hidden = YES;
|
||||||
|
_gradientView.hidden = YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
- (void)setSelected:(BOOL)selected animated:(BOOL)animated
|
||||||
|
{
|
||||||
|
[super setSelected:selected animated:animated];
|
||||||
|
|
||||||
|
// Configure the view for the selected state
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// GMAlbumsViewController.h
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 19/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
// Measuring IOS8 Photos APP at @2x (iPhone5s):
|
||||||
|
// The rows are 180px/90pts
|
||||||
|
// Left image border is 21px/10.5pts
|
||||||
|
// Separation between image and text is 42px/21pts (double the previouse one)
|
||||||
|
// The bigger image measures 139px/69.5pts including 1px/0.5pts white border.
|
||||||
|
// The second image measures 131px/65.6pts including 1px/0.5pts white border. Only 3px/1.5pts visible
|
||||||
|
// The third image measures 123px/61.5pts including 1px/0.5pts white border. Only 3px/1.5pts visible
|
||||||
|
|
||||||
|
static int kAlbumRowHeight = 90;
|
||||||
|
static int kAlbumLeftToImageSpace = 10;
|
||||||
|
static int kAlbumImageToTextSpace = 21;
|
||||||
|
static float const kAlbumGradientHeight = 20.0f;
|
||||||
|
static CGSize const kAlbumThumbnailSize1 = {70.0f , 70.0f};
|
||||||
|
static CGSize const kAlbumThumbnailSize2 = {66.0f , 66.0f};
|
||||||
|
static CGSize const kAlbumThumbnailSize3 = {62.0f , 62.0f};
|
||||||
|
|
||||||
|
|
||||||
|
@interface GMAlbumsViewController : UITableViewController
|
||||||
|
|
||||||
|
- (void)selectAllAlbumsCell;
|
||||||
|
|
||||||
|
@end
|
|
@ -0,0 +1,416 @@
|
||||||
|
//
|
||||||
|
// GMAlbumsViewController.m
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 19/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "GMImagePickerController.h"
|
||||||
|
#import "GMAlbumsViewController.h"
|
||||||
|
#import "GMGridViewCell.h"
|
||||||
|
#import "GMGridViewController.h"
|
||||||
|
#import "GMAlbumsViewCell.h"
|
||||||
|
|
||||||
|
#include <Photos/Photos.h>
|
||||||
|
|
||||||
|
@interface GMAlbumsViewController() <PHPhotoLibraryChangeObserver>
|
||||||
|
|
||||||
|
@property (strong,nonatomic) NSArray *collectionsFetchResults;
|
||||||
|
@property (strong,nonatomic) NSArray *collectionsLocalizedTitles;
|
||||||
|
@property (strong,nonatomic) NSArray *collectionsFetchResultsAssets;
|
||||||
|
@property (strong,nonatomic) NSArray *collectionsFetchResultsTitles;
|
||||||
|
@property (nonatomic, weak) GMImagePickerController *picker;
|
||||||
|
@property (strong,nonatomic) PHCachingImageManager *imageManager;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
@implementation GMAlbumsViewController
|
||||||
|
|
||||||
|
- (id)init
|
||||||
|
{
|
||||||
|
if (self = [super initWithStyle:UITableViewStylePlain]) {
|
||||||
|
self.preferredContentSize = kPopoverContentSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString *const AllPhotosReuseIdentifier = @"AllPhotosCell";
|
||||||
|
static NSString *const CollectionCellReuseIdentifier = @"CollectionCell";
|
||||||
|
|
||||||
|
- (void)viewDidLoad
|
||||||
|
{
|
||||||
|
[super viewDidLoad];
|
||||||
|
|
||||||
|
self.view.backgroundColor = [self.picker pickerBackgroundColor];
|
||||||
|
|
||||||
|
// Navigation bar customization
|
||||||
|
if (self.picker.customNavigationBarPrompt) {
|
||||||
|
self.navigationItem.prompt = self.picker.customNavigationBarPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.imageManager = [[PHCachingImageManager alloc] init];
|
||||||
|
|
||||||
|
// Table view aspect
|
||||||
|
self.tableView.rowHeight = kAlbumRowHeight;
|
||||||
|
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
NSDictionary *barButtonItemAttributes = @{NSFontAttributeName: [UIFont fontWithName:self.picker.pickerFontName size:self.picker.pickerFontHeaderSize]};
|
||||||
|
|
||||||
|
NSString *cancelTitle = self.picker.customCancelButtonTitle ? self.picker.customCancelButtonTitle : NSLocalizedStringFromTableInBundle(@"picker.navigation.cancel-button", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Cancel");
|
||||||
|
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:cancelTitle
|
||||||
|
style:UIBarButtonItemStylePlain
|
||||||
|
target:self.picker
|
||||||
|
action:@selector(dismiss:)];
|
||||||
|
if (self.picker.useCustomFontForNavigationBar) {
|
||||||
|
[self.navigationItem.leftBarButtonItem setTitleTextAttributes:barButtonItemAttributes forState:UIControlStateNormal];
|
||||||
|
[self.navigationItem.leftBarButtonItem setTitleTextAttributes:barButtonItemAttributes forState:UIControlStateSelected];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.picker.allowsMultipleSelection) {
|
||||||
|
NSString *doneTitle = self.picker.customDoneButtonTitle ? self.picker.customDoneButtonTitle : NSLocalizedStringFromTableInBundle(@"picker.navigation.done-button", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Done");
|
||||||
|
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:doneTitle
|
||||||
|
style:UIBarButtonItemStyleDone
|
||||||
|
target:self.picker
|
||||||
|
action:@selector(finishPickingAssets:)];
|
||||||
|
if (self.picker.useCustomFontForNavigationBar) {
|
||||||
|
[self.navigationItem.rightBarButtonItem setTitleTextAttributes:barButtonItemAttributes forState:UIControlStateNormal];
|
||||||
|
[self.navigationItem.rightBarButtonItem setTitleTextAttributes:barButtonItemAttributes forState:UIControlStateSelected];
|
||||||
|
}
|
||||||
|
|
||||||
|
self.navigationItem.rightBarButtonItem.enabled = (self.picker.autoDisableDoneButton ? self.picker.selectedAssets.count > 0 : TRUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom toolbar
|
||||||
|
self.toolbarItems = self.picker.toolbarItems;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
if (!self.picker.title) {
|
||||||
|
self.title = NSLocalizedStringFromTableInBundle(@"picker.navigation.title", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Navigation bar default title");
|
||||||
|
} else {
|
||||||
|
self.title = self.picker.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch PHAssetCollections:
|
||||||
|
PHFetchResult *topLevelUserCollections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil];
|
||||||
|
PHFetchResult *smartAlbums = [PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeSmartAlbum subtype:PHAssetCollectionSubtypeAlbumRegular options:nil];
|
||||||
|
self.collectionsFetchResults = @[topLevelUserCollections, smartAlbums];
|
||||||
|
self.collectionsLocalizedTitles = @[NSLocalizedStringFromTableInBundle(@"picker.table.smart-albums-header", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Smart Albums"),NSLocalizedStringFromTableInBundle(@"picker.table.user-albums-header", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Albums")];
|
||||||
|
|
||||||
|
[self updateFetchResults];
|
||||||
|
|
||||||
|
// Register for changes
|
||||||
|
[[PHPhotoLibrary sharedPhotoLibrary] registerChangeObserver:self];
|
||||||
|
|
||||||
|
if ([self respondsToSelector:@selector(setEdgesForExtendedLayout:)])
|
||||||
|
{
|
||||||
|
self.edgesForExtendedLayout = UIRectEdgeNone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc
|
||||||
|
{
|
||||||
|
[[PHPhotoLibrary sharedPhotoLibrary] unregisterChangeObserver:self];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIStatusBarStyle)preferredStatusBarStyle {
|
||||||
|
return self.picker.pickerStatusBarStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)selectAllAlbumsCell {
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
|
||||||
|
[self tableView:self.tableView didSelectRowAtIndexPath:indexPath];
|
||||||
|
}
|
||||||
|
|
||||||
|
-(void)updateFetchResults
|
||||||
|
{
|
||||||
|
//What I do here is fetch both the albums list and the assets of each album.
|
||||||
|
//This way I have acces to the number of items in each album, I can load the 3
|
||||||
|
//thumbnails directly and I can pass the fetched result to the gridViewController.
|
||||||
|
|
||||||
|
self.collectionsFetchResultsAssets=nil;
|
||||||
|
self.collectionsFetchResultsTitles=nil;
|
||||||
|
|
||||||
|
//Fetch PHAssetCollections:
|
||||||
|
PHFetchResult *topLevelUserCollections = [self.collectionsFetchResults objectAtIndex:0];
|
||||||
|
PHFetchResult *smartAlbums = [self.collectionsFetchResults objectAtIndex:1];
|
||||||
|
|
||||||
|
//All album: Sorted by descending creation date.
|
||||||
|
NSMutableArray *allFetchResultArray = [[NSMutableArray alloc] init];
|
||||||
|
NSMutableArray *allFetchResultLabel = [[NSMutableArray alloc] init];
|
||||||
|
{
|
||||||
|
PHFetchOptions *options = [[PHFetchOptions alloc] init];
|
||||||
|
options.predicate = [NSPredicate predicateWithFormat:@"mediaType in %@", self.picker.mediaTypes];
|
||||||
|
options.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]];
|
||||||
|
PHFetchResult *assetsFetchResult = [PHAsset fetchAssetsWithOptions:options];
|
||||||
|
[allFetchResultArray addObject:assetsFetchResult];
|
||||||
|
[allFetchResultLabel addObject:NSLocalizedStringFromTableInBundle(@"picker.table.all-photos-label", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"All photos")];
|
||||||
|
}
|
||||||
|
|
||||||
|
//User albums:
|
||||||
|
NSMutableArray *userFetchResultArray = [[NSMutableArray alloc] init];
|
||||||
|
NSMutableArray *userFetchResultLabel = [[NSMutableArray alloc] init];
|
||||||
|
for(PHCollection *collection in topLevelUserCollections)
|
||||||
|
{
|
||||||
|
if ([collection isKindOfClass:[PHAssetCollection class]])
|
||||||
|
{
|
||||||
|
PHFetchOptions *options = [[PHFetchOptions alloc] init];
|
||||||
|
options.predicate = [NSPredicate predicateWithFormat:@"mediaType in %@", self.picker.mediaTypes];
|
||||||
|
PHAssetCollection *assetCollection = (PHAssetCollection *)collection;
|
||||||
|
|
||||||
|
//Albums collections are allways PHAssetCollectionType=1 & PHAssetCollectionSubtype=2
|
||||||
|
|
||||||
|
PHFetchResult *assetsFetchResult = [PHAsset fetchAssetsInAssetCollection:assetCollection options:options];
|
||||||
|
[userFetchResultArray addObject:assetsFetchResult];
|
||||||
|
[userFetchResultLabel addObject:collection.localizedTitle];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//Smart albums: Sorted by descending creation date.
|
||||||
|
NSMutableArray *smartFetchResultArray = [[NSMutableArray alloc] init];
|
||||||
|
NSMutableArray *smartFetchResultLabel = [[NSMutableArray alloc] init];
|
||||||
|
for(PHCollection *collection in smartAlbums)
|
||||||
|
{
|
||||||
|
if ([collection isKindOfClass:[PHAssetCollection class]])
|
||||||
|
{
|
||||||
|
PHAssetCollection *assetCollection = (PHAssetCollection *)collection;
|
||||||
|
|
||||||
|
//Smart collections are PHAssetCollectionType=2;
|
||||||
|
if(self.picker.customSmartCollections && [self.picker.customSmartCollections containsObject:@(assetCollection.assetCollectionSubtype)])
|
||||||
|
{
|
||||||
|
PHFetchOptions *options = [[PHFetchOptions alloc] init];
|
||||||
|
options.predicate = [NSPredicate predicateWithFormat:@"mediaType in %@", self.picker.mediaTypes];
|
||||||
|
options.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]];
|
||||||
|
|
||||||
|
PHFetchResult *assetsFetchResult = [PHAsset fetchAssetsInAssetCollection:assetCollection options:options];
|
||||||
|
if(assetsFetchResult.count>0)
|
||||||
|
{
|
||||||
|
[smartFetchResultArray addObject:assetsFetchResult];
|
||||||
|
[smartFetchResultLabel addObject:collection.localizedTitle];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.collectionsFetchResultsAssets= @[allFetchResultArray,smartFetchResultArray,userFetchResultArray];
|
||||||
|
self.collectionsFetchResultsTitles= @[allFetchResultLabel,smartFetchResultLabel,userFetchResultLabel];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Accessors
|
||||||
|
|
||||||
|
- (GMImagePickerController *)picker
|
||||||
|
{
|
||||||
|
return (GMImagePickerController *)self.navigationController.parentViewController;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Rotation
|
||||||
|
|
||||||
|
- (BOOL)shouldAutorotate
|
||||||
|
{
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIInterfaceOrientationMask)supportedInterfaceOrientations
|
||||||
|
{
|
||||||
|
return UIInterfaceOrientationMaskAllButUpsideDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - UITableViewDataSource
|
||||||
|
|
||||||
|
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
|
||||||
|
{
|
||||||
|
return (NSInteger)self.collectionsFetchResultsAssets.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
||||||
|
{
|
||||||
|
PHFetchResult *fetchResult = self.collectionsFetchResultsAssets[(NSUInteger)section];
|
||||||
|
return (NSInteger)fetchResult.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
static NSString *CellIdentifier = @"Cell";
|
||||||
|
|
||||||
|
GMAlbumsViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
|
||||||
|
if (cell == nil) {
|
||||||
|
cell = [[GMAlbumsViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:CellIdentifier];
|
||||||
|
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment the cell's tag
|
||||||
|
NSInteger currentTag = cell.tag + 1;
|
||||||
|
cell.tag = currentTag;
|
||||||
|
|
||||||
|
// Set the label
|
||||||
|
cell.textLabel.font = [UIFont fontWithName:self.picker.pickerFontName size:self.picker.pickerFontHeaderSize];
|
||||||
|
cell.textLabel.text = (self.collectionsFetchResultsTitles[(NSUInteger)indexPath.section])[(NSUInteger)indexPath.row];
|
||||||
|
cell.textLabel.textColor = self.picker.pickerTextColor;
|
||||||
|
|
||||||
|
// Retrieve the pre-fetched assets for this album:
|
||||||
|
PHFetchResult *assetsFetchResult = (self.collectionsFetchResultsAssets[(NSUInteger)indexPath.section])[(NSUInteger)indexPath.row];
|
||||||
|
|
||||||
|
// Display the number of assets
|
||||||
|
if (self.picker.displayAlbumsNumberOfAssets) {
|
||||||
|
cell.detailTextLabel.font = [UIFont fontWithName:self.picker.pickerFontName size:self.picker.pickerFontNormalSize];
|
||||||
|
cell.detailTextLabel.text = [self tableCellSubtitle:assetsFetchResult];
|
||||||
|
cell.detailTextLabel.textColor = self.picker.pickerTextColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the 3 images (if exists):
|
||||||
|
if ([assetsFetchResult count] > 0) {
|
||||||
|
CGFloat scale = [UIScreen mainScreen].scale;
|
||||||
|
|
||||||
|
//Compute the thumbnail pixel size:
|
||||||
|
CGSize tableCellThumbnailSize1 = CGSizeMake(kAlbumThumbnailSize1.width*scale, kAlbumThumbnailSize1.height*scale);
|
||||||
|
PHAsset *asset = assetsFetchResult[0];
|
||||||
|
[cell setVideoLayout:(asset.mediaType==PHAssetMediaTypeVideo)];
|
||||||
|
[self.imageManager requestImageForAsset:asset
|
||||||
|
targetSize:tableCellThumbnailSize1
|
||||||
|
contentMode:PHImageContentModeAspectFill
|
||||||
|
options:nil
|
||||||
|
resultHandler:^(UIImage *result, NSDictionary *info) {
|
||||||
|
if (cell.tag == currentTag) {
|
||||||
|
cell.imageView1.image = result;
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Second & third images:
|
||||||
|
// TODO: Only preload the 3pixels height visible frame!
|
||||||
|
if ([assetsFetchResult count] > 1) {
|
||||||
|
//Compute the thumbnail pixel size:
|
||||||
|
CGSize tableCellThumbnailSize2 = CGSizeMake(kAlbumThumbnailSize2.width*scale, kAlbumThumbnailSize2.height*scale);
|
||||||
|
PHAsset *asset = assetsFetchResult[1];
|
||||||
|
[self.imageManager requestImageForAsset:asset
|
||||||
|
targetSize:tableCellThumbnailSize2
|
||||||
|
contentMode:PHImageContentModeAspectFill
|
||||||
|
options:nil
|
||||||
|
resultHandler:^(UIImage *result, NSDictionary *info) {
|
||||||
|
if (cell.tag == currentTag) {
|
||||||
|
cell.imageView2.image = result;
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
cell.imageView2.image = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([assetsFetchResult count] > 2) {
|
||||||
|
CGSize tableCellThumbnailSize3 = CGSizeMake(kAlbumThumbnailSize3.width*scale, kAlbumThumbnailSize3.height*scale);
|
||||||
|
PHAsset *asset = assetsFetchResult[2];
|
||||||
|
[self.imageManager requestImageForAsset:asset
|
||||||
|
targetSize:tableCellThumbnailSize3
|
||||||
|
contentMode:PHImageContentModeAspectFill
|
||||||
|
options:nil
|
||||||
|
resultHandler:^(UIImage *result, NSDictionary *info) {
|
||||||
|
if (cell.tag == currentTag) {
|
||||||
|
cell.imageView3.image = result;
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
cell.imageView3.image = nil;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
[cell setVideoLayout:NO];
|
||||||
|
cell.imageView3.image = [UIImage imageNamed:@"GMEmptyFolder"];
|
||||||
|
cell.imageView2.image = [UIImage imageNamed:@"GMEmptyFolder"];
|
||||||
|
cell.imageView1.image = [UIImage imageNamed:@"GMEmptyFolder"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - UITableViewDelegate
|
||||||
|
|
||||||
|
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
|
||||||
|
|
||||||
|
// Init the GMGridViewController
|
||||||
|
GMGridViewController *gridViewController = [[GMGridViewController alloc] initWithPicker:[self picker]];
|
||||||
|
// Set the title
|
||||||
|
gridViewController.title = cell.textLabel.text;
|
||||||
|
// Use the prefetched assets!
|
||||||
|
gridViewController.assetsFetchResults = [[_collectionsFetchResultsAssets objectAtIndex:(NSUInteger)indexPath.section] objectAtIndex:(NSUInteger)indexPath.row];
|
||||||
|
|
||||||
|
// Remove selection so it looks better on slide in
|
||||||
|
[tableView deselectRowAtIndexPath:indexPath animated:true];
|
||||||
|
|
||||||
|
// Push GMGridViewController
|
||||||
|
[self.navigationController pushViewController:gridViewController animated:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
-(void)tableView:(UITableView *)tableView willDisplayHeaderView:(UIView *)view forSection:(NSInteger)section
|
||||||
|
{
|
||||||
|
UITableViewHeaderFooterView *header = (UITableViewHeaderFooterView *)view;
|
||||||
|
// header.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
// header.backgroundView.backgroundColor = [UIColor clearColor];
|
||||||
|
|
||||||
|
// Default is a bold font, but keep this styled as a normal font
|
||||||
|
header.textLabel.font = [UIFont fontWithName:self.picker.pickerFontName size:self.picker.pickerFontNormalSize];
|
||||||
|
header.textLabel.textColor = self.picker.pickerTextColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
|
||||||
|
{
|
||||||
|
//Tip: Returning nil hides the section header!
|
||||||
|
|
||||||
|
NSString *title = nil;
|
||||||
|
if (section > 0) {
|
||||||
|
// Only show title for non-empty sections:
|
||||||
|
PHFetchResult *fetchResult = self.collectionsFetchResultsAssets[(NSUInteger)section];
|
||||||
|
if (fetchResult.count > 0) {
|
||||||
|
title = self.collectionsLocalizedTitles[(NSUInteger)(section - 1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - PHPhotoLibraryChangeObserver
|
||||||
|
|
||||||
|
- (void)photoLibraryDidChange:(PHChange *)changeInstance
|
||||||
|
{
|
||||||
|
// Call might come on any background queue. Re-dispatch to the main queue to handle it.
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
|
||||||
|
NSMutableArray *updatedCollectionsFetchResults = nil;
|
||||||
|
|
||||||
|
for (PHFetchResult *collectionsFetchResult in self.collectionsFetchResults) {
|
||||||
|
PHFetchResultChangeDetails *changeDetails = [changeInstance changeDetailsForFetchResult:collectionsFetchResult];
|
||||||
|
if (changeDetails) {
|
||||||
|
if (!updatedCollectionsFetchResults) {
|
||||||
|
updatedCollectionsFetchResults = [self.collectionsFetchResults mutableCopy];
|
||||||
|
}
|
||||||
|
[updatedCollectionsFetchResults replaceObjectAtIndex:[self.collectionsFetchResults indexOfObject:collectionsFetchResult] withObject:[changeDetails fetchResultAfterChanges]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This only affects to changes in albums level (add/remove/edit album)
|
||||||
|
if (updatedCollectionsFetchResults) {
|
||||||
|
self.collectionsFetchResults = updatedCollectionsFetchResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// However, we want to update if photos are added, so the counts of items & thumbnails are updated too.
|
||||||
|
// Maybe some checks could be done here , but for now is OKey.
|
||||||
|
[self updateFetchResults];
|
||||||
|
[self.tableView reloadData];
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Cell Subtitle
|
||||||
|
|
||||||
|
- (NSString *)tableCellSubtitle:(PHFetchResult*)assetsFetchResult
|
||||||
|
{
|
||||||
|
// Just return the number of assets. Album app does this:
|
||||||
|
return [NSString stringWithFormat:@"%ld", (long)[assetsFetchResult count]];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 3.0 KiB |
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// GMGridViewCell.h
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 19/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <UIKit/UIKit.h>
|
||||||
|
#include <Photos/Photos.h>
|
||||||
|
|
||||||
|
|
||||||
|
@interface GMGridViewCell : UICollectionViewCell
|
||||||
|
|
||||||
|
@property (nonatomic, strong) PHAsset *asset;
|
||||||
|
//The imageView
|
||||||
|
@property (nonatomic, strong) UIImageView *imageView;
|
||||||
|
//Video additional information
|
||||||
|
@property (nonatomic, strong) UIImageView *videoIcon;
|
||||||
|
@property (nonatomic, strong) UILabel *videoDuration;
|
||||||
|
@property (nonatomic, strong) UIView *gradientView;
|
||||||
|
@property (nonatomic, strong) CAGradientLayer *gradient;
|
||||||
|
//Selection overlay
|
||||||
|
@property (nonatomic) BOOL shouldShowSelection;
|
||||||
|
@property (nonatomic, strong) UIView *coverView;
|
||||||
|
@property (nonatomic, strong) UIButton *selectedButton;
|
||||||
|
|
||||||
|
@property (nonatomic, assign, getter = isEnabled) BOOL enabled;
|
||||||
|
|
||||||
|
- (void)bind:(PHAsset *)asset;
|
||||||
|
|
||||||
|
@end
|
|
@ -0,0 +1,176 @@
|
||||||
|
//
|
||||||
|
// GMGridViewCell.m
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 19/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "GMGridViewCell.h"
|
||||||
|
|
||||||
|
|
||||||
|
@interface GMGridViewCell ()
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
@implementation GMGridViewCell
|
||||||
|
|
||||||
|
static UIFont *titleFont;
|
||||||
|
static CGFloat titleHeight;
|
||||||
|
static UIImage *videoIcon;
|
||||||
|
static UIColor *titleColor;
|
||||||
|
static UIImage *checkedIcon;
|
||||||
|
static UIColor *selectedColor;
|
||||||
|
static UIColor *disabledColor;
|
||||||
|
|
||||||
|
+ (void)initialize
|
||||||
|
{
|
||||||
|
titleFont = [UIFont systemFontOfSize:12];
|
||||||
|
titleHeight = 20.0f;
|
||||||
|
videoIcon = [UIImage imageNamed:@"GMImagePickerVideo"];
|
||||||
|
titleColor = [UIColor whiteColor];
|
||||||
|
checkedIcon = [UIImage imageNamed:@"CTAssetsPickerChecked"];
|
||||||
|
selectedColor = [UIColor colorWithWhite:1 alpha:0.3];
|
||||||
|
disabledColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)awakeFromNib
|
||||||
|
{
|
||||||
|
[super awakeFromNib];
|
||||||
|
|
||||||
|
self.contentView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
|
||||||
|
self.contentView.translatesAutoresizingMaskIntoConstraints = YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (id)initWithFrame:(CGRect)frame
|
||||||
|
{
|
||||||
|
if (self = [super initWithFrame:frame]) {
|
||||||
|
self.opaque = NO;
|
||||||
|
self.enabled = YES;
|
||||||
|
|
||||||
|
CGFloat cellSize = self.contentView.bounds.size.width;
|
||||||
|
|
||||||
|
// The image view
|
||||||
|
_imageView = [UIImageView new];
|
||||||
|
_imageView.frame = CGRectMake(0, 0, cellSize, cellSize);
|
||||||
|
_imageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||||
|
/*if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad)
|
||||||
|
{
|
||||||
|
_imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_imageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||||
|
}*/
|
||||||
|
_imageView.clipsToBounds = YES;
|
||||||
|
_imageView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
_imageView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
|
||||||
|
[self addSubview:_imageView];
|
||||||
|
|
||||||
|
|
||||||
|
// The video gradient, label & icon
|
||||||
|
float x_offset = 4.0f;
|
||||||
|
UIColor *topGradient = [UIColor colorWithRed:0.00 green:0.00 blue:0.00 alpha:0.0];
|
||||||
|
UIColor *botGradient = [UIColor colorWithRed:0.00 green:0.00 blue:0.00 alpha:0.8];
|
||||||
|
_gradientView = [[UIView alloc] initWithFrame: CGRectMake(0.0f, self.bounds.size.height-titleHeight, self.bounds.size.width, titleHeight)];
|
||||||
|
_gradient = [CAGradientLayer layer];
|
||||||
|
_gradient.frame = _gradientView.bounds;
|
||||||
|
_gradient.colors = [NSArray arrayWithObjects:(id)[topGradient CGColor], (id)[botGradient CGColor], nil];
|
||||||
|
[_gradientView.layer insertSublayer:_gradient atIndex:0];
|
||||||
|
_gradientView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin;
|
||||||
|
_gradientView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
[self addSubview:_gradientView];
|
||||||
|
_gradientView.hidden = YES;
|
||||||
|
|
||||||
|
_videoIcon = [UIImageView new];
|
||||||
|
_videoIcon.frame = CGRectMake(x_offset, self.bounds.size.height-titleHeight, self.bounds.size.width-2*x_offset, titleHeight);
|
||||||
|
_videoIcon.contentMode = UIViewContentModeLeft;
|
||||||
|
_videoIcon.image = [UIImage imageNamed:@"GMVideoIcon" inBundle:[NSBundle bundleForClass:GMGridViewCell.class] compatibleWithTraitCollection:nil];
|
||||||
|
_videoIcon.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
_videoIcon.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleWidth;
|
||||||
|
[self addSubview:_videoIcon];
|
||||||
|
_videoIcon.hidden = YES;
|
||||||
|
|
||||||
|
_videoDuration = [UILabel new];
|
||||||
|
_videoDuration.font = titleFont;
|
||||||
|
_videoDuration.textColor = titleColor;
|
||||||
|
_videoDuration.textAlignment = NSTextAlignmentRight;
|
||||||
|
_videoDuration.frame = CGRectMake(x_offset, self.bounds.size.height-titleHeight, self.bounds.size.width-2*x_offset, titleHeight);
|
||||||
|
_videoDuration.contentMode = UIViewContentModeRight;
|
||||||
|
_videoDuration.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
_videoDuration.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleWidth;
|
||||||
|
[self addSubview:_videoDuration];
|
||||||
|
_videoDuration.hidden = YES;
|
||||||
|
|
||||||
|
// Selection overlay & icon
|
||||||
|
_coverView = [[UIView alloc] initWithFrame:self.bounds];
|
||||||
|
_coverView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
_coverView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
|
||||||
|
_coverView.backgroundColor = [UIColor colorWithRed:0.24 green:0.47 blue:0.85 alpha:0.6];
|
||||||
|
[self addSubview:_coverView];
|
||||||
|
_coverView.hidden = YES;
|
||||||
|
|
||||||
|
_selectedButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
|
_selectedButton.frame = CGRectMake(2*self.bounds.size.width/3, 0*self.bounds.size.width/3, self.bounds.size.width/3, self.bounds.size.width/3);
|
||||||
|
_selectedButton.contentMode = UIViewContentModeTopRight;
|
||||||
|
_selectedButton.adjustsImageWhenHighlighted = NO;
|
||||||
|
[_selectedButton setImage:nil forState:UIControlStateNormal];
|
||||||
|
_selectedButton.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
_selectedButton.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
|
||||||
|
[_selectedButton setImage:[UIImage imageNamed:@"GMSelected" inBundle:[NSBundle bundleForClass:GMGridViewCell.class] compatibleWithTraitCollection:nil] forState:UIControlStateSelected];
|
||||||
|
_selectedButton.hidden = NO;
|
||||||
|
_selectedButton.userInteractionEnabled = NO;
|
||||||
|
[self addSubview:_selectedButton];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: the views above are created in case this is toggled per cell, on the fly, etc.!
|
||||||
|
self.shouldShowSelection = YES;
|
||||||
|
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required to resize the CAGradientLayer because it does not support auto resizing.
|
||||||
|
- (void)layoutSubviews {
|
||||||
|
[super layoutSubviews];
|
||||||
|
_gradient.frame = _gradientView.bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)bind:(PHAsset *)asset
|
||||||
|
{
|
||||||
|
self.asset = asset;
|
||||||
|
|
||||||
|
if (self.asset.mediaType == PHAssetMediaTypeVideo) {
|
||||||
|
_videoIcon.hidden = NO;
|
||||||
|
_videoDuration.hidden = NO;
|
||||||
|
_gradientView.hidden = NO;
|
||||||
|
_videoDuration.text = [self getDurationWithFormat:self.asset.duration];
|
||||||
|
} else {
|
||||||
|
_videoIcon.hidden = YES;
|
||||||
|
_videoDuration.hidden = YES;
|
||||||
|
_gradientView.hidden = YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override setSelected
|
||||||
|
- (void)setSelected:(BOOL)selected
|
||||||
|
{
|
||||||
|
[super setSelected:selected];
|
||||||
|
|
||||||
|
if (!self.shouldShowSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_coverView.hidden = !selected;
|
||||||
|
_selectedButton.selected = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
-(NSString*)getDurationWithFormat:(NSTimeInterval)duration
|
||||||
|
{
|
||||||
|
NSInteger ti = (NSInteger)duration;
|
||||||
|
NSInteger seconds = ti % 60;
|
||||||
|
NSInteger minutes = (ti / 60) % 60;
|
||||||
|
//NSInteger hours = (ti / 3600);
|
||||||
|
return [NSString stringWithFormat:@"%02ld:%02ld", (long)minutes, (long)seconds];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
|
@ -0,0 +1,21 @@
|
||||||
|
//
|
||||||
|
// GMGridViewController.h
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 19/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
#import "GMImagePickerController.h"
|
||||||
|
#include <UIKit/UIKit.h>
|
||||||
|
#include <Photos/Photos.h>
|
||||||
|
|
||||||
|
|
||||||
|
@interface GMGridViewController : UICollectionViewController
|
||||||
|
|
||||||
|
@property (strong,nonatomic) PHFetchResult *assetsFetchResults;
|
||||||
|
|
||||||
|
-(id)initWithPicker:(GMImagePickerController *)picker;
|
||||||
|
|
||||||
|
@end
|
|
@ -0,0 +1,611 @@
|
||||||
|
//
|
||||||
|
// GMGridViewController.m
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 19/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "GMGridViewController.h"
|
||||||
|
#import "GMImagePickerController.h"
|
||||||
|
#import "GMAlbumsViewController.h"
|
||||||
|
#import "GMGridViewCell.h"
|
||||||
|
|
||||||
|
#include <Photos/Photos.h>
|
||||||
|
|
||||||
|
|
||||||
|
//Helper methods
|
||||||
|
@implementation NSIndexSet (Convenience)
|
||||||
|
- (NSArray *)aapl_indexPathsFromIndexesWithSection:(NSUInteger)section {
|
||||||
|
NSMutableArray *indexPaths = [NSMutableArray arrayWithCapacity:self.count];
|
||||||
|
[self enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) {
|
||||||
|
[indexPaths addObject:[NSIndexPath indexPathForItem:(NSInteger)idx inSection:(NSInteger)section]];
|
||||||
|
}];
|
||||||
|
return indexPaths;
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation UICollectionView (Convenience)
|
||||||
|
- (NSArray *)aapl_indexPathsForElementsInRect:(CGRect)rect {
|
||||||
|
NSArray *allLayoutAttributes = [self.collectionViewLayout layoutAttributesForElementsInRect:rect];
|
||||||
|
if (allLayoutAttributes.count == 0) { return nil; }
|
||||||
|
NSMutableArray *indexPaths = [NSMutableArray arrayWithCapacity:allLayoutAttributes.count];
|
||||||
|
for (UICollectionViewLayoutAttributes *layoutAttributes in allLayoutAttributes) {
|
||||||
|
NSIndexPath *indexPath = layoutAttributes.indexPath;
|
||||||
|
[indexPaths addObject:indexPath];
|
||||||
|
}
|
||||||
|
return indexPaths;
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@interface GMImagePickerController ()
|
||||||
|
|
||||||
|
- (void)finishPickingAssets:(id)sender;
|
||||||
|
- (void)dismiss:(id)sender;
|
||||||
|
- (NSString *)toolbarTitle;
|
||||||
|
- (UIView *)noAssetsView;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
@interface GMGridViewController () <PHPhotoLibraryChangeObserver>
|
||||||
|
|
||||||
|
@property (nonatomic, weak) GMImagePickerController *picker;
|
||||||
|
@property (strong,nonatomic) PHCachingImageManager *imageManager;
|
||||||
|
@property (assign, nonatomic) CGRect previousPreheatRect;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
static CGSize AssetGridThumbnailSize;
|
||||||
|
NSString * const GMGridViewCellIdentifier = @"GMGridViewCellIdentifier";
|
||||||
|
|
||||||
|
@implementation GMGridViewController
|
||||||
|
{
|
||||||
|
CGFloat screenWidth;
|
||||||
|
CGFloat screenHeight;
|
||||||
|
UICollectionViewFlowLayout *portraitLayout;
|
||||||
|
UICollectionViewFlowLayout *landscapeLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
-(id)initWithPicker:(GMImagePickerController *)picker
|
||||||
|
{
|
||||||
|
//Custom init. The picker contains custom information to create the FlowLayout
|
||||||
|
self.picker = picker;
|
||||||
|
|
||||||
|
//Ipad popover is not affected by rotation!
|
||||||
|
if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad)
|
||||||
|
{
|
||||||
|
screenWidth = CGRectGetWidth(picker.view.bounds);
|
||||||
|
screenHeight = CGRectGetHeight(picker.view.bounds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if(UIInterfaceOrientationIsLandscape([UIApplication sharedApplication].statusBarOrientation))
|
||||||
|
{
|
||||||
|
screenHeight = CGRectGetWidth(picker.view.bounds);
|
||||||
|
screenWidth = CGRectGetHeight(picker.view.bounds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
screenWidth = CGRectGetWidth(picker.view.bounds);
|
||||||
|
screenHeight = CGRectGetHeight(picker.view.bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
UICollectionViewFlowLayout *layout = [self collectionViewFlowLayoutForOrientation:[UIApplication sharedApplication].statusBarOrientation];
|
||||||
|
if (self = [super initWithCollectionViewLayout:layout])
|
||||||
|
{
|
||||||
|
//Compute the thumbnail pixel size:
|
||||||
|
CGFloat scale = [UIScreen mainScreen].scale;
|
||||||
|
//NSLog(@"This is @%fx scale device", scale);
|
||||||
|
if(scale >= 3)
|
||||||
|
{
|
||||||
|
scale = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetGridThumbnailSize = CGSizeMake(layout.itemSize.width * scale, layout.itemSize.height * scale);
|
||||||
|
|
||||||
|
self.collectionView.allowsMultipleSelection = picker.allowsMultipleSelection;
|
||||||
|
|
||||||
|
[self.collectionView registerClass:GMGridViewCell.class
|
||||||
|
forCellWithReuseIdentifier:GMGridViewCellIdentifier];
|
||||||
|
|
||||||
|
self.preferredContentSize = kPopoverContentSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
- (void)viewDidLoad
|
||||||
|
{
|
||||||
|
[super viewDidLoad];
|
||||||
|
[self setupViews];
|
||||||
|
|
||||||
|
// Navigation bar customization
|
||||||
|
if (self.picker.customNavigationBarPrompt) {
|
||||||
|
self.navigationItem.prompt = self.picker.customNavigationBarPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.imageManager = [[PHCachingImageManager alloc] init];
|
||||||
|
[self resetCachedAssets];
|
||||||
|
[[PHPhotoLibrary sharedPhotoLibrary] registerChangeObserver:self];
|
||||||
|
|
||||||
|
if ([self respondsToSelector:@selector(setEdgesForExtendedLayout:)])
|
||||||
|
{
|
||||||
|
self.edgesForExtendedLayout = UIRectEdgeNone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)viewWillAppear:(BOOL)animated
|
||||||
|
{
|
||||||
|
[super viewWillAppear:animated];
|
||||||
|
|
||||||
|
[self setupButtons];
|
||||||
|
[self setupToolbar];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)viewDidAppear:(BOOL)animated
|
||||||
|
{
|
||||||
|
[super viewDidAppear:animated];
|
||||||
|
[self updateCachedAssets];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc
|
||||||
|
{
|
||||||
|
[self resetCachedAssets];
|
||||||
|
[[PHPhotoLibrary sharedPhotoLibrary] unregisterChangeObserver:self];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIStatusBarStyle)preferredStatusBarStyle {
|
||||||
|
return self.picker.pickerStatusBarStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Rotation
|
||||||
|
|
||||||
|
- (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration
|
||||||
|
{
|
||||||
|
if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UICollectionViewFlowLayout *layout = [self collectionViewFlowLayoutForOrientation:toInterfaceOrientation];
|
||||||
|
|
||||||
|
//Update the AssetGridThumbnailSize:
|
||||||
|
CGFloat scale = [UIScreen mainScreen].scale;
|
||||||
|
AssetGridThumbnailSize = CGSizeMake(layout.itemSize.width * scale, layout.itemSize.height * scale);
|
||||||
|
|
||||||
|
[self resetCachedAssets];
|
||||||
|
//This is optional. Reload visible thumbnails:
|
||||||
|
for (GMGridViewCell *cell in [self.collectionView visibleCells]) {
|
||||||
|
NSInteger currentTag = cell.tag;
|
||||||
|
[self.imageManager requestImageForAsset:cell.asset
|
||||||
|
targetSize:AssetGridThumbnailSize
|
||||||
|
contentMode:PHImageContentModeAspectFill
|
||||||
|
options:nil
|
||||||
|
resultHandler:^(UIImage *result, NSDictionary *info)
|
||||||
|
{
|
||||||
|
// Only update the thumbnail if the cell tag hasn't changed. Otherwise, the cell has been re-used.
|
||||||
|
if (cell.tag == currentTag) {
|
||||||
|
[cell.imageView setImage:result];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
[self.collectionView setCollectionViewLayout:layout animated:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Setup
|
||||||
|
|
||||||
|
- (void)setupViews
|
||||||
|
{
|
||||||
|
self.collectionView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.view.backgroundColor = [self.picker pickerBackgroundColor];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupButtons
|
||||||
|
{
|
||||||
|
if (self.picker.allowsMultipleSelection) {
|
||||||
|
NSString *doneTitle = self.picker.customDoneButtonTitle ? self.picker.customDoneButtonTitle : NSLocalizedStringFromTableInBundle(@"picker.navigation.done-button", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Done");
|
||||||
|
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:doneTitle
|
||||||
|
style:UIBarButtonItemStyleDone
|
||||||
|
target:self.picker
|
||||||
|
action:@selector(finishPickingAssets:)];
|
||||||
|
|
||||||
|
self.navigationItem.rightBarButtonItem.enabled = (self.picker.autoDisableDoneButton ? self.picker.selectedAssets.count > 0 : TRUE);
|
||||||
|
} else {
|
||||||
|
NSString *cancelTitle = self.picker.customCancelButtonTitle ? self.picker.customCancelButtonTitle : NSLocalizedStringFromTableInBundle(@"picker.navigation.cancel-button", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Cancel");
|
||||||
|
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:cancelTitle
|
||||||
|
style:UIBarButtonItemStyleDone
|
||||||
|
target:self.picker
|
||||||
|
action:@selector(dismiss:)];
|
||||||
|
}
|
||||||
|
if (self.picker.useCustomFontForNavigationBar) {
|
||||||
|
if (self.picker.useCustomFontForNavigationBar) {
|
||||||
|
NSDictionary* barButtonItemAttributes = @{NSFontAttributeName: [UIFont fontWithName:self.picker.pickerFontName size:self.picker.pickerFontHeaderSize]};
|
||||||
|
[self.navigationItem.rightBarButtonItem setTitleTextAttributes:barButtonItemAttributes forState:UIControlStateNormal];
|
||||||
|
[self.navigationItem.rightBarButtonItem setTitleTextAttributes:barButtonItemAttributes forState:UIControlStateSelected];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupToolbar
|
||||||
|
{
|
||||||
|
self.toolbarItems = self.picker.toolbarItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Collection View Layout
|
||||||
|
|
||||||
|
- (UICollectionViewFlowLayout *)collectionViewFlowLayoutForOrientation:(UIInterfaceOrientation)orientation
|
||||||
|
{
|
||||||
|
if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad)
|
||||||
|
{
|
||||||
|
if(!portraitLayout)
|
||||||
|
{
|
||||||
|
portraitLayout = [[UICollectionViewFlowLayout alloc] init];
|
||||||
|
portraitLayout.minimumInteritemSpacing = self.picker.minimumInteritemSpacing;
|
||||||
|
int cellTotalUsableWidth = (int)(screenWidth - (self.picker.colsInPortrait-1)*self.picker.minimumInteritemSpacing);
|
||||||
|
portraitLayout.itemSize = CGSizeMake(cellTotalUsableWidth/self.picker.colsInPortrait, cellTotalUsableWidth/self.picker.colsInPortrait);
|
||||||
|
double cellTotalUsedWidth = (double)portraitLayout.itemSize.width*self.picker.colsInPortrait;
|
||||||
|
double spaceTotalWidth = (double)screenWidth-cellTotalUsedWidth;
|
||||||
|
double spaceWidth = spaceTotalWidth/(double)(self.picker.colsInPortrait-1);
|
||||||
|
portraitLayout.minimumLineSpacing = spaceWidth;
|
||||||
|
}
|
||||||
|
return portraitLayout;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if(UIInterfaceOrientationIsLandscape(orientation))
|
||||||
|
{
|
||||||
|
if(!landscapeLayout)
|
||||||
|
{
|
||||||
|
landscapeLayout = [[UICollectionViewFlowLayout alloc] init];
|
||||||
|
landscapeLayout.minimumInteritemSpacing = self.picker.minimumInteritemSpacing;
|
||||||
|
int cellTotalUsableWidth = (int)(screenHeight - (self.picker.colsInLandscape-1)*self.picker.minimumInteritemSpacing);
|
||||||
|
landscapeLayout.itemSize = CGSizeMake(cellTotalUsableWidth/self.picker.colsInLandscape, cellTotalUsableWidth/self.picker.colsInLandscape);
|
||||||
|
double cellTotalUsedWidth = (double)landscapeLayout.itemSize.width*self.picker.colsInLandscape;
|
||||||
|
double spaceTotalWidth = (double)screenHeight-cellTotalUsedWidth;
|
||||||
|
double spaceWidth = spaceTotalWidth/(double)(self.picker.colsInLandscape-1);
|
||||||
|
landscapeLayout.minimumLineSpacing = spaceWidth;
|
||||||
|
}
|
||||||
|
return landscapeLayout;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if(!portraitLayout)
|
||||||
|
{
|
||||||
|
portraitLayout = [[UICollectionViewFlowLayout alloc] init];
|
||||||
|
portraitLayout.minimumInteritemSpacing = self.picker.minimumInteritemSpacing;
|
||||||
|
int cellTotalUsableWidth = (int)(screenWidth - (self.picker.colsInPortrait-1) * self.picker.minimumInteritemSpacing);
|
||||||
|
portraitLayout.itemSize = CGSizeMake(cellTotalUsableWidth/self.picker.colsInPortrait, cellTotalUsableWidth/self.picker.colsInPortrait);
|
||||||
|
double cellTotalUsedWidth = (double)portraitLayout.itemSize.width*self.picker.colsInPortrait;
|
||||||
|
double spaceTotalWidth = (double)screenWidth-cellTotalUsedWidth;
|
||||||
|
double spaceWidth = spaceTotalWidth/(double)(self.picker.colsInPortrait-1);
|
||||||
|
portraitLayout.minimumLineSpacing = spaceWidth;
|
||||||
|
}
|
||||||
|
return portraitLayout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Collection View Data Source
|
||||||
|
|
||||||
|
- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
GMGridViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:GMGridViewCellIdentifier
|
||||||
|
forIndexPath:indexPath];
|
||||||
|
|
||||||
|
// Increment the cell's tag
|
||||||
|
NSInteger currentTag = cell.tag + 1;
|
||||||
|
cell.tag = currentTag;
|
||||||
|
|
||||||
|
PHAsset *asset = self.assetsFetchResults[(NSUInteger)indexPath.item];
|
||||||
|
|
||||||
|
/*if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad)
|
||||||
|
{
|
||||||
|
NSLog(@"Image manager: Requesting FIT image for iPad");
|
||||||
|
[self.imageManager requestImageForAsset:asset
|
||||||
|
targetSize:AssetGridThumbnailSize
|
||||||
|
contentMode:PHImageContentModeAspectFit
|
||||||
|
options:nil
|
||||||
|
resultHandler:^(UIImage *result, NSDictionary *info) {
|
||||||
|
|
||||||
|
// Only update the thumbnail if the cell tag hasn't changed. Otherwise, the cell has been re-used.
|
||||||
|
if (cell.tag == currentTag) {
|
||||||
|
[cell.imageView setImage:result];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
else*/
|
||||||
|
{
|
||||||
|
//NSLog(@"Image manager: Requesting FILL image for iPhone");
|
||||||
|
[self.imageManager requestImageForAsset:asset
|
||||||
|
targetSize:AssetGridThumbnailSize
|
||||||
|
contentMode:PHImageContentModeAspectFill
|
||||||
|
options:nil
|
||||||
|
resultHandler:^(UIImage *result, NSDictionary *info) {
|
||||||
|
|
||||||
|
// Only update the thumbnail if the cell tag hasn't changed. Otherwise, the cell has been re-used.
|
||||||
|
if (cell.tag == currentTag) {
|
||||||
|
[cell.imageView setImage:result];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[cell bind:asset];
|
||||||
|
|
||||||
|
cell.shouldShowSelection = self.picker.allowsMultipleSelection;
|
||||||
|
|
||||||
|
// Optional protocol to determine if some kind of assets can't be selected (pej long videos, etc...)
|
||||||
|
if ([self.picker.delegate respondsToSelector:@selector(assetsPickerController:shouldEnableAsset:)]) {
|
||||||
|
cell.enabled = [self.picker.delegate assetsPickerController:self.picker shouldEnableAsset:asset];
|
||||||
|
} else {
|
||||||
|
cell.enabled = YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting `selected` property blocks further deselection. Have to call selectItemAtIndexPath too. ( ref: http://stackoverflow.com/a/17812116/1648333 )
|
||||||
|
if ([self.picker.selectedAssets containsObject:asset]) {
|
||||||
|
cell.selected = YES;
|
||||||
|
[collectionView selectItemAtIndexPath:indexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone];
|
||||||
|
} else {
|
||||||
|
cell.selected = NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Collection View Delegate
|
||||||
|
|
||||||
|
- (BOOL)collectionView:(UICollectionView *)collectionView shouldSelectItemAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
PHAsset *asset = self.assetsFetchResults[(NSUInteger)indexPath.item];
|
||||||
|
|
||||||
|
GMGridViewCell *cell = (GMGridViewCell *)[collectionView cellForItemAtIndexPath:indexPath];
|
||||||
|
|
||||||
|
if (!cell.isEnabled) {
|
||||||
|
return NO;
|
||||||
|
} else if ([self.picker.delegate respondsToSelector:@selector(assetsPickerController:shouldSelectAsset:)]) {
|
||||||
|
return [self.picker.delegate assetsPickerController:self.picker shouldSelectAsset:asset];
|
||||||
|
} else {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
PHAsset *asset = self.assetsFetchResults[(NSUInteger)indexPath.item];
|
||||||
|
|
||||||
|
[self.picker selectAsset:asset];
|
||||||
|
|
||||||
|
if ([self.picker.delegate respondsToSelector:@selector(assetsPickerController:didSelectAsset:)]) {
|
||||||
|
[self.picker.delegate assetsPickerController:self.picker didSelectAsset:asset];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)collectionView:(UICollectionView *)collectionView shouldDeselectItemAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
PHAsset *asset = self.assetsFetchResults[(NSUInteger)indexPath.item];
|
||||||
|
|
||||||
|
if ([self.picker.delegate respondsToSelector:@selector(assetsPickerController:shouldDeselectAsset:)]) {
|
||||||
|
return [self.picker.delegate assetsPickerController:self.picker shouldDeselectAsset:asset];
|
||||||
|
} else {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)collectionView:(UICollectionView *)collectionView didDeselectItemAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
PHAsset *asset = self.assetsFetchResults[(NSUInteger)indexPath.item];
|
||||||
|
|
||||||
|
[self.picker deselectAsset:asset];
|
||||||
|
|
||||||
|
if ([self.picker.delegate respondsToSelector:@selector(assetsPickerController:didDeselectAsset:)]) {
|
||||||
|
[self.picker.delegate assetsPickerController:self.picker didDeselectAsset:asset];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)collectionView:(UICollectionView *)collectionView shouldHighlightItemAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
PHAsset *asset = self.assetsFetchResults[(NSUInteger)indexPath.item];
|
||||||
|
|
||||||
|
if ([self.picker.delegate respondsToSelector:@selector(assetsPickerController:shouldHighlightAsset:)]) {
|
||||||
|
return [self.picker.delegate assetsPickerController:self.picker shouldHighlightAsset:asset];
|
||||||
|
} else {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)collectionView:(UICollectionView *)collectionView didHighlightItemAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
PHAsset *asset = self.assetsFetchResults[(NSUInteger)indexPath.item];
|
||||||
|
|
||||||
|
if ([self.picker.delegate respondsToSelector:@selector(assetsPickerController:didHighlightAsset:)]) {
|
||||||
|
[self.picker.delegate assetsPickerController:self.picker didHighlightAsset:asset];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)collectionView:(UICollectionView *)collectionView didUnhighlightItemAtIndexPath:(NSIndexPath *)indexPath
|
||||||
|
{
|
||||||
|
PHAsset *asset = self.assetsFetchResults[(NSUInteger)indexPath.item];
|
||||||
|
|
||||||
|
if ([self.picker.delegate respondsToSelector:@selector(assetsPickerController:didUnhighlightAsset:)]) {
|
||||||
|
[self.picker.delegate assetsPickerController:self.picker didUnhighlightAsset:asset];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - UICollectionViewDataSource
|
||||||
|
|
||||||
|
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
|
||||||
|
{
|
||||||
|
NSInteger count = (NSInteger)self.assetsFetchResults.count;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - PHPhotoLibraryChangeObserver
|
||||||
|
|
||||||
|
- (void)photoLibraryDidChange:(PHChange *)changeInstance
|
||||||
|
{
|
||||||
|
// Call might come on any background queue. Re-dispatch to the main queue to handle it.
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
|
||||||
|
// check if there are changes to the assets (insertions, deletions, updates)
|
||||||
|
PHFetchResultChangeDetails *collectionChanges = [changeInstance changeDetailsForFetchResult:self.assetsFetchResults];
|
||||||
|
if (collectionChanges) {
|
||||||
|
|
||||||
|
// get the new fetch result
|
||||||
|
self.assetsFetchResults = [collectionChanges fetchResultAfterChanges];
|
||||||
|
|
||||||
|
UICollectionView *collectionView = self.collectionView;
|
||||||
|
|
||||||
|
if (![collectionChanges hasIncrementalChanges] || [collectionChanges hasMoves]) {
|
||||||
|
// we need to reload all if the incremental diffs are not available
|
||||||
|
[collectionView reloadData];
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// if we have incremental diffs, tell the collection view to animate insertions and deletions
|
||||||
|
[collectionView performBatchUpdates:^{
|
||||||
|
NSIndexSet *removedIndexes = [collectionChanges removedIndexes];
|
||||||
|
if ([removedIndexes count]) {
|
||||||
|
[collectionView deleteItemsAtIndexPaths:[removedIndexes aapl_indexPathsFromIndexesWithSection:0]];
|
||||||
|
}
|
||||||
|
NSIndexSet *insertedIndexes = [collectionChanges insertedIndexes];
|
||||||
|
if ([insertedIndexes count]) {
|
||||||
|
[collectionView insertItemsAtIndexPaths:[insertedIndexes aapl_indexPathsFromIndexesWithSection:0]];
|
||||||
|
if (self.picker.showCameraButton && self.picker.autoSelectCameraImages) {
|
||||||
|
for (NSIndexPath *path in [insertedIndexes aapl_indexPathsFromIndexesWithSection:0]) {
|
||||||
|
[self collectionView:collectionView didSelectItemAtIndexPath:path];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NSIndexSet *changedIndexes = [collectionChanges changedIndexes];
|
||||||
|
if ([changedIndexes count]) {
|
||||||
|
[collectionView reloadItemsAtIndexPaths:[changedIndexes aapl_indexPathsFromIndexesWithSection:0]];
|
||||||
|
}
|
||||||
|
} completion:NULL];
|
||||||
|
}
|
||||||
|
|
||||||
|
[self resetCachedAssets];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - UIScrollViewDelegate
|
||||||
|
|
||||||
|
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
|
||||||
|
{
|
||||||
|
[self updateCachedAssets];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Asset Caching
|
||||||
|
|
||||||
|
- (void)resetCachedAssets
|
||||||
|
{
|
||||||
|
[self.imageManager stopCachingImagesForAllAssets];
|
||||||
|
self.previousPreheatRect = CGRectZero;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateCachedAssets
|
||||||
|
{
|
||||||
|
BOOL isViewVisible = [self isViewLoaded] && [[self view] window] != nil;
|
||||||
|
if (!isViewVisible) { return; }
|
||||||
|
|
||||||
|
// The preheat window is twice the height of the visible rect
|
||||||
|
CGRect preheatRect = self.collectionView.bounds;
|
||||||
|
preheatRect = CGRectInset(preheatRect, 0.0f, -0.5f * CGRectGetHeight(preheatRect));
|
||||||
|
|
||||||
|
// If scrolled by a "reasonable" amount...
|
||||||
|
CGFloat delta = ABS(CGRectGetMidY(preheatRect) - CGRectGetMidY(self.previousPreheatRect));
|
||||||
|
if (delta > CGRectGetHeight(self.collectionView.bounds) / 3.0f) {
|
||||||
|
|
||||||
|
// Compute the assets to start caching and to stop caching.
|
||||||
|
NSMutableArray *addedIndexPaths = [NSMutableArray array];
|
||||||
|
NSMutableArray *removedIndexPaths = [NSMutableArray array];
|
||||||
|
|
||||||
|
[self computeDifferenceBetweenRect:self.previousPreheatRect andRect:preheatRect removedHandler:^(CGRect removedRect) {
|
||||||
|
NSArray *indexPaths = [self.collectionView aapl_indexPathsForElementsInRect:removedRect];
|
||||||
|
[removedIndexPaths addObjectsFromArray:indexPaths];
|
||||||
|
} addedHandler:^(CGRect addedRect) {
|
||||||
|
NSArray *indexPaths = [self.collectionView aapl_indexPathsForElementsInRect:addedRect];
|
||||||
|
[addedIndexPaths addObjectsFromArray:indexPaths];
|
||||||
|
}];
|
||||||
|
|
||||||
|
NSArray *assetsToStartCaching = [self assetsAtIndexPaths:addedIndexPaths];
|
||||||
|
NSArray *assetsToStopCaching = [self assetsAtIndexPaths:removedIndexPaths];
|
||||||
|
|
||||||
|
[self.imageManager startCachingImagesForAssets:assetsToStartCaching
|
||||||
|
targetSize:AssetGridThumbnailSize
|
||||||
|
contentMode:PHImageContentModeAspectFill
|
||||||
|
options:nil];
|
||||||
|
[self.imageManager stopCachingImagesForAssets:assetsToStopCaching
|
||||||
|
targetSize:AssetGridThumbnailSize
|
||||||
|
contentMode:PHImageContentModeAspectFill
|
||||||
|
options:nil];
|
||||||
|
|
||||||
|
self.previousPreheatRect = preheatRect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)computeDifferenceBetweenRect:(CGRect)oldRect andRect:(CGRect)newRect removedHandler:(void (^)(CGRect removedRect))removedHandler addedHandler:(void (^)(CGRect addedRect))addedHandler
|
||||||
|
{
|
||||||
|
if (CGRectIntersectsRect(newRect, oldRect)) {
|
||||||
|
CGFloat oldMaxY = CGRectGetMaxY(oldRect);
|
||||||
|
CGFloat oldMinY = CGRectGetMinY(oldRect);
|
||||||
|
CGFloat newMaxY = CGRectGetMaxY(newRect);
|
||||||
|
CGFloat newMinY = CGRectGetMinY(newRect);
|
||||||
|
if (newMaxY > oldMaxY) {
|
||||||
|
CGRect rectToAdd = CGRectMake(newRect.origin.x, oldMaxY, newRect.size.width, (newMaxY - oldMaxY));
|
||||||
|
addedHandler(rectToAdd);
|
||||||
|
}
|
||||||
|
if (oldMinY > newMinY) {
|
||||||
|
CGRect rectToAdd = CGRectMake(newRect.origin.x, newMinY, newRect.size.width, (oldMinY - newMinY));
|
||||||
|
addedHandler(rectToAdd);
|
||||||
|
}
|
||||||
|
if (newMaxY < oldMaxY) {
|
||||||
|
CGRect rectToRemove = CGRectMake(newRect.origin.x, newMaxY, newRect.size.width, (oldMaxY - newMaxY));
|
||||||
|
removedHandler(rectToRemove);
|
||||||
|
}
|
||||||
|
if (oldMinY < newMinY) {
|
||||||
|
CGRect rectToRemove = CGRectMake(newRect.origin.x, oldMinY, newRect.size.width, (newMinY - oldMinY));
|
||||||
|
removedHandler(rectToRemove);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addedHandler(newRect);
|
||||||
|
removedHandler(oldRect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray *)assetsAtIndexPaths:(NSArray *)indexPaths
|
||||||
|
{
|
||||||
|
if (indexPaths.count == 0) { return nil; }
|
||||||
|
|
||||||
|
NSMutableArray *assets = [NSMutableArray arrayWithCapacity:indexPaths.count];
|
||||||
|
for (NSIndexPath *indexPath in indexPaths) {
|
||||||
|
PHAsset *asset = self.assetsFetchResults[(NSUInteger)indexPath.item];
|
||||||
|
[assets addObject:asset];
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@end
|
|
@ -0,0 +1,24 @@
|
||||||
|
//
|
||||||
|
// GMImagePicker.h
|
||||||
|
// GMImagePicker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/14/19.
|
||||||
|
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
//! Project version number for GMImagePicker.
|
||||||
|
FOUNDATION_EXPORT double GMImagePickerVersionNumber;
|
||||||
|
|
||||||
|
//! Project version string for GMImagePicker.
|
||||||
|
FOUNDATION_EXPORT const unsigned char GMImagePickerVersionString[];
|
||||||
|
|
||||||
|
// In this header, you should import all the public headers of your framework using statements like #import <GMImagePicker/PublicHeader.h>
|
||||||
|
|
||||||
|
|
||||||
|
#import <GMImagePicker/GMImagePickerController.h>
|
||||||
|
#import <GMImagePicker/GMAlbumsViewCell.h>
|
||||||
|
#import <GMImagePicker/GMAlbumsViewController.h>
|
||||||
|
#import <GMImagePicker/GMGridViewCell.h>
|
||||||
|
#import <GMImagePicker/GMGridViewController.h>
|
|
@ -0,0 +1,332 @@
|
||||||
|
//
|
||||||
|
// GMImagePickerController.h
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 19/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#import <Photos/Photos.h>
|
||||||
|
|
||||||
|
|
||||||
|
//This is the default image picker size!
|
||||||
|
//static CGSize const kPopoverContentSize = {320, 480};
|
||||||
|
//However, the iPad is 1024x768 so it can allow popups up to 768!
|
||||||
|
static CGSize const kPopoverContentSize = {480, 720};
|
||||||
|
|
||||||
|
|
||||||
|
@protocol GMImagePickerControllerDelegate;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A controller that allows picking multiple photos and videos from user's photo library.
|
||||||
|
*/
|
||||||
|
@interface GMImagePickerController : UIViewController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The assets picker’s delegate object.
|
||||||
|
*/
|
||||||
|
@property (nonatomic, weak) id <GMImagePickerControllerDelegate> delegate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It contains the selected `PHAsset` objects. The order of the objects is the selection order.
|
||||||
|
*
|
||||||
|
* You can add assets before presenting the picker to show the user some preselected assets.
|
||||||
|
*/
|
||||||
|
@property (nonatomic, strong) NSMutableArray *selectedAssets;
|
||||||
|
|
||||||
|
|
||||||
|
/** UI Customizations **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines which smart collections are displayed (int array of enum: PHAssetCollectionSubtypeSmartAlbum)
|
||||||
|
* The default smart collections are:
|
||||||
|
* - Favorites
|
||||||
|
* - RecentlyAdded
|
||||||
|
* - Videos
|
||||||
|
* - SlomoVideos
|
||||||
|
* - Timelapses
|
||||||
|
* - Bursts
|
||||||
|
* - Panoramas
|
||||||
|
*/
|
||||||
|
@property (nonatomic, strong) NSArray* customSmartCollections;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines which media types are allowed (int array of enum: PHAssetMediaType)
|
||||||
|
* This defaults to all media types (view, audio and images)
|
||||||
|
* This can override customSmartCollections behavior (ie, remove video-only smart collections)
|
||||||
|
*/
|
||||||
|
@property (nonatomic, strong) NSArray* mediaTypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set, it displays a this string instead of the localised default of "Done" on the done button. Note also that this
|
||||||
|
* is not used when a single selection is active since the selection of the chosen photo closes the VC thus rendering
|
||||||
|
* the button pointless.
|
||||||
|
*/
|
||||||
|
@property (nonatomic) NSString* customDoneButtonTitle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set, it displays this string instead of the localised default of "Cancel" on the cancel button
|
||||||
|
*/
|
||||||
|
@property (nonatomic) NSString* customCancelButtonTitle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set, it displays a prompt in the navigation bar
|
||||||
|
*/
|
||||||
|
@property (nonatomic) NSString* customNavigationBarPrompt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether or not a toolbar with info about user selection is shown.
|
||||||
|
* The InfoToolbar is visible by default.
|
||||||
|
*/
|
||||||
|
@property (nonatomic) BOOL displaySelectionInfoToolbar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether or not the number of assets is shown in the Album list.
|
||||||
|
* The number of assets is visible by default.
|
||||||
|
*/
|
||||||
|
@property (nonatomic, assign) BOOL displayAlbumsNumberOfAssets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically disables the "Done" button if nothing is selected. Defaults to YES.
|
||||||
|
*/
|
||||||
|
@property (nonatomic, assign) BOOL autoDisableDoneButton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the picker either for miltiple image selections, or just a single selection. In the case of a single selection
|
||||||
|
* the VC is closed on selection so the Done button is neither displayed or used. Default is YES.
|
||||||
|
*/
|
||||||
|
@property (nonatomic, assign) BOOL allowsMultipleSelection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In the case where allowsMultipleSelection = NO, set this to YES to have the user confirm their selection. Default is NO.
|
||||||
|
*/
|
||||||
|
@property (nonatomic, assign) BOOL confirmSingleSelection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set, it displays this string (if confirmSingleSelection = YES) instead of the localised default.
|
||||||
|
*/
|
||||||
|
@property (nonatomic) NSString *confirmSingleSelectionPrompt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True to always show the toolbar, with a camera button allowing new photos to be taken. False to auto show/hide the
|
||||||
|
* toolbar, and have no camera button. Default is false. If true, this renders displaySelectionInfoToolbar a no-op.
|
||||||
|
*/
|
||||||
|
@property (nonatomic, assign) BOOL showCameraButton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True to auto select the image(s) taken with the camera if showCameraButton = YES. In the case of allowsMultipleSelection = YES,
|
||||||
|
* this will trigger the selection handler too.
|
||||||
|
*/
|
||||||
|
@property (nonatomic, assign) BOOL autoSelectCameraImages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set, the user is allowed to edit captured still images
|
||||||
|
*/
|
||||||
|
@property (nonatomic, assign) BOOL allowsEditingCameraImages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid customizations:
|
||||||
|
*
|
||||||
|
* - colsInPortrait: Number of columns in portrait (3 by default)
|
||||||
|
* - colsInLandscape: Number of columns in landscape (5 by default)
|
||||||
|
* - minimumInteritemSpacing: Horizontal and vertical minimum space between grid cells (2.0 by default)
|
||||||
|
*/
|
||||||
|
@property (nonatomic) NSInteger colsInPortrait;
|
||||||
|
@property (nonatomic) NSInteger colsInLandscape;
|
||||||
|
@property (nonatomic) double minimumInteritemSpacing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI customizations:
|
||||||
|
*
|
||||||
|
* - pickerBackgroundColor: The colour for all backgrounds; behind the table and cells. Defaults to [UIColor whiteColor]
|
||||||
|
* - pickerTextColor: The color for text in the views. This needs to work with pickerBackgroundColor! Default of darkTextColor
|
||||||
|
* - toolbarBackgroundColor: The background color of the toolbar. Defaults to nil.
|
||||||
|
* - toolbarBarTintColor: The color for the background tint of the toolbar. Defaults to nil.
|
||||||
|
* - toolbarTextColor: The color of the text on the toolbar
|
||||||
|
* - toolbarTintColor: The tint colour used for any buttons on the toolbar
|
||||||
|
* - navigationBarBackgroundColor: The background of the navigation bar. Defaults to nil.
|
||||||
|
* - navigationBarBarTintColor: The color for the background tint of the navigation bar. Defaults to nil.
|
||||||
|
* - navigationBarTextColor: The color for the text in the navigation bar. Defaults to [UIColor darkTextColor]
|
||||||
|
* - navigationBarTintColor: The tint color used for any buttons on the navigation Bar
|
||||||
|
* - pickerFontName: The font to use everywhere. Defaults to HelveticaNeue. It is advised if you set this to check, and possibly set, appropriately the custom font sizes. For font information, check http://www.iosfonts.com/
|
||||||
|
* - pickerFontName: The font to use everywhere. Defaults to HelveticaNeue-Bold. It is advised if you set this to check, and possibly set, appropriately the custom font sizes.
|
||||||
|
* - pickerFontNormalSize: The size of the custom font used in most places. Defaults to 14.0f
|
||||||
|
* - pickerFontHeaderSize: The size of the custom font for album names. Defaults to 17.0f
|
||||||
|
* - pickerStatusBarsStyle: On iPhones this will matter if custom navigation bar colours are being used. Defaults to UIStatusBarStyleDefault
|
||||||
|
* - useCustomFontForNavigationBar: True to use the custom font (or it's default) in the navigation bar, false to leave to iOS Defaults.
|
||||||
|
* - arrangeSmartCollectionsFirst: True will put the users smart collections above their albums, false will set it opposite. Default is NO.
|
||||||
|
*/
|
||||||
|
@property (nonatomic, strong) UIColor *pickerBackgroundColor;
|
||||||
|
@property (nonatomic, strong) UIColor *pickerTextColor;
|
||||||
|
@property (nonatomic, strong) UIColor *toolbarBackgroundColor;
|
||||||
|
@property (nonatomic, strong) UIColor *toolbarBarTintColor;
|
||||||
|
@property (nonatomic, strong) UIColor *toolbarTextColor;
|
||||||
|
@property (nonatomic, strong) UIColor *toolbarTintColor;
|
||||||
|
@property (nonatomic, strong) UIColor *navigationBarBackgroundColor;
|
||||||
|
@property (nonatomic, strong) UIColor *navigationBarBarTintColor;
|
||||||
|
@property (nonatomic, strong) UIColor *navigationBarTextColor;
|
||||||
|
@property (nonatomic, strong) UIColor *navigationBarTintColor;
|
||||||
|
@property (nonatomic, strong) NSString *pickerFontName;
|
||||||
|
@property (nonatomic, strong) NSString *pickerBoldFontName;
|
||||||
|
@property (nonatomic) CGFloat pickerFontNormalSize;
|
||||||
|
@property (nonatomic) CGFloat pickerFontHeaderSize;
|
||||||
|
@property (nonatomic) UIStatusBarStyle pickerStatusBarStyle;
|
||||||
|
@property (nonatomic) BOOL useCustomFontForNavigationBar;
|
||||||
|
@property (nonatomic) BOOL arrangeSmartCollectionsFirst;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reference to the navigation controller used to manage the whole picking process
|
||||||
|
*/
|
||||||
|
@property (nonatomic, strong) UINavigationController *navigationController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Managing Asset Selection
|
||||||
|
*/
|
||||||
|
- (void)selectAsset:(PHAsset *)asset;
|
||||||
|
- (void)deselectAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User finish Actions
|
||||||
|
*/
|
||||||
|
- (void)dismiss:(id)sender;
|
||||||
|
- (void)finishPickingAssets:(id)sender;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@protocol GMImagePickerControllerDelegate <NSObject>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name Closing the Picker
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the delegate that the user finish picking photos or videos.
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param assets An array containing picked PHAssets objects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
- (void)assetsPickerController:(GMImagePickerController *)picker didFinishPickingAssets:(NSArray *)assets;
|
||||||
|
|
||||||
|
|
||||||
|
@optional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the delegate that the user cancelled the pick operation.
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
*/
|
||||||
|
- (void)assetsPickerControllerDidCancel:(GMImagePickerController *)picker;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name Enabling Assets
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the delegate if the specified asset should be shown.
|
||||||
|
*
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param asset The asset to be shown.
|
||||||
|
*
|
||||||
|
* @return `YES` if the asset should be shown or `NO` if it should not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
- (BOOL)assetsPickerController:(GMImagePickerController *)picker shouldShowAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the delegate if the specified asset should be enabled for selection.
|
||||||
|
*
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param asset The asset to be enabled.
|
||||||
|
*
|
||||||
|
* @return `YES` if the asset should be enabled or `NO` if it should not.
|
||||||
|
*/
|
||||||
|
- (BOOL)assetsPickerController:(GMImagePickerController *)picker shouldEnableAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name Managing the Selected Assets
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the delegate if the specified asset should be selected.
|
||||||
|
*
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param asset The asset to be selected.
|
||||||
|
*
|
||||||
|
* @return `YES` if the asset should be selected or `NO` if it should not.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
- (BOOL)assetsPickerController:(GMImagePickerController *)picker shouldSelectAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the delegate that the asset was selected.
|
||||||
|
*
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param asset The asset that was selected.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
- (void)assetsPickerController:(GMImagePickerController *)picker didSelectAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the delegate if the specified asset should be deselected.
|
||||||
|
*
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param asset The asset to be deselected.
|
||||||
|
*
|
||||||
|
* @return `YES` if the asset should be deselected or `NO` if it should not.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
- (BOOL)assetsPickerController:(GMImagePickerController *)picker shouldDeselectAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the delegate that the item at the specified path was deselected.
|
||||||
|
*
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param asset The asset that was deselected.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
- (void)assetsPickerController:(GMImagePickerController *)picker didDeselectAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name Managing Asset Highlighting
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the delegate if the specified asset should be highlighted.
|
||||||
|
*
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param asset The asset to be highlighted.
|
||||||
|
*
|
||||||
|
* @return `YES` if the asset should be highlighted or `NO` if it should not.
|
||||||
|
*/
|
||||||
|
- (BOOL)assetsPickerController:(GMImagePickerController *)picker shouldHighlightAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the delegate that asset was highlighted.
|
||||||
|
*
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param asset The asset that was highlighted.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
- (void)assetsPickerController:(GMImagePickerController *)picker didHighlightAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the delegate that the highlight was removed from the asset.
|
||||||
|
*
|
||||||
|
* @param picker The controller object managing the assets picker interface.
|
||||||
|
* @param asset The asset that had its highlight removed.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
- (void)assetsPickerController:(GMImagePickerController *)picker didUnhighlightAsset:(PHAsset *)asset;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@end
|
|
@ -0,0 +1,388 @@
|
||||||
|
//
|
||||||
|
// GMImagePickerController.m
|
||||||
|
// GMPhotoPicker
|
||||||
|
//
|
||||||
|
// Created by Guillermo Muntaner Perelló on 19/09/14.
|
||||||
|
// Copyright (c) 2014 Guillermo Muntaner Perelló. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <MobileCoreServices/MobileCoreServices.h>
|
||||||
|
#import "GMImagePickerController.h"
|
||||||
|
#import "GMAlbumsViewController.h"
|
||||||
|
#import <Photos/Photos.h>
|
||||||
|
|
||||||
|
@interface GMImagePickerController () <UINavigationControllerDelegate, UIImagePickerControllerDelegate, UIAlertViewDelegate>
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation GMImagePickerController
|
||||||
|
|
||||||
|
- (id)init
|
||||||
|
{
|
||||||
|
if (self = [super init]) {
|
||||||
|
_selectedAssets = [[NSMutableArray alloc] init];
|
||||||
|
|
||||||
|
// Default values:
|
||||||
|
_displaySelectionInfoToolbar = YES;
|
||||||
|
_displayAlbumsNumberOfAssets = YES;
|
||||||
|
_autoDisableDoneButton = YES;
|
||||||
|
_allowsMultipleSelection = YES;
|
||||||
|
_confirmSingleSelection = NO;
|
||||||
|
_showCameraButton = NO;
|
||||||
|
|
||||||
|
// Grid configuration:
|
||||||
|
_colsInPortrait = 3;
|
||||||
|
_colsInLandscape = 5;
|
||||||
|
_minimumInteritemSpacing = 2.0;
|
||||||
|
|
||||||
|
// Sample of how to select the collections you want to display:
|
||||||
|
_customSmartCollections = @[@(PHAssetCollectionSubtypeSmartAlbumFavorites),
|
||||||
|
@(PHAssetCollectionSubtypeSmartAlbumRecentlyAdded),
|
||||||
|
@(PHAssetCollectionSubtypeSmartAlbumVideos),
|
||||||
|
@(PHAssetCollectionSubtypeSmartAlbumSlomoVideos),
|
||||||
|
@(PHAssetCollectionSubtypeSmartAlbumTimelapses),
|
||||||
|
@(PHAssetCollectionSubtypeSmartAlbumBursts),
|
||||||
|
@(PHAssetCollectionSubtypeSmartAlbumPanoramas)];
|
||||||
|
// If you don't want to show smart collections, just put _customSmartCollections to nil;
|
||||||
|
//_customSmartCollections=nil;
|
||||||
|
|
||||||
|
// Which media types will display
|
||||||
|
_mediaTypes = @[@(PHAssetMediaTypeAudio),
|
||||||
|
@(PHAssetMediaTypeVideo),
|
||||||
|
@(PHAssetMediaTypeImage)];
|
||||||
|
|
||||||
|
self.preferredContentSize = kPopoverContentSize;
|
||||||
|
|
||||||
|
// UI Customisation
|
||||||
|
_pickerBackgroundColor = [UIColor whiteColor];
|
||||||
|
_pickerTextColor = [UIColor darkTextColor];
|
||||||
|
_pickerFontName = @"HelveticaNeue";
|
||||||
|
_pickerBoldFontName = @"HelveticaNeue-Bold";
|
||||||
|
_pickerFontNormalSize = 14.0f;
|
||||||
|
_pickerFontHeaderSize = 17.0f;
|
||||||
|
|
||||||
|
_navigationBarBackgroundColor = [UIColor whiteColor];
|
||||||
|
_navigationBarTextColor = [UIColor darkTextColor];
|
||||||
|
_navigationBarTintColor = [UIColor darkTextColor];
|
||||||
|
|
||||||
|
_toolbarBarTintColor = [UIColor whiteColor];
|
||||||
|
_toolbarTextColor = [UIColor darkTextColor];
|
||||||
|
_toolbarTintColor = [UIColor darkTextColor];
|
||||||
|
|
||||||
|
_pickerStatusBarStyle = UIStatusBarStyleDefault;
|
||||||
|
|
||||||
|
[self setupNavigationController];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)viewWillAppear:(BOOL)animated
|
||||||
|
{
|
||||||
|
[super viewWillAppear:animated];
|
||||||
|
|
||||||
|
// Ensure nav and toolbar customisations are set. Defaults are in place, but the user may have changed them
|
||||||
|
self.view.backgroundColor = _pickerBackgroundColor;
|
||||||
|
|
||||||
|
_navigationController.toolbar.translucent = YES;
|
||||||
|
_navigationController.toolbar.barTintColor = _toolbarBarTintColor;
|
||||||
|
_navigationController.toolbar.tintColor = _toolbarTintColor;
|
||||||
|
|
||||||
|
_navigationController.navigationBar.backgroundColor = _navigationBarBackgroundColor;
|
||||||
|
_navigationController.navigationBar.tintColor = _navigationBarTintColor;
|
||||||
|
NSDictionary *attributes;
|
||||||
|
if (_useCustomFontForNavigationBar) {
|
||||||
|
attributes = @{NSForegroundColorAttributeName : _navigationBarTextColor,
|
||||||
|
NSFontAttributeName : [UIFont fontWithName:_pickerBoldFontName size:_pickerFontHeaderSize]};
|
||||||
|
} else {
|
||||||
|
attributes = @{NSForegroundColorAttributeName : _navigationBarTextColor};
|
||||||
|
}
|
||||||
|
_navigationController.navigationBar.titleTextAttributes = attributes;
|
||||||
|
|
||||||
|
[self updateToolbar];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIStatusBarStyle)preferredStatusBarStyle {
|
||||||
|
return _pickerStatusBarStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Setup Navigation Controller
|
||||||
|
|
||||||
|
- (void)setupNavigationController
|
||||||
|
{
|
||||||
|
GMAlbumsViewController *albumsViewController = [[GMAlbumsViewController alloc] init];
|
||||||
|
_navigationController = [[UINavigationController alloc] initWithRootViewController:albumsViewController];
|
||||||
|
_navigationController.delegate = self;
|
||||||
|
[_navigationController.navigationBar setTranslucent:NO];
|
||||||
|
[_navigationController willMoveToParentViewController:self];
|
||||||
|
[_navigationController.view setFrame:self.view.frame];
|
||||||
|
[self.view addSubview:_navigationController.view];
|
||||||
|
[self addConstraintsToChildViewControllersView:_navigationController.view];
|
||||||
|
[self addChildViewController:_navigationController];
|
||||||
|
[_navigationController didMoveToParentViewController:self];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)addConstraintsToChildViewControllersView:(UIView *)view {
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
NSArray * hConstraints = [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-0-[view]-0-|" options:0 metrics:0 views:NSDictionaryOfVariableBindings(view)];
|
||||||
|
NSLayoutConstraint * topConstraint = [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.topLayoutGuide attribute:NSLayoutAttributeBottom multiplier:1 constant:0];
|
||||||
|
NSLayoutConstraint * bottomConstraint = [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:view.superview attribute:NSLayoutAttributeBottom multiplier:1 constant:0];
|
||||||
|
[view.superview addConstraints:@[topConstraint,bottomConstraint]];
|
||||||
|
[view.superview addConstraints:hConstraints];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - UIAlertViewDelegate
|
||||||
|
|
||||||
|
-(void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex
|
||||||
|
{
|
||||||
|
if (buttonIndex == 1) {
|
||||||
|
// Only if OK was pressed do we want to completge the selection
|
||||||
|
[self finishPickingAssets:self];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Select / Deselect Asset
|
||||||
|
|
||||||
|
- (void)selectAsset:(PHAsset *)asset
|
||||||
|
{
|
||||||
|
[self.selectedAssets insertObject:asset atIndex:self.selectedAssets.count];
|
||||||
|
[self updateDoneButton];
|
||||||
|
|
||||||
|
if (!self.allowsMultipleSelection) {
|
||||||
|
if (self.confirmSingleSelection) {
|
||||||
|
NSString *message = self.confirmSingleSelectionPrompt ? self.confirmSingleSelectionPrompt : [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"picker.confirm.message", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Do you want to select the image you tapped on?")];
|
||||||
|
|
||||||
|
[[[UIAlertView alloc] initWithTitle:[NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"picker.confirm.title", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Are You Sure?")]
|
||||||
|
message:message
|
||||||
|
delegate:self
|
||||||
|
cancelButtonTitle:[NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"picker.action.no", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"No")]
|
||||||
|
otherButtonTitles:[NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"picker.action.yes", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"Yes")], nil] show];
|
||||||
|
} else {
|
||||||
|
[self finishPickingAssets:self];
|
||||||
|
}
|
||||||
|
} else if (self.displaySelectionInfoToolbar || self.showCameraButton) {
|
||||||
|
[self updateToolbar];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)deselectAsset:(PHAsset *)asset
|
||||||
|
{
|
||||||
|
[self.selectedAssets removeObjectAtIndex:[self.selectedAssets indexOfObject:asset]];
|
||||||
|
if (self.selectedAssets.count == 0) {
|
||||||
|
[self updateDoneButton];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.displaySelectionInfoToolbar || self.showCameraButton) {
|
||||||
|
[self updateToolbar];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateDoneButton
|
||||||
|
{
|
||||||
|
if (!self.allowsMultipleSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UINavigationController *nav = (UINavigationController *)self.childViewControllers[0];
|
||||||
|
for (UIViewController *viewController in nav.viewControllers) {
|
||||||
|
viewController.navigationItem.rightBarButtonItem.enabled = (self.autoDisableDoneButton ? self.selectedAssets.count > 0 : TRUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateToolbar
|
||||||
|
{
|
||||||
|
if (!self.allowsMultipleSelection && !self.showCameraButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UINavigationController *nav = (UINavigationController *)self.childViewControllers[0];
|
||||||
|
for (UIViewController *viewController in nav.viewControllers) {
|
||||||
|
NSUInteger index = 1;
|
||||||
|
if (_showCameraButton) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
[[viewController.toolbarItems objectAtIndex:index] setTitleTextAttributes:[self toolbarTitleTextAttributes] forState:UIControlStateNormal];
|
||||||
|
[[viewController.toolbarItems objectAtIndex:index] setTitleTextAttributes:[self toolbarTitleTextAttributes] forState:UIControlStateDisabled];
|
||||||
|
[[viewController.toolbarItems objectAtIndex:index] setTitle:[self toolbarTitle]];
|
||||||
|
[viewController.navigationController setToolbarHidden:(self.selectedAssets.count == 0 && !self.showCameraButton) animated:YES];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - User finish Actions
|
||||||
|
|
||||||
|
- (void)dismiss:(id)sender
|
||||||
|
{
|
||||||
|
if ([self.delegate respondsToSelector:@selector(assetsPickerControllerDidCancel:)]) {
|
||||||
|
[self.delegate assetsPickerControllerDidCancel:self];
|
||||||
|
}
|
||||||
|
|
||||||
|
[self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
- (void)finishPickingAssets:(id)sender
|
||||||
|
{
|
||||||
|
if ([self.delegate respondsToSelector:@selector(assetsPickerController:didFinishPickingAssets:)]) {
|
||||||
|
[self.delegate assetsPickerController:self didFinishPickingAssets:self.selectedAssets];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Toolbar Title
|
||||||
|
|
||||||
|
- (NSPredicate *)predicateOfAssetType:(PHAssetMediaType)type
|
||||||
|
{
|
||||||
|
return [NSPredicate predicateWithBlock:^BOOL(PHAsset *asset, NSDictionary *bindings) {
|
||||||
|
return (asset.mediaType == type);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)toolbarTitle
|
||||||
|
{
|
||||||
|
if (self.selectedAssets.count == 0) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSPredicate *photoPredicate = [self predicateOfAssetType:PHAssetMediaTypeImage];
|
||||||
|
NSPredicate *videoPredicate = [self predicateOfAssetType:PHAssetMediaTypeVideo];
|
||||||
|
|
||||||
|
NSInteger nImages = [self.selectedAssets filteredArrayUsingPredicate:photoPredicate].count;
|
||||||
|
NSInteger nVideos = [self.selectedAssets filteredArrayUsingPredicate:videoPredicate].count;
|
||||||
|
|
||||||
|
if (nImages > 0 && nVideos > 0) {
|
||||||
|
return [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"picker.selection.multiple-items", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"%@ Items Selected" ), @(nImages + nVideos)];
|
||||||
|
} else if (nImages > 1) {
|
||||||
|
return [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"picker.selection.multiple-photos", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"%@ Photos Selected"), @(nImages)];
|
||||||
|
} else if (nImages == 1) {
|
||||||
|
return NSLocalizedStringFromTableInBundle(@"picker.selection.single-photo", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"1 Photo Selected" );
|
||||||
|
} else if (nVideos > 1) {
|
||||||
|
return [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"picker.selection.multiple-videos", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"%@ Videos Selected"), @(nVideos)];
|
||||||
|
} else if (nVideos == 1) {
|
||||||
|
return NSLocalizedStringFromTableInBundle(@"picker.selection.single-video", @"GMImagePicker", [NSBundle bundleForClass:GMImagePickerController.class], @"1 Video Selected");
|
||||||
|
} else {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Toolbar Items
|
||||||
|
|
||||||
|
- (void)cameraButtonPressed:(UIBarButtonItem *)button
|
||||||
|
{
|
||||||
|
if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) {
|
||||||
|
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"No Camera!"
|
||||||
|
message:@"Sorry, this device does not have a camera."
|
||||||
|
delegate:nil
|
||||||
|
cancelButtonTitle:@"OK"
|
||||||
|
otherButtonTitles:nil];
|
||||||
|
[alert show];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This allows the selection of the image taken to be better seen if the user is not already in that VC
|
||||||
|
if (self.autoSelectCameraImages && [self.navigationController.topViewController isKindOfClass:[GMAlbumsViewController class]]) {
|
||||||
|
[((GMAlbumsViewController *)self.navigationController.topViewController) selectAllAlbumsCell];
|
||||||
|
}
|
||||||
|
|
||||||
|
UIImagePickerController *picker = [[UIImagePickerController alloc] init];
|
||||||
|
picker.sourceType = UIImagePickerControllerSourceTypeCamera;
|
||||||
|
picker.mediaTypes = @[(NSString *)kUTTypeImage];
|
||||||
|
picker.allowsEditing = self.allowsEditingCameraImages;
|
||||||
|
picker.delegate = self;
|
||||||
|
picker.modalPresentationStyle = UIModalPresentationPopover;
|
||||||
|
|
||||||
|
UIPopoverPresentationController *popPC = picker.popoverPresentationController;
|
||||||
|
popPC.permittedArrowDirections = UIPopoverArrowDirectionAny;
|
||||||
|
popPC.barButtonItem = button;
|
||||||
|
|
||||||
|
[self showViewController:picker sender:button];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSDictionary *)toolbarTitleTextAttributes {
|
||||||
|
return @{NSForegroundColorAttributeName : _toolbarTextColor,
|
||||||
|
NSFontAttributeName : [UIFont fontWithName:_pickerFontName size:_pickerFontHeaderSize]};
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIBarButtonItem *)titleButtonItem
|
||||||
|
{
|
||||||
|
UIBarButtonItem *title = [[UIBarButtonItem alloc] initWithTitle:self.toolbarTitle
|
||||||
|
style:UIBarButtonItemStylePlain
|
||||||
|
target:nil
|
||||||
|
action:nil];
|
||||||
|
|
||||||
|
NSDictionary *attributes = [self toolbarTitleTextAttributes];
|
||||||
|
[title setTitleTextAttributes:attributes forState:UIControlStateNormal];
|
||||||
|
[title setTitleTextAttributes:attributes forState:UIControlStateDisabled];
|
||||||
|
[title setEnabled:NO];
|
||||||
|
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIBarButtonItem *)spaceButtonItem
|
||||||
|
{
|
||||||
|
return [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIBarButtonItem *)cameraButtonItem
|
||||||
|
{
|
||||||
|
return [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCamera target:self action:@selector(cameraButtonPressed:)];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray *)toolbarItems
|
||||||
|
{
|
||||||
|
UIBarButtonItem *camera = [self cameraButtonItem];
|
||||||
|
UIBarButtonItem *title = [self titleButtonItem];
|
||||||
|
UIBarButtonItem *space = [self spaceButtonItem];
|
||||||
|
|
||||||
|
NSMutableArray *items = [[NSMutableArray alloc] init];
|
||||||
|
if (_showCameraButton) {
|
||||||
|
[items addObject:camera];
|
||||||
|
}
|
||||||
|
[items addObject:space];
|
||||||
|
[items addObject:title];
|
||||||
|
[items addObject:space];
|
||||||
|
|
||||||
|
return [NSArray arrayWithArray:items];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Camera Delegate
|
||||||
|
|
||||||
|
- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary<NSString *,id> *)info
|
||||||
|
{
|
||||||
|
[picker.presentingViewController dismissViewControllerAnimated:YES completion:nil];
|
||||||
|
|
||||||
|
NSString *mediaType = info[UIImagePickerControllerMediaType];
|
||||||
|
if ([mediaType isEqualToString:(NSString *)kUTTypeImage]) {
|
||||||
|
UIImage *image = info[UIImagePickerControllerEditedImage] ? : info[UIImagePickerControllerOriginalImage];
|
||||||
|
UIImageWriteToSavedPhotosAlbum(image,
|
||||||
|
self,
|
||||||
|
@selector(image:finishedSavingWithError:contextInfo:),
|
||||||
|
nil);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
-(void)imagePickerControllerDidCancel:(UIImagePickerController *)picker
|
||||||
|
{
|
||||||
|
[picker.presentingViewController dismissViewControllerAnimated:YES completion:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
-(void)image:(UIImage *)image finishedSavingWithError:(NSError *)error contextInfo:(void *)contextInfo
|
||||||
|
{
|
||||||
|
if (error) {
|
||||||
|
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Image Not Saved"
|
||||||
|
message:@"Sorry, unable to save the new image!"
|
||||||
|
delegate:nil
|
||||||
|
cancelButtonTitle:@"OK"
|
||||||
|
otherButtonTitles:nil];
|
||||||
|
[alert show];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: The image view will auto refresh as the photo's are being observed in the other VCs
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 158 B |
After Width: | Height: | Size: 194 B |
|
@ -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>
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit ed572f53ce58b8e23499abeb3a926033cbe480f7
|
|
@ -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>
|
|
|
@ -1,29 +0,0 @@
|
||||||
//
|
|
||||||
// Action.js
|
|
||||||
// OpenInTusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 5/22/21.
|
|
||||||
// Copyright © 2021 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
var Action = function() {};
|
|
||||||
|
|
||||||
Action.prototype = {
|
|
||||||
|
|
||||||
run: function(arguments) {
|
|
||||||
const results = {
|
|
||||||
url: window.location.href,
|
|
||||||
};
|
|
||||||
const el = document.querySelector('link[rel=alternate][type="application/activity+json"]');
|
|
||||||
if (el) {
|
|
||||||
results.activityPubURL = el.href;
|
|
||||||
}
|
|
||||||
arguments.completionFunction(results);
|
|
||||||
},
|
|
||||||
|
|
||||||
finalize: function(arguments) {
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
var ExtensionPreprocessingJS = new Action();
|
|
|
@ -1,105 +0,0 @@
|
||||||
//
|
|
||||||
// ActionViewController.swift
|
|
||||||
// OpenInTusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 5/23/21.
|
|
||||||
// Copyright © 2021 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import MobileCoreServices
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
class ActionViewController: UIViewController {
|
|
||||||
|
|
||||||
@IBOutlet weak var imageView: UIImageView!
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findURLFromWebPage(completion: @escaping @Sendable (URLComponents?) -> Void) {
|
|
||||||
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
|
||||||
for provider in item.attachments! {
|
|
||||||
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, 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,
|
|
||||||
let components = URLComponents(string: urlString) else {
|
|
||||||
completion(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
completion(components)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
completion(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findURLItem(completion: @escaping @Sendable (URLComponents?) -> Void) {
|
|
||||||
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
|
||||||
for provider in item.attachments! {
|
|
||||||
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (result, error) in
|
|
||||||
guard let result = result as? URL,
|
|
||||||
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
|
|
||||||
completion(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(components)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
completion(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func searchForURLInApp(_ components: URLComponents) {
|
|
||||||
var components = components
|
|
||||||
components.scheme = "tusker"
|
|
||||||
self.openURL(components.url!)
|
|
||||||
self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func openURL(_ url: URL) {
|
|
||||||
var responder: UIResponder = self
|
|
||||||
while let parent = responder.next {
|
|
||||||
if let application = parent as? UIApplication {
|
|
||||||
application.perform(#selector(openURL(_:)), with: url)
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
responder = parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBAction func done() {
|
|
||||||
extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,65 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="ObA-dk-sSI">
|
|
||||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
|
||||||
<dependencies>
|
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
|
||||||
</dependencies>
|
|
||||||
<scenes>
|
|
||||||
<!--Image-->
|
|
||||||
<scene sceneID="7MM-of-jgj">
|
|
||||||
<objects>
|
|
||||||
<viewController title="Image" id="ObA-dk-sSI" customClass="ActionViewController" customModule="OpenInTusker" customModuleProvider="target" sceneMemberID="viewController">
|
|
||||||
<view key="view" contentMode="scaleToFill" id="zMn-AG-sqS">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="320" height="528"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
|
||||||
<subviews>
|
|
||||||
<navigationBar contentMode="scaleToFill" horizontalCompressionResistancePriority="751" verticalCompressionResistancePriority="751" translatesAutoresizingMaskIntoConstraints="NO" id="NOA-Dm-cuz">
|
|
||||||
<rect key="frame" x="0.0" y="44" width="320" height="44"/>
|
|
||||||
<items>
|
|
||||||
<navigationItem id="3HJ-uW-3hn">
|
|
||||||
<barButtonItem key="leftBarButtonItem" title="Done" style="done" id="WYi-yp-eM6">
|
|
||||||
<connections>
|
|
||||||
<action selector="done" destination="ObA-dk-sSI" id="Qdu-qn-U6V"/>
|
|
||||||
</connections>
|
|
||||||
</barButtonItem>
|
|
||||||
</navigationItem>
|
|
||||||
</items>
|
|
||||||
</navigationBar>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Unable to find Mastodon link on this page." textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yho-gp-VyR">
|
|
||||||
<rect key="frame" x="0.0" y="254" width="320" height="20.5"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
|
||||||
<nil key="textColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
<viewLayoutGuide key="safeArea" id="VVe-Uw-JpX"/>
|
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="VVe-Uw-JpX" firstAttribute="trailing" secondItem="NOA-Dm-cuz" secondAttribute="trailing" id="A05-Pj-hrr"/>
|
|
||||||
<constraint firstItem="NOA-Dm-cuz" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="HxO-8t-aoh"/>
|
|
||||||
<constraint firstItem="Yho-gp-VyR" firstAttribute="centerY" secondItem="zMn-AG-sqS" secondAttribute="centerY" id="R7q-OB-hhA"/>
|
|
||||||
<constraint firstItem="Yho-gp-VyR" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="TEy-zi-dP7"/>
|
|
||||||
<constraint firstItem="Yho-gp-VyR" firstAttribute="trailing" secondItem="VVe-Uw-JpX" secondAttribute="trailing" id="Uvn-0x-Y6N"/>
|
|
||||||
<constraint firstItem="NOA-Dm-cuz" firstAttribute="top" secondItem="VVe-Uw-JpX" secondAttribute="top" id="we0-1t-bgp"/>
|
|
||||||
</constraints>
|
|
||||||
</view>
|
|
||||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
|
||||||
<size key="freeformSize" width="320" height="528"/>
|
|
||||||
<connections>
|
|
||||||
<outlet property="view" destination="zMn-AG-sqS" id="Qma-de-2ek"/>
|
|
||||||
</connections>
|
|
||||||
</viewController>
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="X47-rx-isc" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
|
||||||
</objects>
|
|
||||||
<point key="canvasLocation" x="-61" y="-57"/>
|
|
||||||
</scene>
|
|
||||||
</scenes>
|
|
||||||
<resources>
|
|
||||||
<systemColor name="systemBackgroundColor">
|
|
||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
</systemColor>
|
|
||||||
</resources>
|
|
||||||
</document>
|
|
|
@ -1,57 +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>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>Open in Tusker</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>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>$(MARKETING_VERSION)</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
|
||||||
<key>NSExtension</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExtensionAttributes</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExtensionServiceRoleType</key>
|
|
||||||
<string>NSExtensionServiceRoleTypeViewer</string>
|
|
||||||
<key>NSExtensionActivationRule</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
</dict>
|
|
||||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
|
||||||
<string>Action</string>
|
|
||||||
<key>NSExtensionServiceAllowsFinderPreviewItem</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSExtensionServiceAllowsTouchBarItem</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSExtensionServiceFinderPreviewIconName</key>
|
|
||||||
<string>NSActionTemplate</string>
|
|
||||||
<key>NSExtensionServiceTouchBarBezelColorName</key>
|
|
||||||
<string>TouchBarBezel</string>
|
|
||||||
<key>NSExtensionServiceTouchBarIconName</key>
|
|
||||||
<string>NSActionTemplate</string>
|
|
||||||
</dict>
|
|
||||||
<key>NSExtensionMainStoryboard</key>
|
|
||||||
<string>MainInterface</string>
|
|
||||||
<key>NSExtensionPointIdentifier</key>
|
|
||||||
<string>com.apple.ui-services</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
Before Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 900 B |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.2 KiB |
|
@ -1,103 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "60x60@2x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "60x60"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "60x60@3x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "60x60"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "76x76"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "76x76@2x.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "76x76"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "83.5x83.5@2x.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "83.5x83.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "1024x1024@1x.png",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
},
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"color" : {
|
|
||||||
"reference" : "systemPurpleColor"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -0,0 +1,319 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
lazy var decoder: JSONDecoder = {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
return decoder
|
||||||
|
}()
|
||||||
|
|
||||||
|
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.accessToken = accessToken
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) {
|
||||||
|
guard let request = createURLRequest(request: request) else {
|
||||||
|
completion(.failure(Error.invalidRequest))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = session.dataTask(with: request) { data, response, error in
|
||||||
|
if let error = error {
|
||||||
|
completion(.failure(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = data,
|
||||||
|
let response = response as? HTTPURLResponse else {
|
||||||
|
completion(.failure(Error.invalidResponse))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard response.statusCode == 200 else {
|
||||||
|
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
|
||||||
|
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
|
||||||
|
completion(.failure(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let result = try? self.decoder.decode(Result.self, from: data) else {
|
||||||
|
completion(.failure(Error.invalidModel))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if var result = result as? ClientModel {
|
||||||
|
result.client = self
|
||||||
|
} else if var result = result as? [ClientModel] {
|
||||||
|
result.client = self
|
||||||
|
}
|
||||||
|
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
||||||
|
|
||||||
|
completion(.success(result, pagination))
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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")
|
||||||
|
}
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
return urlRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
|
||||||
|
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
|
||||||
|
"client_name" => name,
|
||||||
|
"redirect_uris" => redirectURI,
|
||||||
|
"scopes" => scopes.scopeString,
|
||||||
|
"website" => website?.absoluteString
|
||||||
|
]))
|
||||||
|
run(request) { result in
|
||||||
|
defer { completion(result) }
|
||||||
|
guard case let .success(application, _) = result else { return }
|
||||||
|
|
||||||
|
self.appID = application.id
|
||||||
|
self.clientID = application.clientID
|
||||||
|
self.clientSecret = application.clientSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
||||||
|
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
|
||||||
|
"client_id" => clientID,
|
||||||
|
"client_secret" => clientSecret,
|
||||||
|
"grant_type" => "authorization_code",
|
||||||
|
"code" => authorizationCode,
|
||||||
|
"redirect_uri" => redirectURI
|
||||||
|
]))
|
||||||
|
run(request) { result in
|
||||||
|
defer { completion(result) }
|
||||||
|
guard case let .success(loginSettings, _) = result else { return }
|
||||||
|
|
||||||
|
self.accessToken = loginSettings.accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Self
|
||||||
|
public func getSelfAccount() -> Request<Account> {
|
||||||
|
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getFavourites() -> Request<[Status]> {
|
||||||
|
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
|
||||||
|
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getInstance() -> Request<Instance> {
|
||||||
|
return Request<Instance>(method: .get, path: "/api/v1/instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getCustomEmoji() -> Request<[Emoji]> {
|
||||||
|
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Accounts
|
||||||
|
public func getAccount(id: String) -> Request<Account> {
|
||||||
|
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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 func getBlocks() -> Request<[Account]> {
|
||||||
|
return Request<[Account]>(method: .get, path: "/api/v1/blocks")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getDomainBlocks() -> Request<[String]> {
|
||||||
|
return Request<[String]>(method: .get, path: "api/v1/domain_blocks")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func block(domain: String) -> Request<Empty> {
|
||||||
|
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
|
||||||
|
"domain" => domain
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unblock(domain: String) -> Request<Empty> {
|
||||||
|
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
|
||||||
|
"domain" => domain
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filters
|
||||||
|
public func getFilters() -> Request<[Filter]> {
|
||||||
|
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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: .parameters([
|
||||||
|
"phrase" => phrase,
|
||||||
|
"irreversible" => irreversible,
|
||||||
|
"whole_word" => wholeWord,
|
||||||
|
"expires_at" => expiresAt
|
||||||
|
] + "context" => context.contextStrings))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getFilter(id: String) -> Request<Filter> {
|
||||||
|
return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Follows
|
||||||
|
public func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> {
|
||||||
|
var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
|
||||||
|
request.range = range
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getFollowSuggestions() -> Request<[Account]> {
|
||||||
|
return Request<[Account]>(method: .get, path: "/api/v1/suggestions")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func followRemote(acct: String) -> Request<Account> {
|
||||||
|
return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lists
|
||||||
|
public func getLists() -> Request<[List]> {
|
||||||
|
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getList(id: String) -> Request<List> {
|
||||||
|
return Request<List>(method: .get, path: "/api/v1/lists/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createList(title: String) -> Request<List> {
|
||||||
|
return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Media
|
||||||
|
public func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
||||||
|
return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
|
||||||
|
"description" => description,
|
||||||
|
"focus" => focus
|
||||||
|
], attachment))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mutes
|
||||||
|
public func getMutes(range: RequestRange) -> Request<[Account]> {
|
||||||
|
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
||||||
|
request.range = range
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
public 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 func clearNotifications() -> Request<Empty> {
|
||||||
|
return Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reports
|
||||||
|
public func getReports() -> Request<[Report]> {
|
||||||
|
return Request<[Report]>(method: .get, path: "/api/v1/reports")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
|
||||||
|
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
|
||||||
|
"account_id" => account.id,
|
||||||
|
"comment" => comment
|
||||||
|
] + "status_ids" => statuses.map { $0.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Search
|
||||||
|
public func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
|
||||||
|
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
||||||
|
"q" => query,
|
||||||
|
"resolve" => resolve,
|
||||||
|
"limit" => limit
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Statuses
|
||||||
|
public func getStatus(id: String) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
|
||||||
|
"status" => text,
|
||||||
|
"content_type" => contentType.mimeType,
|
||||||
|
"in_reply_to_id" => inReplyTo,
|
||||||
|
"sensitive" => sensitive,
|
||||||
|
"spoiler_text" => spoilerText,
|
||||||
|
"visibility" => visibility?.rawValue,
|
||||||
|
"language" => language
|
||||||
|
] + "media_ids" => media?.map { $0.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timelines
|
||||||
|
public func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
||||||
|
return timeline.request(range: range)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Client {
|
||||||
|
public enum Error: Swift.Error {
|
||||||
|
case unknownError
|
||||||
|
case invalidRequest
|
||||||
|
case invalidResponse
|
||||||
|
case invalidModel
|
||||||
|
case mastodonError(String)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// ClientModel.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol ClientModel {
|
||||||
|
var client: Client! { get set }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == ClientModel {
|
||||||
|
var client: Client! {
|
||||||
|
get {
|
||||||
|
return first?.client
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
for var el in self {
|
||||||
|
el.client = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element: ClientModel {
|
||||||
|
var client: Client! {
|
||||||
|
get {
|
||||||
|
return first?.client
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
for var el in self {
|
||||||
|
el.client = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
import Foundation
|
||||||
|
|
||||||
public final class Account: AccountProtocol, Decodable, Sendable {
|
public class Account: Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let username: String
|
public let username: String
|
||||||
public let acct: String
|
public let acct: String
|
||||||
|
@ -20,87 +20,78 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
||||||
public let statusesCount: Int
|
public let statusesCount: Int
|
||||||
public let note: String
|
public let note: String
|
||||||
public let url: URL
|
public let url: URL
|
||||||
// required on mastodon, but optional on gotosocial
|
public let avatar: URL
|
||||||
public let avatar: URL?
|
public let avatarStatic: URL
|
||||||
public let avatarStatic: URL?
|
|
||||||
public let header: URL?
|
public let header: URL?
|
||||||
public let headerStatic: URL?
|
public let headerStatic: URL?
|
||||||
public let emojis: [Emoji]
|
public private(set) var emojis: [Emoji]
|
||||||
public let moved: Bool?
|
public let moved: Bool?
|
||||||
public let movedTo: Account?
|
public let fields: [Field]?
|
||||||
public let fields: [Field]
|
|
||||||
public let bot: Bool?
|
public let bot: Bool?
|
||||||
|
|
||||||
|
// 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 {
|
public required init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
self.username = try container.decode(String.self, forKey: .username)
|
self.username = try container.decode(String.self, forKey: .username)
|
||||||
self.acct = try container.decode(String.self, forKey: .acct)
|
self.acct = try container.decode(String.self, forKey: .acct)
|
||||||
self.displayName = try container.decode(String.self, forKey: .displayName)
|
self.displayName = try container.decode(String.self, forKey: .displayName)
|
||||||
self.locked = try container.decode(Bool.self, forKey: .locked)
|
self.locked = try container.decode(Bool.self, forKey: .locked)
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
|
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
|
||||||
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
|
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
|
||||||
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
|
||||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||||
self.note = try container.decode(String.self, forKey: .note)
|
self.note = try container.decode(String.self, forKey: .note)
|
||||||
self.url = try container.decode(URL.self, forKey: .url)
|
self.url = try container.decode(URL.self, forKey: .url)
|
||||||
self.avatar = try? container.decode(URL.self, forKey: .avatar)
|
self.avatar = try container.decode(URL.self, forKey: .avatar)
|
||||||
self.avatarStatic = try? container.decode(URL.self, forKey: .avatarStatic)
|
self.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic)
|
||||||
self.header = try? container.decode(URL.self, forKey: .header)
|
if let header = try? container.decodeIfPresent(String.self, forKey: .header),
|
||||||
self.headerStatic = try? container.decode(URL.self, forKey: .headerStatic)
|
let url = URL(string: header) {
|
||||||
// even up-to-date pixelfed instances sometimes lack this, for reasons unclear
|
self.header = url
|
||||||
if let emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) {
|
|
||||||
self.emojis = emojis
|
|
||||||
} else {
|
} else {
|
||||||
self.emojis = []
|
self.header = nil
|
||||||
}
|
}
|
||||||
self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? []
|
if let headerStatic = try? container.decodeIfPresent(String.self, forKey: .headerStatic),
|
||||||
self.bot = try? container.decode(Bool.self, forKey: .bot)
|
let url = URL(string: headerStatic) {
|
||||||
|
self.headerStatic = url
|
||||||
if let moved = try? container.decode(Bool.self, forKey: .moved) {
|
|
||||||
self.moved = moved
|
|
||||||
self.movedTo = nil
|
|
||||||
} else if let account = try? container.decode(Account.self, forKey: .moved) {
|
|
||||||
self.moved = true
|
|
||||||
self.movedTo = account
|
|
||||||
} else {
|
} else {
|
||||||
self.moved = false
|
self.headerStatic = nil
|
||||||
self.movedTo = nil
|
|
||||||
}
|
}
|
||||||
|
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
|
||||||
|
self.moved = try container.decodeIfPresent(Bool.self, forKey: .moved)
|
||||||
|
self.fields = try container.decodeIfPresent([Field].self, forKey: .fields)
|
||||||
|
self.bot = try container.decodeIfPresent(Bool.self, forKey: .bot)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func authorizeFollowRequest(_ accountID: String) -> Request<Relationship> {
|
public static func authorizeFollowRequest(_ account: Account) -> Request<Empty> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize")
|
return Request<Empty>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func rejectFollowRequest(_ accountID: String) -> Request<Relationship> {
|
public static func rejectFollowRequest(_ account: Account) -> Request<Empty> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject")
|
return Request<Empty>(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {
|
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFollowers(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getFollowers(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/followers")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/followers")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFollowing(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getFollowing(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/following")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/following")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
|
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil) -> Request<[Status]> {
|
||||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||||
"only_media" => onlyMedia,
|
"only_media" => onlyMedia,
|
||||||
"pinned" => pinned,
|
"pinned" => pinned,
|
||||||
"exclude_replies" => excludeReplies,
|
"exclude_replies" => excludeReplies
|
||||||
"exclude_reblogs" => excludeReblogs,
|
|
||||||
])
|
])
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
|
@ -110,32 +101,26 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/follow")
|
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> {
|
public static func unfollow(_ accountID: String) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func block(_ accountID: String) -> Request<Relationship> {
|
public static func block(_ account: Account) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/block")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/block")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unblock(_ accountID: String) -> Request<Relationship> {
|
public static func unblock(_ account: Account) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unblock")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unblock")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func mute(_ accountID: String, notifications: Bool? = nil) -> Request<Relationship> {
|
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/mute", body: ParametersBody([
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: .parameters([
|
||||||
"notifications" => notifications
|
"notifications" => notifications
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unmute(_ accountID: String) -> Request<Relationship> {
|
public static func unmute(_ account: Account) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unmute")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unmute")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getLists(_ account: Account) -> Request<[List]> {
|
public static func getLists(_ account: Account) -> Request<[List]> {
|
||||||
|
@ -172,15 +157,8 @@ extension Account: CustomDebugStringConvertible {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Account {
|
extension Account {
|
||||||
public struct Field: Codable, Equatable, Sendable {
|
public struct Field: Codable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let value: String
|
public let value: String
|
||||||
public let verifiedAt: Date?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case name
|
|
||||||
case value
|
|
||||||
case verifiedAt = "verified_at"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
//
|
||||||
|
// Application.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class Application: Decodable {
|
||||||
|
public let name: String
|
||||||
|
public let website: URL?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case website
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,44 +8,41 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Attachment: Codable, Sendable {
|
public class Attachment: Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Kind
|
public let kind: Kind
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let remoteURL: URL?
|
public let remoteURL: URL?
|
||||||
public let previewURL: URL?
|
public let previewURL: URL
|
||||||
|
public let textURL: URL?
|
||||||
public let meta: Metadata?
|
public let meta: Metadata?
|
||||||
public let description: String?
|
public let description: String?
|
||||||
public let blurHash: String?
|
|
||||||
|
|
||||||
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> {
|
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> {
|
||||||
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: FormDataBody([
|
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: .formData([
|
||||||
"description" => (description ?? attachment.description),
|
"description" => (description ?? attachment.description),
|
||||||
"focus" => focus
|
"focus" => focus
|
||||||
], nil))
|
], nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(id: String, kind: Attachment.Kind, url: URL, remoteURL: URL? = nil, previewURL: URL? = nil, meta: Attachment.Metadata? = nil, description: String? = nil, blurHash: String? = nil) {
|
required public init(from decoder: Decoder) throws {
|
||||||
self.id = id
|
|
||||||
self.kind = kind
|
|
||||||
self.url = url
|
|
||||||
self.remoteURL = remoteURL
|
|
||||||
self.previewURL = previewURL
|
|
||||||
self.meta = meta
|
|
||||||
self.description = description
|
|
||||||
self.blurHash = blurHash
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||||
self.url = try container.decode(URL.self, forKey: .url)
|
self.url = URL(lenient: try container.decode(String.self, forKey: .url))!
|
||||||
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL)
|
if let remote = try? container.decode(String.self, forKey: .remoteURL) {
|
||||||
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL)
|
self.remoteURL = URL(lenient: remote.replacingOccurrences(of: " ", with: "%20"))
|
||||||
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
|
} else {
|
||||||
self.description = try? container.decode(String?.self, forKey: .description)
|
self.remoteURL = nil
|
||||||
self.blurHash = try? container.decode(String?.self, forKey: .blurHash)
|
}
|
||||||
|
self.previewURL = URL(lenient: try container.decode(String.self, forKey: .previewURL).replacingOccurrences(of: " ", with: "%20"))!
|
||||||
|
if let text = try? container.decode(String.self, forKey: .textURL) {
|
||||||
|
self.textURL = URL(lenient: text.replacingOccurrences(of: " ", with: "%20"))
|
||||||
|
} else {
|
||||||
|
self.textURL = nil
|
||||||
|
}
|
||||||
|
self.meta = try? container.decode(Metadata.self, forKey: .meta)
|
||||||
|
self.description = try? container.decode(String.self, forKey: .description)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -54,41 +51,35 @@ public struct Attachment: Codable, Sendable {
|
||||||
case url
|
case url
|
||||||
case remoteURL = "remote_url"
|
case remoteURL = "remote_url"
|
||||||
case previewURL = "preview_url"
|
case previewURL = "preview_url"
|
||||||
|
case textURL = "text_url"
|
||||||
case meta
|
case meta
|
||||||
case description
|
case description
|
||||||
case blurHash = "blurhash"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Attachment {
|
extension Attachment {
|
||||||
public enum Kind: String, Codable, Sendable {
|
public enum Kind: String, Decodable {
|
||||||
case image
|
case image
|
||||||
case video
|
case video
|
||||||
case gifv
|
case gifv
|
||||||
case audio
|
case audio
|
||||||
case unknown
|
case unknown
|
||||||
|
|
||||||
|
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
switch try container.decode(String.self) {
|
let str = try container.decode(String.self)
|
||||||
// gotosocial uses "gif" for gif images
|
if let kind = Kind(rawValue: str.lowercased()) {
|
||||||
case "image", "gif":
|
self = kind
|
||||||
self = .image
|
} else {
|
||||||
case "video":
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Attachment type must be one of image, video, gifv, audio, or unknown.")
|
||||||
self = .video
|
|
||||||
case "gifv":
|
|
||||||
self = .gifv
|
|
||||||
case "audio":
|
|
||||||
self = .audio
|
|
||||||
default:
|
|
||||||
self = .unknown
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Attachment {
|
extension Attachment {
|
||||||
public struct Metadata: Codable, Sendable {
|
public class Metadata: Decodable {
|
||||||
public let length: String?
|
public let length: String?
|
||||||
public let duration: Float?
|
public let duration: Float?
|
||||||
public let audioEncoding: String?
|
public let audioEncoding: String?
|
||||||
|
@ -119,7 +110,7 @@ extension Attachment {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ImageMetadata: Codable, Sendable {
|
public class ImageMetadata: Decodable {
|
||||||
public let width: Int?
|
public let width: Int?
|
||||||
public let height: Int?
|
public let height: Int?
|
||||||
public let size: String?
|
public let size: String?
|
||||||
|
@ -133,3 +124,14 @@ extension Attachment {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate extension URL {
|
||||||
|
private static let allowedChars = CharacterSet.urlHostAllowed.union(.urlPathAllowed).union(.urlQueryAllowed)
|
||||||
|
|
||||||
|
init?(lenient string: String) {
|
||||||
|
guard let escaped = string.addingPercentEncoding(withAllowedCharacters: URL.allowedChars) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(string: escaped)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
//
|
||||||
|
// Card.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class Card: Decodable {
|
||||||
|
public let url: URL
|
||||||
|
public let title: String
|
||||||
|
public let description: String
|
||||||
|
public let image: URL?
|
||||||
|
public let kind: Kind
|
||||||
|
public let authorName: String?
|
||||||
|
public let authorURL: URL?
|
||||||
|
public let providerName: String?
|
||||||
|
public let providerURL: URL?
|
||||||
|
public let html: String?
|
||||||
|
public let width: Int?
|
||||||
|
public let height: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case url
|
||||||
|
case title
|
||||||
|
case description
|
||||||
|
case image
|
||||||
|
case kind = "type"
|
||||||
|
case authorName = "author_name"
|
||||||
|
case authorURL = "author_url"
|
||||||
|
case providerName = "provider_name"
|
||||||
|
case providerURL = "provider_url"
|
||||||
|
case html
|
||||||
|
case width
|
||||||
|
case height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Card {
|
||||||
|
public enum Kind: String, Decodable {
|
||||||
|
case link
|
||||||
|
case photo
|
||||||
|
case video
|
||||||
|
case rich
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct ConversationContext: Decodable, Sendable {
|
public class ConversationContext: Decodable {
|
||||||
public let ancestors: [Status]
|
public let ancestors: [Status]
|
||||||
public let descendants: [Status]
|
public let descendants: [Status]
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// Emoji.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/8/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class Emoji: Decodable {
|
||||||
|
public let shortcode: String
|
||||||
|
public let url: URL
|
||||||
|
public let staticURL: URL
|
||||||
|
public let visibleInPicker: Bool
|
||||||
|
|
||||||
|
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
|
// Pachyderm
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/9/18.
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct FilterV1: Decodable, Sendable {
|
public class Filter: Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let phrase: String
|
public let phrase: String
|
||||||
private let context: [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> {
|
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<FilterV1>(method: .put, path: "/api/v1/filters/\(filterID)", body: ParametersBody([
|
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([
|
||||||
"phrase" => phrase,
|
"phrase" => (phrase ?? filter.phrase),
|
||||||
"whole_word" => wholeWord,
|
"irreversible" => (irreversible ?? filter.irreversible),
|
||||||
"expires_in" => expiresIn,
|
"whole_word" => (wholeWord ?? filter.wholeWord),
|
||||||
] + "context" => context.contextStrings))
|
"expires_at" => (expiresAt ?? filter.expiresAt)
|
||||||
|
] + "context" => (context?.contextStrings ?? filter.context)))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ filterID: String) -> Request<Empty> {
|
public static func delete(_ filter: Filter) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filterID)")
|
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filter.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -44,17 +45,16 @@ public struct FilterV1: Decodable, Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FilterV1 {
|
extension Filter {
|
||||||
public enum Context: String, Decodable, CaseIterable, Sendable {
|
public enum Context: String, Decodable {
|
||||||
case home
|
case home
|
||||||
case notifications
|
case notifications
|
||||||
case `public`
|
case `public`
|
||||||
case thread
|
case thread
|
||||||
case account
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == FilterV1.Context {
|
extension Array where Element == Filter.Context {
|
||||||
var contextStrings: [String] {
|
var contextStrings: [String] {
|
||||||
return map { $0.rawValue }
|
return map { $0.rawValue }
|
||||||
}
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
//
|
||||||
|
// Hashtag.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class Hashtag: Decodable {
|
||||||
|
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: Decodable {
|
||||||
|
public let day: Date
|
||||||
|
public let uses: Int
|
||||||
|
public let accounts: 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.url == rhs.url
|
||||||
|
}
|
||||||
|
|
||||||
|
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,45 @@
|
||||||
|
//
|
||||||
|
// List.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class List: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let title: String
|
||||||
|
|
||||||
|
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: .parameters(["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: [Account]) -> Request<Empty> {
|
||||||
|
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
||||||
|
"account_ids" => accounts.map { $0.id }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func remove(_ list: List, accounts: [Account]) -> Request<Empty> {
|
||||||
|
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
||||||
|
"account_ids" => accounts.map { $0.id }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case title
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct LoginSettings: Decodable, Sendable {
|
public class LoginSettings: Decodable {
|
||||||
public let accessToken: String
|
public let accessToken: String
|
||||||
private let scope: 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: Decodable {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
//
|
||||||
|
// Notification.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class Notification: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let kind: Kind
|
||||||
|
public let createdAt: Date
|
||||||
|
public let account: Account
|
||||||
|
public let status: Status?
|
||||||
|
|
||||||
|
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||||
|
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([
|
||||||
|
"id" => notificationID
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case kind = "type"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case account
|
||||||
|
case status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification {
|
||||||
|
public enum Kind: String, Decodable, CaseIterable {
|
||||||
|
case mention
|
||||||
|
case reblog
|
||||||
|
case favourite
|
||||||
|
case follow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification: Identifiable {}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
//
|
||||||
|
// RegisteredApplication.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class RegisteredApplication: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let clientID: String
|
||||||
|
public let clientSecret: String
|
||||||
|
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if let id = try? container.decode(String.self, forKey: .id) {
|
||||||
|
self.id = id
|
||||||
|
} else if let id = try? container.decode(Int.self, forKey: .id) {
|
||||||
|
self.id = String(id)
|
||||||
|
} else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: CodingKeys.id, in: container, debugDescription: "Expect application id to be string or number")
|
||||||
|
}
|
||||||
|
if let clientID = try? container.decode(String.self, forKey: .clientID) {
|
||||||
|
self.clientID = clientID
|
||||||
|
} else if let clientID = try? container.decode(Int.self, forKey: .clientID) {
|
||||||
|
self.clientID = String(clientID)
|
||||||
|
} else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: CodingKeys.id, in: container, debugDescription: "Expect client id to be string or number")
|
||||||
|
}
|
||||||
|
self.clientSecret = try container.decode(String.self, forKey: .clientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case clientID = "client_id"
|
||||||
|
case clientSecret = "client_secret"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Report: Decodable, Sendable {
|
public class Report: Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let actionTaken: Bool
|
public let actionTaken: Bool
|
||||||
|
|
|
@ -8,11 +8,10 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Scope: String, Sendable {
|
public enum Scope: String {
|
||||||
case read
|
case read
|
||||||
case write
|
case write
|
||||||
case follow
|
case follow
|
||||||
case push
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == Scope {
|
extension Array where Element == Scope {
|
|
@ -8,9 +8,9 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct SearchResults: Decodable, Sendable {
|
public class SearchResults: Decodable {
|
||||||
public let accounts: [Account]
|
public let accounts: [Account]
|
||||||
public let statuses: [TryDecode<Status>]
|
public let statuses: [Status]
|
||||||
public let hashtags: [Hashtag]
|
public let hashtags: [Hashtag]
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
|
@ -0,0 +1,133 @@
|
||||||
|
//
|
||||||
|
// Status.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class Status: 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 static func getContext(_ status: Status) -> Request<ConversationContext> {
|
||||||
|
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/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(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
|
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
|
||||||
|
request.range = range
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
|
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/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(_ status: Status) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func unreblog(_ status: Status) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func favourite(_ status: Status) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func unfavourite(_ status: Status) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func pin(_ status: Status) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/pin")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func unpin(_ status: Status) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unpin")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func muteConversation(_ status: Status) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/mute")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func unmuteConversation(_ status: Status) -> Request<Status> {
|
||||||
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
import Foundation
|
||||||
|
|
||||||
public enum StatusContentType: String, Codable, CaseIterable, Sendable {
|
public enum StatusContentType: String, Codable, CaseIterable {
|
||||||
case plain, markdown, html
|
case plain, markdown, html
|
||||||
|
|
||||||
var mimeType: String {
|
var mimeType: String {
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Timeline: Equatable, Hashable, Sendable {
|
public enum Timeline {
|
||||||
case home
|
case home
|
||||||
case `public`(local: Bool)
|
case `public`(local: Bool)
|
||||||
case tag(hashtag: String)
|
case tag(hashtag: String)
|
||||||
|
@ -17,7 +17,7 @@ public enum Timeline: Equatable, Hashable, Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Timeline {
|
extension Timeline {
|
||||||
var endpoint: Endpoint {
|
var endpoint: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .home:
|
case .home:
|
||||||
return "/api/v1/timelines/home"
|
return "/api/v1/timelines/home"
|
||||||
|
@ -32,14 +32,12 @@ extension Timeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
|
func request(range: RequestRange) -> Request<[Status]> {
|
||||||
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
|
var request = Request<[Status]>(method: .get, path: endpoint)
|
||||||
if case .public(true) = self {
|
if case .public(true) = self {
|
||||||
request.queryParameters.append("local" => true)
|
request.queryParameters.append("local" => true)
|
||||||
}
|
}
|
||||||
request.range = range
|
request.range = range
|
||||||
// 206 can happen when the timeline is being regenerated and therefore is incomplete
|
|
||||||
request.additionalAcceptableHTTPCodes = [206]
|
|
||||||
return request
|
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>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
//
|
||||||
|
// Body.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/8/18.
|
||||||
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Body {
|
||||||
|
case parameters([Parameter]?)
|
||||||
|
case formData([Parameter]?, FormAttachment?)
|
||||||
|
case empty
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Body {
|
||||||
|
private static let boundary: String = "PachydermBoundary"
|
||||||
|
|
||||||
|
var data: Data? {
|
||||||
|
switch self {
|
||||||
|
case let .parameters(parameters):
|
||||||
|
return parameters?.urlEncoded.data(using: .utf8)
|
||||||
|
case let .formData(parameters, attachment):
|
||||||
|
var data = Data()
|
||||||
|
parameters?.forEach { param in
|
||||||
|
guard let value = param.value else { return }
|
||||||
|
data.append("--\(Body.boundary)\r\n")
|
||||||
|
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
|
||||||
|
data.append("\(value)\r\n")
|
||||||
|
}
|
||||||
|
if let attachment = attachment {
|
||||||
|
data.append("--\(Body.boundary)\r\n")
|
||||||
|
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
|
||||||
|
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
|
||||||
|
data.append(attachment.data)
|
||||||
|
data.append("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
data.append("--\(Body.boundary)--\r\n")
|
||||||
|
return data
|
||||||
|
case .empty:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimeType: String? {
|
||||||
|
switch self {
|
||||||
|
case let .parameters(parameters):
|
||||||
|
if parameters == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "application/x-www-form-urlencoded; charset=utf-8"
|
||||||
|
case let .formData(parameters, attachment):
|
||||||
|
if parameters == nil && attachment == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "multipart/form-data; boundary=\(Body.boundary)"
|
||||||
|
case .empty:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct FormAttachment: Sendable {
|
public struct FormAttachment {
|
||||||
let mimeType: String
|
let mimeType: String
|
||||||
let data: Data
|
let data: Data
|
||||||
let fileName: String
|
let fileName: String
|
|
@ -8,12 +8,12 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Method: Sendable {
|
enum Method {
|
||||||
case get, post, put, patch, delete
|
case get, post, put, patch, delete
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Method {
|
extension Method {
|
||||||
public var name: String {
|
var name: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .get:
|
case .get:
|
||||||
return "GET"
|
return "GET"
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Parameter: Sendable {
|
struct Parameter {
|
||||||
let name: String
|
let name: String
|
||||||
let value: 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 {
|
static func =>(name: String, focus: (Float, Float)?) -> Parameter {
|
||||||
guard let focus = focus else { return Parameter(name: name, value: nil) }
|
guard let focus = focus else { return Parameter(name: name, value: nil) }
|
||||||
return Parameter(name: name, value: "\(focus.0),\(focus.1)")
|
return Parameter(name: name, value: "\(focus.0),\(focus.1)")
|
||||||
|
@ -56,10 +52,6 @@ extension String {
|
||||||
let name = "\(name)[]"
|
let name = "\(name)[]"
|
||||||
return values.map { Parameter(name: name, value: $0) }
|
return values.map { Parameter(name: name, value: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func =>(name: String, values: [Int]) -> [Parameter] {
|
|
||||||
return name => values.map { $0.description }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Parameter: CustomStringConvertible {
|
extension Parameter: CustomStringConvertible {
|
||||||
|
@ -75,11 +67,8 @@ extension Parameter: CustomStringConvertible {
|
||||||
extension Array where Element == Parameter {
|
extension Array where Element == Parameter {
|
||||||
var urlEncoded: String {
|
var urlEncoded: String {
|
||||||
return compactMap {
|
return compactMap {
|
||||||
guard let value = $0.value,
|
guard let value = $0.value else { return nil }
|
||||||
let escapedValue = value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else {
|
return "\($0.name)=\(value)"
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return "\($0.name)=\(escapedValue)"
|
|
||||||
}.joined(separator: "&")
|
}.joined(separator: "&")
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,17 +8,15 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Request<ResultType: Decodable>: Sendable {
|
public struct Request<ResultType: Decodable> {
|
||||||
let method: Method
|
let method: Method
|
||||||
let endpoint: Endpoint
|
let path: String
|
||||||
let body: Body
|
let body: Body
|
||||||
var queryParameters: [Parameter]
|
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 = .empty, queryParameters: [Parameter] = []) {
|
||||||
self.method = method
|
self.method = method
|
||||||
self.endpoint = path
|
self.path = path
|
||||||
self.body = body
|
self.body = body
|
||||||
self.queryParameters = queryParameters
|
self.queryParameters = queryParameters
|
||||||
}
|
}
|
|
@ -8,24 +8,11 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum RequestRange: Sendable {
|
public enum RequestRange {
|
||||||
case `default`
|
case `default`
|
||||||
case count(Int)
|
case count(Int)
|
||||||
/// Chronologically immediately before the given ID
|
|
||||||
case before(id: String, count: Int?)
|
case before(id: String, count: Int?)
|
||||||
/// Chronologically immediately after the given ID
|
|
||||||
case after(id: String, count: Int?)
|
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 {
|
extension RequestRange {
|
|
@ -8,6 +8,6 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Empty: Decodable, Sendable {
|
public struct Empty: Decodable {
|
||||||
|
|
||||||
}
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Pagination: Sendable {
|
public struct Pagination {
|
||||||
public let older: RequestRange?
|
public let older: RequestRange?
|
||||||
public let newer: RequestRange?
|
public let newer: RequestRange?
|
||||||
}
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Response<Result: Decodable & Sendable>: Sendable {
|
public enum Response<Result: Decodable> {
|
||||||
case success(Result, Pagination?)
|
case success(Result, Pagination?)
|
||||||
case failure(Client.Error)
|
case failure(Error)
|
||||||
}
|
}
|
|
@ -1,25 +1,24 @@
|
||||||
//
|
//
|
||||||
// CharacterCounter.swift
|
// CharacterCounter.swift
|
||||||
// ComposeUI
|
// Pachyderm
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/29/18.
|
// Created by Shadowfacts on 9/29/18.
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import InstanceFeatures
|
|
||||||
|
|
||||||
public struct CharacterCounter {
|
public struct CharacterCounter {
|
||||||
|
|
||||||
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
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 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)
|
let mentionsRemoved = removeMentions(in: text)
|
||||||
var count = mentionsRemoved.count
|
var count = mentionsRemoved.count
|
||||||
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
||||||
count -= match.range.length
|
count -= match.range.length
|
||||||
count += instanceFeatures.charsReservedPerURL
|
count += 23 // Mastodon link length
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|