Compare commits
4 Commits
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 5821a16ca7 | |
Shadowfacts | bd81c81500 | |
Shadowfacts | a2a2feef58 | |
Shadowfacts | a38f9df3af |
|
@ -1,12 +1,9 @@
|
||||||
|
[submodule "SwiftSoup"]
|
||||||
|
path = SwiftSoup
|
||||||
|
url = git://github.com/scinfu/SwiftSoup.git
|
||||||
[submodule "Cache"]
|
[submodule "Cache"]
|
||||||
path = Cache
|
path = Cache
|
||||||
url = git@github.com:hyperoslo/Cache.git
|
url = git@github.com:hyperoslo/Cache.git
|
||||||
[submodule "Gifu"]
|
[submodule "Gifu"]
|
||||||
path = Gifu
|
path = Gifu
|
||||||
url = git://github.com/kaishin/Gifu.git
|
url = git://github.com/kaishin/Gifu.git
|
||||||
[submodule "Embassy"]
|
|
||||||
path = Embassy
|
|
||||||
url = https://github.com/envoy/Embassy.git
|
|
||||||
[submodule "Ambassador"]
|
|
||||||
path = Ambassador
|
|
||||||
url = https://github.com/envoy/Ambassador.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 |
138
CHANGELOG.md
|
@ -1,138 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## 2020.1 (10)
|
|
||||||
This build is a hotfix for a couple pressing issues. The changelog for the previous build is included below.
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix crash when opening Preferences while signed in with a deleted account
|
|
||||||
- Fix visibility and content warning not being copied when replying to a post
|
|
||||||
|
|
||||||
## 2020.1 (9)
|
|
||||||
The marquee feature of this build is the new and improved Compose screen. It's been rewritten to use SwiftUI, is significantly more resilient to data loss, and now shows the toolbar when the main text field is not focused. It also turns out Apple is surprise-releasing iOS 14 very soon (or possibly already has, depending when you're reading this). For those who were not already on the beta train, iOS 14 brings a number of new features including a sidebar on iPadOS and lots and lots of context menus (a home screen widget is coming Soon™).
|
|
||||||
|
|
||||||
Known Issues:
|
|
||||||
- Pasting images to create attachments when composing a post is not currently supported due to an iOS bug (#109)
|
|
||||||
- Full-size previews do not display in context menus for attachments on the Compose screen due to an iOS issue (#110)
|
|
||||||
|
|
||||||
Features/Improvements:
|
|
||||||
- Rewrite Compose screen using SwiftUI
|
|
||||||
- Prevent draft posts being lost if the app crahes or is killed by the system while composing
|
|
||||||
- Show toolbar while post content is not being edited
|
|
||||||
- Save post visibility in drafts
|
|
||||||
- Move Draw Something action out of the context menu
|
|
||||||
- iOS 14: Use context menus for setting post visibility
|
|
||||||
- Show BlurHash previews for attachments on Mastodon
|
|
||||||
- Add Expand All Content Warnings preference (Preferences -> Behavior)
|
|
||||||
- Add Collapse Long Posts preference (Preferences -> Behavior)
|
|
||||||
- Improve image gallery opening animation
|
|
||||||
- Use fade in/out animations for opening/closing gallery and attachment picker when the Reduce Motion system setting is enabled
|
|
||||||
- iOS 14: Also requires the "Prefer Cross Fade" setting be enabled
|
|
||||||
- Slightly reduce default status font sizes
|
|
||||||
- Add "Direct Message" context menu action to Compose button on profile screen
|
|
||||||
- Allow viewing attachments and navigating through posts/accounts on instance public timelines
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix errors when uploading attachments not displaying
|
|
||||||
- Fix attachments not posting in the correct, user-specified order
|
|
||||||
- Fix accounts displaying with outdated information (avatars, display names, etc.)
|
|
||||||
- Fix Compose not showing button on profile screen
|
|
||||||
- Fix navigation title not being set on profile screen
|
|
||||||
- Fix follow notifications not showing names for users without display names set
|
|
||||||
- iPadOS 14: Fix crash when resizing app in split view mode
|
|
||||||
|
|
||||||
## 2020.1 (8)
|
|
||||||
This is just an emergency build to fix crashes on iOS 13 when selecting attachments. The changelog of the previous build is included below.
|
|
||||||
|
|
||||||
Features/Improvements:
|
|
||||||
- Enlarge tap targets on status reply/favorite/reblog/more buttons
|
|
||||||
- Disable automatic GIF playback when Low Power Mode is enabled
|
|
||||||
- Show custom emoji in user profile field names
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix crash when attempting to add attachments on iOS 13
|
|
||||||
- Fix potential crashes
|
|
||||||
|
|
||||||
## 2020.1 (7)
|
|
||||||
This is the first update since WWDC and the introduction of iOS 14. As such, most of the focus has been on fixing iOS 14-specific problems. However, there are still a couple new features, both for those on the iOS 14 beta and those not.
|
|
||||||
|
|
||||||
Features/Improvements:
|
|
||||||
- Add toggle between Posts, Posts and Replies, and Media on user profiles
|
|
||||||
- Remove 'Show Replies in Profiles' preference
|
|
||||||
- Limit link preview animation to only link text
|
|
||||||
- Add additional context menu actions for statuses, accounts, and hashtags
|
|
||||||
- Add semi-translucent background to image descriptions, so they're legible against light images
|
|
||||||
- iPadOS 14: Add sidebar
|
|
||||||
- When using multitasking on iPad and switching in and out of "compact" mode, the active tab as well as the navigation history for all tabs will be transferred between the sidebar and tab bar modes.
|
|
||||||
- iOS 14: Use context menus on status/account '...' buttons
|
|
||||||
- iOS 14: Replace 'More' status swipe action with 'Share'
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix crash when attempting to change post visibility on iPad
|
|
||||||
- Fix attachment view corners not being rounded
|
|
||||||
- Fix crash when viewing instance public timelines
|
|
||||||
- Fix Preferences button not appearing on My Profile tab
|
|
||||||
- Fix tapping current tab bar item not scrolling to top
|
|
||||||
- Fix crash showing audio attachments on Mastodon
|
|
||||||
- Fix timeline refreshing forever
|
|
||||||
- Set app category (fixes usage not being categorized correctly under Screen Time)
|
|
||||||
- iOS 14: Fix crash when searching for instances
|
|
||||||
- iOS 14: Fix crash when displaying accounts with no pinned posts
|
|
||||||
- iOS 14: Fix crash when displaying search results
|
|
||||||
|
|
||||||
## 2020.1 (6)
|
|
||||||
This is the pre-WWDC update with lots of bugfixes and some small features. There will likely be another build this week to fix any pressing issues that arise from iOS 14.
|
|
||||||
|
|
||||||
Features/Improvements:
|
|
||||||
- Add mute/unmute conversation status action
|
|
||||||
- iPadOS: Add pointer interactions to remove attachment button, gallery view share/dismiss buttons
|
|
||||||
- Disable reblog button for direct/followers-only posts
|
|
||||||
- On Pleroma, the reblog button is still enabled for your own followers-only posts to match Pleroma's "Boost to original audience" feature.
|
|
||||||
- Add preference to always display status visibilities below account avatars
|
|
||||||
- Add preference to show reply indicators for statuses in timelines
|
|
||||||
- Show share/dismiss controls and image description for gifv attachments
|
|
||||||
- 'Share' is currently disabled for gifv attachments, it will be enabled in a future build
|
|
||||||
- Add crash report helper
|
|
||||||
- If the app detects that it crashed the last time it was running, it will allow you to review the crash report and email it to me
|
|
||||||
- Add Recognize Text context menu option for images on the Compose screen
|
|
||||||
- This uses iOS' builtin Vision framework to perform on-device OCR and generate an image description from the recognized text
|
|
||||||
- Tweak attachment previews to always have a 16:9 aspect ratio
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix account/status More actions not working
|
|
||||||
- Improve share sheet loading speed
|
|
||||||
- Fix crash when loading bookmarks
|
|
||||||
- Prompt for Photos access before showing photo picker. Prevents empty sheet displaying.
|
|
||||||
- Fix profile fields not displaying and improve layout
|
|
||||||
- Fix profile header image not displaying the first time an account is loaded
|
|
||||||
- Don't show Follow action for your own account
|
|
||||||
- Fix attachments on the Compose screen being cut-off above the home indicator on iPhone X-style devices
|
|
||||||
- Fix audio being played by other apps pausing when displaying a gifv attachment on Mastodon
|
|
||||||
|
|
||||||
|
|
||||||
## 2020.1 (5)
|
|
||||||
The main focus of this update has been switching to using CoreData internally to cache/synchronize the most up-to-date versions of all statuses. Currently, this does not provide any new functionality, however, it lays the groundwork for several significant features coming in the future, including multiple window support on iPadOS and state restoration/persistence between launches.
|
|
||||||
|
|
||||||
Even though there aren't a huge number of new features in this build, a great deal has changed under the hood. As such, this build may suffer somewhat in the stability department. Please bear with me and report any issues you encounter; you can send me a message on the fediverse, email me at me@shadowfacts.net, or file an issue on the project issue tracker at https://git.shadowfacts.net/shadowfacts/Tusker/issues. Thank you!
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- iPadOS: Add pointer interactions to status action buttons and profile header button
|
|
||||||
- iPadOS: Allow scrolling w/ trackpad/magic mouse to dismiss attachment gallery
|
|
||||||
- iPadOS: Enable interactive push gesture with trackpad/magic mouse
|
|
||||||
- Add drawing attachments using PencilKit
|
|
||||||
- Long-press to open context menu on the 'Add Attachment' button on the Compose screen, select 'Draw Something'
|
|
||||||
- Supports Apple Pencil on iPad, including tilt and pressure sensitivity
|
|
||||||
- Add avatar and instance domain in accounts switcher in Preferences
|
|
||||||
- Show gifv attachments on Mastodon
|
|
||||||
- Currently doesn't show attachment description or share/close buttons
|
|
||||||
- Add 'Clear Cache' option to Preferences -> Advanced for debugging
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix size of attachment previews in context menu
|
|
||||||
- Fix previewing audio/video attachments
|
|
||||||
- Fix incorrect image size during attachment expand/shrink animation
|
|
||||||
- Prevent avatars in grouped action notification from overflowing the cell and hiding the timestamp
|
|
||||||
- Fix text in conversation main statuses not being de-selectable
|
|
||||||
- Fix scroll-to-top sometimes not scrolling all the way to the top
|
|
||||||
- Fix account profile descriptions being squashed in the follow notification account list
|
|
||||||
|
|
||||||
|
|
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>
|
2
Gifu
|
@ -1 +1 @@
|
||||||
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007
|
Subproject commit ed572f53ce58b8e23499abeb3a926033cbe480f7
|
|
@ -26,26 +26,12 @@ public class Client {
|
||||||
|
|
||||||
public var timeoutInterval: TimeInterval = 60
|
public var timeoutInterval: TimeInterval = 60
|
||||||
|
|
||||||
static let decoder: JSONDecoder = {
|
lazy var decoder: JSONDecoder = {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let formatter = DateFormatter()
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
decoder.dateDecodingStrategy = .formatted(formatter)
|
|
||||||
return decoder
|
return decoder
|
||||||
}()
|
}()
|
||||||
|
|
||||||
static let encoder: JSONEncoder = {
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
encoder.dateEncodingStrategy = .formatted(formatter)
|
|
||||||
return encoder
|
|
||||||
}()
|
|
||||||
|
|
||||||
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.accessToken = accessToken
|
self.accessToken = accessToken
|
||||||
|
@ -60,24 +46,29 @@ public class Client {
|
||||||
|
|
||||||
let task = session.dataTask(with: request) { data, response, error in
|
let task = session.dataTask(with: request) { data, response, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(.networkError(error)))
|
completion(.failure(error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data,
|
guard let data = data,
|
||||||
let response = response as? HTTPURLResponse else {
|
let response = response as? HTTPURLResponse else {
|
||||||
completion(.failure(.invalidResponse))
|
completion(.failure(Error.invalidResponse))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard response.statusCode == 200 else {
|
guard response.statusCode == 200 else {
|
||||||
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
|
||||||
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
|
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
|
guard let result = try? self.decoder.decode(Result.self, from: data) else {
|
||||||
completion(.failure(.invalidModel))
|
completion(.failure(Error.invalidModel))
|
||||||
return
|
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)
|
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
||||||
|
|
||||||
completion(.success(result, pagination))
|
completion(.success(result, pagination))
|
||||||
|
@ -97,12 +88,13 @@ public class Client {
|
||||||
if let accessToken = accessToken {
|
if let accessToken = accessToken {
|
||||||
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
}
|
}
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
return urlRequest
|
return urlRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Authorization
|
// MARK: - Authorization
|
||||||
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
|
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: ParametersBody([
|
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
|
||||||
"client_name" => name,
|
"client_name" => name,
|
||||||
"redirect_uris" => redirectURI,
|
"redirect_uris" => redirectURI,
|
||||||
"scopes" => scopes.scopeString,
|
"scopes" => scopes.scopeString,
|
||||||
|
@ -119,7 +111,7 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
||||||
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
|
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
|
||||||
"client_id" => clientID,
|
"client_id" => clientID,
|
||||||
"client_secret" => clientSecret,
|
"client_secret" => clientSecret,
|
||||||
"grant_type" => "authorization_code",
|
"grant_type" => "authorization_code",
|
||||||
|
@ -135,32 +127,32 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Self
|
// MARK: - Self
|
||||||
public static func getSelfAccount() -> Request<Account> {
|
public func getSelfAccount() -> Request<Account> {
|
||||||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFavourites() -> Request<[Status]> {
|
public func getFavourites() -> Request<[Status]> {
|
||||||
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
|
public func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
|
||||||
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getInstance() -> Request<Instance> {
|
public func getInstance() -> Request<Instance> {
|
||||||
return Request<Instance>(method: .get, path: "/api/v1/instance")
|
return Request<Instance>(method: .get, path: "/api/v1/instance")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getCustomEmoji() -> Request<[Emoji]> {
|
public func getCustomEmoji() -> Request<[Emoji]> {
|
||||||
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Accounts
|
// MARK: - Accounts
|
||||||
public static func getAccount(id: String) -> Request<Account> {
|
public func getAccount(id: String) -> Request<Account> {
|
||||||
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil) -> Request<[Account]> {
|
public func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil) -> Request<[Account]> {
|
||||||
return Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [
|
return Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [
|
||||||
"q" => query,
|
"q" => query,
|
||||||
"limit" => limit,
|
"limit" => limit,
|
||||||
|
@ -169,33 +161,33 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Blocks
|
// MARK: - Blocks
|
||||||
public static func getBlocks() -> Request<[Account]> {
|
public func getBlocks() -> Request<[Account]> {
|
||||||
return Request<[Account]>(method: .get, path: "/api/v1/blocks")
|
return Request<[Account]>(method: .get, path: "/api/v1/blocks")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getDomainBlocks() -> Request<[String]> {
|
public func getDomainBlocks() -> Request<[String]> {
|
||||||
return Request<[String]>(method: .get, path: "api/v1/domain_blocks")
|
return Request<[String]>(method: .get, path: "api/v1/domain_blocks")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func block(domain: String) -> Request<Empty> {
|
public func block(domain: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
|
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
|
||||||
"domain" => domain
|
"domain" => domain
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unblock(domain: String) -> Request<Empty> {
|
public func unblock(domain: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
|
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
|
||||||
"domain" => domain
|
"domain" => domain
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filters
|
// MARK: - Filters
|
||||||
public static func getFilters() -> Request<[Filter]> {
|
public func getFilters() -> Request<[Filter]> {
|
||||||
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
|
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
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: ParametersBody([
|
return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
|
||||||
"phrase" => phrase,
|
"phrase" => phrase,
|
||||||
"irreversible" => irreversible,
|
"irreversible" => irreversible,
|
||||||
"whole_word" => wholeWord,
|
"whole_word" => wholeWord,
|
||||||
|
@ -203,55 +195,55 @@ public class Client {
|
||||||
] + "context" => context.contextStrings))
|
] + "context" => context.contextStrings))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFilter(id: String) -> Request<Filter> {
|
public func getFilter(id: String) -> Request<Filter> {
|
||||||
return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
|
return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Follows
|
// MARK: - Follows
|
||||||
public static func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> {
|
public func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFollowSuggestions() -> Request<[Account]> {
|
public func getFollowSuggestions() -> Request<[Account]> {
|
||||||
return Request<[Account]>(method: .get, path: "/api/v1/suggestions")
|
return Request<[Account]>(method: .get, path: "/api/v1/suggestions")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func followRemote(acct: String) -> Request<Account> {
|
public func followRemote(acct: String) -> Request<Account> {
|
||||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Lists
|
// MARK: - Lists
|
||||||
public static func getLists() -> Request<[List]> {
|
public func getLists() -> Request<[List]> {
|
||||||
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getList(id: String) -> Request<List> {
|
public func getList(id: String) -> Request<List> {
|
||||||
return Request<List>(method: .get, path: "/api/v1/lists/\(id)")
|
return Request<List>(method: .get, path: "/api/v1/lists/\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createList(title: String) -> Request<List> {
|
public func createList(title: String) -> Request<List> {
|
||||||
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
|
return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Media
|
// MARK: - Media
|
||||||
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
public func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
||||||
return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
|
return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
|
||||||
"description" => description,
|
"description" => description,
|
||||||
"focus" => focus
|
"focus" => focus
|
||||||
], attachment))
|
], attachment))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mutes
|
// MARK: - Mutes
|
||||||
public static func getMutes(range: RequestRange) -> Request<[Account]> {
|
public func getMutes(range: RequestRange) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
public static func getNotifications(excludeTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
public func getNotifications(excludeTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||||
"exclude_types" => excludeTypes.map { $0.rawValue }
|
"exclude_types" => excludeTypes.map { $0.rawValue }
|
||||||
)
|
)
|
||||||
|
@ -259,24 +251,24 @@ public class Client {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func clearNotifications() -> Request<Empty> {
|
public func clearNotifications() -> Request<Empty> {
|
||||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
|
return Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reports
|
// MARK: - Reports
|
||||||
public static func getReports() -> Request<[Report]> {
|
public func getReports() -> Request<[Report]> {
|
||||||
return Request<[Report]>(method: .get, path: "/api/v1/reports")
|
return Request<[Report]>(method: .get, path: "/api/v1/reports")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
|
public func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
|
||||||
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
|
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
|
||||||
"account_id" => account.id,
|
"account_id" => account.id,
|
||||||
"comment" => comment
|
"comment" => comment
|
||||||
] + "status_ids" => statuses.map { $0.id }))
|
] + "status_ids" => statuses.map { $0.id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Search
|
// MARK: - Search
|
||||||
public static func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
|
public func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
|
||||||
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
||||||
"q" => query,
|
"q" => query,
|
||||||
"resolve" => resolve,
|
"resolve" => resolve,
|
||||||
|
@ -285,19 +277,19 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Statuses
|
// MARK: - Statuses
|
||||||
public static func getStatus(id: String) -> Request<Status> {
|
public func getStatus(id: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
|
return Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createStatus(text: String,
|
public func createStatus(text: String,
|
||||||
contentType: StatusContentType = .plain,
|
contentType: StatusContentType = .plain,
|
||||||
inReplyTo: String? = nil,
|
inReplyTo: String? = nil,
|
||||||
media: [Attachment]? = nil,
|
media: [Attachment]? = nil,
|
||||||
sensitive: Bool? = nil,
|
sensitive: Bool? = nil,
|
||||||
spoilerText: String? = nil,
|
spoilerText: String? = nil,
|
||||||
visibility: Status.Visibility? = nil,
|
visibility: Status.Visibility? = nil,
|
||||||
language: String? = nil) -> Request<Status> {
|
language: String? = nil) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
|
||||||
"status" => text,
|
"status" => text,
|
||||||
"content_type" => contentType.mimeType,
|
"content_type" => contentType.mimeType,
|
||||||
"in_reply_to_id" => inReplyTo,
|
"in_reply_to_id" => inReplyTo,
|
||||||
|
@ -309,47 +301,19 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timelines
|
// MARK: - Timelines
|
||||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
public func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
||||||
return timeline.request(range: range)
|
return timeline.request(range: range)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: Bookmarks
|
|
||||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
|
||||||
request.range = range
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Client {
|
extension Client {
|
||||||
public enum Error: LocalizedError {
|
public enum Error: Swift.Error {
|
||||||
case networkError(Swift.Error)
|
case unknownError
|
||||||
case unexpectedStatus(Int)
|
|
||||||
case invalidRequest
|
case invalidRequest
|
||||||
case invalidResponse
|
case invalidResponse
|
||||||
case invalidModel
|
case invalidModel
|
||||||
case mastodonError(String)
|
case mastodonError(String)
|
||||||
|
|
||||||
public var localizedDescription: String {
|
|
||||||
switch self {
|
|
||||||
case .networkError(let error):
|
|
||||||
return "Network Error: \(error.localizedDescription)"
|
|
||||||
// todo: support more status codes
|
|
||||||
case .unexpectedStatus(413):
|
|
||||||
return "HTTP 413: Payload Too Large"
|
|
||||||
case .unexpectedStatus(let code):
|
|
||||||
return "HTTP Code \(code)"
|
|
||||||
case .invalidRequest:
|
|
||||||
return "Invalid Request"
|
|
||||||
case .invalidResponse:
|
|
||||||
return "Invalid Response"
|
|
||||||
case .invalidModel:
|
|
||||||
return "Invalid Model"
|
|
||||||
case .mastodonError(let error):
|
|
||||||
return "Server Error: \(error)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public final class Account: AccountProtocol, Decodable {
|
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
|
||||||
|
@ -22,17 +22,16 @@ public final class Account: AccountProtocol, Decodable {
|
||||||
public let url: URL
|
public let url: URL
|
||||||
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 private(set) var 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)
|
||||||
|
@ -46,30 +45,30 @@ public final class Account: AccountProtocol, Decodable {
|
||||||
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) {
|
||||||
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
|
self.header = url
|
||||||
self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? []
|
|
||||||
self.bot = try? container.decode(Bool.self, forKey: .bot)
|
|
||||||
|
|
||||||
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.header = nil
|
||||||
self.movedTo = nil
|
|
||||||
}
|
}
|
||||||
|
if let headerStatic = try? container.decodeIfPresent(String.self, forKey: .headerStatic),
|
||||||
|
let url = URL(string: headerStatic) {
|
||||||
|
self.headerStatic = url
|
||||||
|
} else {
|
||||||
|
self.headerStatic = 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(_ account: Account) -> Request<Relationship> {
|
public static func authorizeFollowRequest(_ account: Account) -> Request<Empty> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
|
return Request<Empty>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func rejectFollowRequest(_ account: Account) -> Request<Relationship> {
|
public static func rejectFollowRequest(_ account: Account) -> Request<Empty> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/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> {
|
||||||
|
@ -115,7 +114,7 @@ public final class Account: AccountProtocol, Decodable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func mute(_ account: Account, 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/\(account.id)/mute", body: ParametersBody([
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: .parameters([
|
||||||
"notifications" => notifications
|
"notifications" => notifications
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,19 +12,6 @@ public class Application: Decodable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let website: URL?
|
public let website: URL?
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
self.name = try container.decode(String.self, forKey: .name)
|
|
||||||
|
|
||||||
if let websiteStr = try container.decodeIfPresent(String.self, forKey: .website),
|
|
||||||
let url = URL(string: websiteStr) {
|
|
||||||
self.website = url
|
|
||||||
} else {
|
|
||||||
self.website = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
case website
|
case website
|
||||||
|
|
|
@ -8,19 +8,18 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Attachment: Codable {
|
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 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))
|
||||||
|
@ -30,13 +29,20 @@ public class Attachment: Codable {
|
||||||
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.textURL = try? container.decode(URL?.self, forKey: .textURL)
|
} else {
|
||||||
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
|
self.remoteURL = nil
|
||||||
self.description = try? container.decode(String?.self, forKey: .description)
|
}
|
||||||
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 {
|
||||||
|
@ -48,22 +54,32 @@ public class Attachment: Codable {
|
||||||
case textURL = "text_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 {
|
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 {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let str = try container.decode(String.self)
|
||||||
|
if let kind = Kind(rawValue: str.lowercased()) {
|
||||||
|
self = kind
|
||||||
|
} else {
|
||||||
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Attachment type must be one of image, video, gifv, audio, or unknown.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Attachment {
|
extension Attachment {
|
||||||
public struct Metadata: Codable {
|
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?
|
||||||
|
@ -94,7 +110,7 @@ extension Attachment {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ImageMetadata: Codable {
|
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?
|
||||||
|
@ -108,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -22,23 +22,6 @@ public class Card: Decodable {
|
||||||
public let width: Int?
|
public let width: Int?
|
||||||
public let height: Int?
|
public let height: Int?
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
self.url = try container.decode(URL.self, forKey: .url)
|
|
||||||
self.title = try container.decode(String.self, forKey: .title)
|
|
||||||
self.description = try container.decode(String.self, forKey: .description)
|
|
||||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
|
||||||
self.image = try? container.decode(URL.self, forKey: .image)
|
|
||||||
self.authorName = try? container.decode(String.self, forKey: .authorName)
|
|
||||||
self.authorURL = try? container.decode(URL.self, forKey: .authorURL)
|
|
||||||
self.providerName = try? container.decode(String.self, forKey: .providerName)
|
|
||||||
self.providerURL = try? container.decode(URL.self, forKey: .providerURL)
|
|
||||||
self.html = try? container.decode(String.self, forKey: .html)
|
|
||||||
self.width = try? container.decode(Int.self, forKey: .width)
|
|
||||||
self.height = try? container.decode(Int.self, forKey: .height)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case url
|
case url
|
||||||
case title
|
case title
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Emoji: Codable {
|
public class Emoji: Decodable {
|
||||||
public let shortcode: String
|
public let shortcode: String
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let staticURL: URL
|
public let staticURL: URL
|
||||||
|
|
|
@ -23,7 +23,7 @@ public class Filter: Decodable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||||
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
|
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([
|
||||||
"phrase" => (phrase ?? filter.phrase),
|
"phrase" => (phrase ?? filter.phrase),
|
||||||
"irreversible" => (irreversible ?? filter.irreversible),
|
"irreversible" => (irreversible ?? filter.irreversible),
|
||||||
"whole_word" => (wholeWord ?? filter.wholeWord),
|
"whole_word" => (wholeWord ?? filter.wholeWord),
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Hashtag: Codable {
|
public class Hashtag: Decodable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let history: [History]?
|
public let history: [History]?
|
||||||
|
@ -27,44 +27,11 @@ public class Hashtag: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Hashtag {
|
extension Hashtag {
|
||||||
public class History: Codable {
|
public class History: Decodable {
|
||||||
public let day: Date
|
public let day: Date
|
||||||
public let uses: Int
|
public let uses: Int
|
||||||
public let accounts: Int
|
public let accounts: Int
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
if let day = try? container.decode(Date.self, forKey: .day) {
|
|
||||||
self.day = day
|
|
||||||
} else if let unixTimestamp = try? container.decode(Double.self, forKey: .day) {
|
|
||||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
|
||||||
} else if let str = try? container.decode(String.self, forKey: .day),
|
|
||||||
let unixTimestamp = Double(str) {
|
|
||||||
self.day = Date(timeIntervalSince1970: unixTimestamp)
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .day, in: container, debugDescription: "day must be either date or UNIX timestamp")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let uses = try? container.decode(Int.self, forKey: .uses) {
|
|
||||||
self.uses = uses
|
|
||||||
} else if let str = try? container.decode(String.self, forKey: .uses),
|
|
||||||
let uses = Int(str) {
|
|
||||||
self.uses = uses
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .uses, in: container, debugDescription: "uses must either be int or string containing int")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let accounts = try? container.decode(Int.self, forKey: .accounts) {
|
|
||||||
self.accounts = accounts
|
|
||||||
} else if let str = try? container.decode(String.self, forKey: .accounts),
|
|
||||||
let accounts = Int(str) {
|
|
||||||
self.accounts = accounts
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .accounts, in: container, debugDescription: "accounts must either be int or string containing int")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case day
|
case day
|
||||||
case uses
|
case uses
|
||||||
|
@ -75,7 +42,7 @@ extension Hashtag {
|
||||||
|
|
||||||
extension Hashtag: Equatable, Hashable {
|
extension Hashtag: Equatable, Hashable {
|
||||||
public static func ==(lhs: Hashtag, rhs: Hashtag) -> Bool {
|
public static func ==(lhs: Hashtag, rhs: Hashtag) -> Bool {
|
||||||
return lhs.name == rhs.name
|
return lhs.url == rhs.url
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
|
|
@ -8,22 +8,10 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class List: Decodable, Equatable, Hashable {
|
public class List: Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let title: String
|
public let title: String
|
||||||
|
|
||||||
public var timeline: Timeline {
|
|
||||||
return .list(id: id)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
|
||||||
return lhs.id == rhs.id
|
|
||||||
}
|
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
||||||
request.range = range
|
request.range = range
|
||||||
|
@ -31,22 +19,22 @@ public class List: Decodable, Equatable, Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ list: List, title: String) -> Request<List> {
|
public static func update(_ list: List, title: String) -> Request<List> {
|
||||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
|
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: .parameters(["title" => title]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ list: List) -> Request<Empty> {
|
public static func delete(_ list: List) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
public static func add(_ list: List, accounts: [Account]) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
||||||
"account_ids" => accountIDs
|
"account_ids" => accounts.map { $0.id }
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
public static func remove(_ list: List, accounts: [Account]) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
||||||
"account_ids" => accountIDs
|
"account_ids" => accounts.map { $0.id }
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,9 +10,10 @@ import Foundation
|
||||||
|
|
||||||
public class LoginSettings: Decodable {
|
public class LoginSettings: Decodable {
|
||||||
public let accessToken: String
|
public let accessToken: String
|
||||||
private let scope: String
|
private let scope: String?
|
||||||
|
|
||||||
public var scopes: [Scope] {
|
public var scopes: [Scope] {
|
||||||
|
guard let scope = scope else { return [] }
|
||||||
return scope.components(separatedBy: .whitespaces).compactMap(Scope.init)
|
return scope.components(separatedBy: .whitespaces).compactMap(Scope.init)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Mention: Codable {
|
public class Mention: Decodable {
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let username: String
|
public let username: String
|
||||||
public let acct: String
|
public let acct: String
|
||||||
|
|
|
@ -15,26 +15,8 @@ public class Notification: Decodable {
|
||||||
public let account: Account
|
public let account: Account
|
||||||
public let status: Status?
|
public let status: Status?
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
|
||||||
if let kind = try? container.decode(Kind.self, forKey: .kind) {
|
|
||||||
self.kind = kind
|
|
||||||
} else {
|
|
||||||
self.kind = .unknown
|
|
||||||
}
|
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
|
||||||
self.account = try container.decode(Account.self, forKey: .account)
|
|
||||||
if container.contains(.status) {
|
|
||||||
self.status = try container.decode(Status.self, forKey: .status)
|
|
||||||
} else {
|
|
||||||
self.status = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
|
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([
|
||||||
"id" => notificationID
|
"id" => notificationID
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
@ -54,8 +36,6 @@ extension Notification {
|
||||||
case reblog
|
case reblog
|
||||||
case favourite
|
case favourite
|
||||||
case follow
|
case follow
|
||||||
case followRequest = "follow_request"
|
|
||||||
case unknown
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
//
|
|
||||||
// AccountProtocol.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/11/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public protocol AccountProtocol {
|
|
||||||
associatedtype Account: AccountProtocol
|
|
||||||
|
|
||||||
var id: String { get }
|
|
||||||
var username: String { get }
|
|
||||||
var acct: String { get }
|
|
||||||
var displayName: String { get }
|
|
||||||
var locked: Bool { get }
|
|
||||||
var createdAt: Date { get }
|
|
||||||
var followersCount: Int { get }
|
|
||||||
var followingCount: Int { get }
|
|
||||||
var statusesCount: Int { get }
|
|
||||||
var note: String { get }
|
|
||||||
var url: URL { get }
|
|
||||||
var avatar: URL { get }
|
|
||||||
var header: URL { get }
|
|
||||||
var moved: Bool? { get }
|
|
||||||
var bot: Bool? { get }
|
|
||||||
|
|
||||||
var movedTo: Account? { get }
|
|
||||||
var emojis: [Emoji] { get }
|
|
||||||
var fields: [Pachyderm.Account.Field] { get }
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
//
|
|
||||||
// StatusProtocol.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/11/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public protocol StatusProtocol {
|
|
||||||
associatedtype Status: StatusProtocol
|
|
||||||
associatedtype Account: AccountProtocol
|
|
||||||
|
|
||||||
var id: String { get }
|
|
||||||
var uri: String { get }
|
|
||||||
var inReplyToID: String? { get }
|
|
||||||
var inReplyToAccountID: String? { get }
|
|
||||||
var content: String { get }
|
|
||||||
var createdAt: Date { get }
|
|
||||||
var reblogsCount: Int { get }
|
|
||||||
var favouritesCount: Int { get }
|
|
||||||
var reblogged: Bool { get }
|
|
||||||
var favourited: Bool { get }
|
|
||||||
var sensitive: Bool { get }
|
|
||||||
var spoilerText: String { get }
|
|
||||||
var visibility: Pachyderm.Status.Visibility { get }
|
|
||||||
var applicationName: String? { get }
|
|
||||||
var pinned: Bool? { get }
|
|
||||||
var bookmarked: Bool? { get }
|
|
||||||
|
|
||||||
var account: Account { get }
|
|
||||||
var reblog: Status? { get }
|
|
||||||
var attachments: [Attachment] { get }
|
|
||||||
var emojis: [Emoji] { get }
|
|
||||||
var hashtags: [Hashtag] { get }
|
|
||||||
var mentions: [Mention] { get }
|
|
||||||
}
|
|
|
@ -13,6 +13,27 @@ public class RegisteredApplication: Decodable {
|
||||||
public let clientID: String
|
public let clientID: String
|
||||||
public let clientSecret: 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 {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case clientID = "client_id"
|
case clientID = "client_id"
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public final class Status: /*StatusProtocol,*/ Decodable {
|
public class Status: Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let uri: String
|
public let uri: String
|
||||||
public let url: URL?
|
public let url: URL?
|
||||||
|
@ -35,27 +35,23 @@ public final class Status: /*StatusProtocol,*/ Decodable {
|
||||||
public let application: Application?
|
public let application: Application?
|
||||||
public let language: String?
|
public let language: String?
|
||||||
public let pinned: Bool?
|
public let pinned: Bool?
|
||||||
public let bookmarked: Bool?
|
|
||||||
public let card: Card?
|
|
||||||
|
|
||||||
public var applicationName: String? { application?.name }
|
public static func getContext(_ status: Status) -> Request<ConversationContext> {
|
||||||
|
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
|
||||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
|
||||||
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getCard(_ status: Status) -> Request<Card> {
|
public static func getCard(_ status: Status) -> Request<Card> {
|
||||||
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
|
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
@ -64,44 +60,36 @@ public final class Status: /*StatusProtocol,*/ Decodable {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func reblog(_ statusID: String) -> Request<Status> {
|
public static func reblog(_ status: Status) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unreblog(_ statusID: String) -> Request<Status> {
|
public static func unreblog(_ status: Status) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func favourite(_ statusID: String) -> Request<Status> {
|
public static func favourite(_ status: Status) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unfavourite(_ statusID: String) -> Request<Status> {
|
public static func unfavourite(_ status: Status) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func pin(_ statusID: String) -> Request<Status> {
|
public static func pin(_ status: Status) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/pin")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/pin")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unpin(_ statusID: String) -> Request<Status> {
|
public static func unpin(_ status: Status) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unpin")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unpin")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func bookmark(_ statusID: String) -> Request<Status> {
|
public static func muteConversation(_ status: Status) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/bookmark")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/mute")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unbookmark(_ statusID: String) -> Request<Status> {
|
public static func unmuteConversation(_ status: Status) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unmute")
|
||||||
}
|
|
||||||
|
|
||||||
public static func muteConversation(_ statusID: String) -> Request<Status> {
|
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/mute")
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func unmuteConversation(_ statusID: String) -> Request<Status> {
|
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -130,8 +118,6 @@ public final class Status: /*StatusProtocol,*/ Decodable {
|
||||||
case application
|
case application
|
||||||
case language
|
case language
|
||||||
case pinned
|
case pinned
|
||||||
case bookmarked
|
|
||||||
case card
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,7 @@ extension Timeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(range: RequestRange) -> Request<[Status]> {
|
func request(range: RequestRange) -> Request<[Status]> {
|
||||||
var request: Request<[Status]> = Request<[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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,82 +8,56 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol Body {
|
enum Body {
|
||||||
var mimeType: String? { get }
|
case parameters([Parameter]?)
|
||||||
var data: Data? { get }
|
case formData([Parameter]?, FormAttachment?)
|
||||||
|
case empty
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmptyBody: Body {
|
extension Body {
|
||||||
var mimeType: String? { nil }
|
private static let boundary: String = "PachydermBoundary"
|
||||||
var data: Data? { nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ParametersBody: Body {
|
var data: Data? {
|
||||||
let parameters: [Parameter]?
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
init(_ parmaeters: [Parameter]?) {
|
data.append("--\(Body.boundary)--\r\n")
|
||||||
self.parameters = parmaeters
|
return data
|
||||||
|
case .empty:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var mimeType: String? {
|
var mimeType: String? {
|
||||||
if parameters == nil || parameters!.isEmpty {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
return "application/x-www-form-urlencoded; charset=utf-8"
|
|
||||||
}
|
|
||||||
|
|
||||||
var data: Data? {
|
|
||||||
return parameters?.urlEncoded.data(using: .utf8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FormDataBody: Body {
|
|
||||||
private static let boundary = "PachydermBoundary"
|
|
||||||
|
|
||||||
let parameters: [Parameter]?
|
|
||||||
let attachment: FormAttachment?
|
|
||||||
|
|
||||||
init(_ parameters: [Parameter]?, _ attachment: FormAttachment?) {
|
|
||||||
self.parameters = parameters
|
|
||||||
self.attachment = attachment
|
|
||||||
}
|
|
||||||
|
|
||||||
var mimeType: String? {
|
|
||||||
if parameters == nil && attachment == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return "multipart/form-data; boundary=\(FormDataBody.boundary)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var data: Data? {
|
|
||||||
var data = Data()
|
|
||||||
parameters?.forEach { param in
|
|
||||||
guard let value = param.value else { return }
|
|
||||||
data.append("--\(FormDataBody.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("--\(FormDataBody.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("--\(FormDataBody.boundary)--\r\n")
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct JsonBody<T: Encodable>: Body {
|
|
||||||
let value: T
|
|
||||||
|
|
||||||
init(_ value: T) {
|
|
||||||
self.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
var mimeType: String? { "application/json" }
|
|
||||||
|
|
||||||
var data: Data? { try? Client.encoder.encode(value) }
|
|
||||||
}
|
|
||||||
|
|
|
@ -67,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: "&")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ public struct Request<ResultType: Decodable> {
|
||||||
let body: Body
|
let body: Body
|
||||||
var queryParameters: [Parameter]
|
var queryParameters: [Parameter]
|
||||||
|
|
||||||
init(method: Method, path: String, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
|
||||||
self.method = method
|
self.method = method
|
||||||
self.path = path
|
self.path = path
|
||||||
self.body = body
|
self.body = body
|
||||||
|
|
|
@ -10,5 +10,5 @@ import Foundation
|
||||||
|
|
||||||
public enum Response<Result: Decodable> {
|
public enum Response<Result: Decodable> {
|
||||||
case success(Result, Pagination?)
|
case success(Result, Pagination?)
|
||||||
case failure(Client.Error)
|
case failure(Error)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,16 +22,16 @@ public class InstanceSelector {
|
||||||
let request = URLRequest(url: url)
|
let request = URLRequest(url: url)
|
||||||
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(.networkError(error)))
|
completion(.failure(error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data,
|
guard let data = data,
|
||||||
let response = response as? HTTPURLResponse else {
|
let response = response as? HTTPURLResponse else {
|
||||||
completion(.failure(.invalidResponse))
|
completion(.failure(Client.Error.invalidResponse))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard response.statusCode == 200 else {
|
guard response.statusCode == 200 else {
|
||||||
completion(.failure(.unexpectedStatus(response.statusCode)))
|
completion(.failure(Client.Error.unknownError))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let result = try? decoder.decode([Instance].self, from: data) else {
|
guard let result = try? decoder.decode([Instance].self, from: data) else {
|
||||||
|
@ -51,7 +51,7 @@ public extension InstanceSelector {
|
||||||
public let description: String
|
public let description: String
|
||||||
public let proxiedThumbnailURL: URL
|
public let proxiedThumbnailURL: URL
|
||||||
public let language: String
|
public let language: String
|
||||||
public let category: String
|
public let category: Category
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case domain
|
case domain
|
||||||
|
@ -62,3 +62,20 @@ public extension InstanceSelector {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension InstanceSelector {
|
||||||
|
enum Category: String, Codable {
|
||||||
|
// source: https://source.joinmastodon.org/mastodon/joinmastodon/blob/master/src/Wizard.js#L108
|
||||||
|
case general
|
||||||
|
case regional
|
||||||
|
case art
|
||||||
|
case journalism
|
||||||
|
case activism
|
||||||
|
case lgbt
|
||||||
|
case games
|
||||||
|
case tech
|
||||||
|
case adult
|
||||||
|
case furry
|
||||||
|
case food
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -9,42 +9,30 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class NotificationGroup {
|
public class NotificationGroup {
|
||||||
public let notifications: [Notification]
|
public let notificationIDs: [String]
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Notification.Kind
|
public let kind: Notification.Kind
|
||||||
public let statusState: StatusState?
|
|
||||||
|
|
||||||
init?(notifications: [Notification]) {
|
init?(notifications: [Notification]) {
|
||||||
guard !notifications.isEmpty else { return nil }
|
guard !notifications.isEmpty else { return nil }
|
||||||
self.notifications = notifications
|
self.notificationIDs = notifications.map { $0.id }
|
||||||
self.id = notifications.first!.id
|
self.id = notifications.first!.id
|
||||||
self.kind = notifications.first!.kind
|
self.kind = notifications.first!.kind
|
||||||
if kind == .mention {
|
|
||||||
self.statusState = .unknown
|
|
||||||
} else {
|
|
||||||
self.statusState = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||||
var groups = [[Notification]]()
|
return notifications.reduce(into: [[Notification]]()) { (groups, notification) in
|
||||||
for notification in notifications {
|
if allowedTypes.contains(notification.kind),
|
||||||
if allowedTypes.contains(notification.kind) {
|
let lastGroup = groups.last,
|
||||||
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
|
let firstStatus = lastGroup.first,
|
||||||
groups[groups.count - 1].append(notification)
|
firstStatus.kind == notification.kind,
|
||||||
continue
|
firstStatus.status?.id == notification.status?.id {
|
||||||
} else if groups.count >= 2 {
|
|
||||||
let secondToLastGroup = groups[groups.count - 2]
|
|
||||||
if allowedTypes.contains(groups[groups.count - 1][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
|
|
||||||
groups[groups.count - 2].append(notification)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
groups.append([notification])
|
groups[groups.count - 1].append(notification)
|
||||||
}
|
} else {
|
||||||
return groups.map {
|
groups.append([notification])
|
||||||
|
}
|
||||||
|
}.map {
|
||||||
NotificationGroup(notifications: $0)!
|
NotificationGroup(notifications: $0)!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
//
|
|
||||||
// StatusState.swift
|
|
||||||
// Pachyderm
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/24/19.
|
|
||||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class StatusState: Equatable, Hashable {
|
|
||||||
public var collapsible: Bool?
|
|
||||||
public var collapsed: Bool?
|
|
||||||
|
|
||||||
public var unknown: Bool {
|
|
||||||
collapsible == nil || collapsed == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(collapsible: Bool?, collapsed: Bool?) {
|
|
||||||
self.collapsible = collapsible
|
|
||||||
self.collapsed = collapsed
|
|
||||||
}
|
|
||||||
|
|
||||||
public func copy() -> StatusState {
|
|
||||||
return StatusState(collapsible: self.collapsible, collapsed: self.collapsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(collapsible)
|
|
||||||
hasher.combine(collapsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var unknown: StatusState {
|
|
||||||
StatusState(collapsible: nil, collapsed: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func == (lhs: StatusState, rhs: StatusState) -> Bool {
|
|
||||||
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
|
|
||||||
}
|
|
||||||
}
|
|
12
README.md
|
@ -1,15 +1,3 @@
|
||||||
# Tusker
|
# Tusker
|
||||||
|
|
||||||
Tusker is a WIP iOS app for Mastodon and Pleroma.
|
Tusker is a WIP iOS app for Mastodon and Pleroma.
|
||||||
|
|
||||||
## Installing for Development
|
|
||||||
|
|
||||||
Xcode 11 is required, macOS Mojave or later should work (only macOS Catalina is regularly tested).
|
|
||||||
|
|
||||||
1. Clone the project: `git clone https://git.shadowfacts.net/shadowfacts/Tusker.git`
|
|
||||||
2. Change directory into the project: `cd Tusker`
|
|
||||||
3. Clone the submodules: `git submodule init && git submodule update`
|
|
||||||
4. Open `Tusker.xcworkspace` in Xcode.
|
|
||||||
5. Change the code signing identity to your own.
|
|
||||||
6. Change the bundle identifier to something unique.
|
|
||||||
7. Select a target in the Tusker scheme and build & run.
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit f445c9067d28346e828e615e2b43cb07b20bca35
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1020"
|
LastUpgradeVersion = "1020"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
@ -47,9 +47,6 @@
|
||||||
BlueprintName = "TuskerUITests"
|
BlueprintName = "TuskerUITests"
|
||||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
<DeviceAppData
|
|
||||||
resolvedPath = "../BlankSlate.xcappdata">
|
|
||||||
</DeviceAppData>
|
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO">
|
skipped = "NO">
|
||||||
|
@ -62,6 +59,17 @@
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
|
||||||
|
BuildableName = "Tusker.app"
|
||||||
|
BlueprintName = "Tusker"
|
||||||
|
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<AdditionalOptions>
|
||||||
|
</AdditionalOptions>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
|
@ -83,12 +91,8 @@
|
||||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<CommandLineArguments>
|
<AdditionalOptions>
|
||||||
<CommandLineArgument
|
</AdditionalOptions>
|
||||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
|
||||||
isEnabled = "YES">
|
|
||||||
</CommandLineArgument>
|
|
||||||
</CommandLineArguments>
|
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|
|
@ -5,18 +5,12 @@
|
||||||
location = "container:Tusker.xcodeproj">
|
location = "container:Tusker.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:BlankSlate.xcappdata">
|
location = "group:Cache/Cache.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Cache/Cache.xcodeproj">
|
location = "group:SwiftSoup/SwiftSoup.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Gifu/Gifu.xcodeproj">
|
location = "group:Gifu/Gifu.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
|
||||||
location = "group:Embassy/Embassy.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
<FileRef
|
|
||||||
location = "group:Ambassador/Ambassador.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
{
|
|
||||||
"object": {
|
|
||||||
"pins": [
|
|
||||||
{
|
|
||||||
"package": "PLCrashReporter",
|
|
||||||
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
|
|
||||||
"state": {
|
|
||||||
"branch": null,
|
|
||||||
"revision": "6b7ca9a2faad6ea990ff60b0a3ee4fdf3db59150",
|
|
||||||
"version": "1.7.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"package": "SheetController",
|
|
||||||
"repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git",
|
|
||||||
"state": {
|
|
||||||
"branch": "master",
|
|
||||||
"revision": "aa0f5192eaf19d01c89dbfa9ec5878a700376f23",
|
|
||||||
"version": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"package": "SwiftSoup",
|
|
||||||
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
|
|
||||||
"state": {
|
|
||||||
"branch": null,
|
|
||||||
"revision": "774dc9c7213085db8aa59595e27c1cd22e428904",
|
|
||||||
"version": "2.3.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"version": 1
|
|
||||||
}
|
|
|
@ -7,24 +7,25 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
class AccountActivity: MastodonActivity {
|
class AccountActivity: UIActivity {
|
||||||
|
|
||||||
override class var activityCategory: UIActivity.Category {
|
override class var activityCategory: UIActivity.Category {
|
||||||
return .action
|
return .action
|
||||||
}
|
}
|
||||||
|
|
||||||
var account: AccountMO?
|
var account: Account?
|
||||||
|
|
||||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||||
for case is AccountMO in activityItems {
|
for case is Account in activityItems {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepare(withActivityItems activityItems: [Any]) {
|
override func prepare(withActivityItems activityItems: [Any]) {
|
||||||
for case let account as AccountMO in activityItems {
|
for case let account as Account in activityItems {
|
||||||
self.account = account
|
self.account = account
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,8 +28,10 @@ class FollowAccountActivity: AccountActivity {
|
||||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
|
||||||
let request = Account.follow(account.id)
|
let request = Account.follow(account.id)
|
||||||
mastodonController.run(request) { (response) in
|
MastodonController.client.run(request) { (response) in
|
||||||
if case .failure(_) = response {
|
if case let .success(relationship, _) = response {
|
||||||
|
MastodonCache.add(relationship: relationship)
|
||||||
|
} else {
|
||||||
// todo: display error message
|
// todo: display error message
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
fatalError()
|
fatalError()
|
||||||
|
|
|
@ -28,9 +28,7 @@ class SendMessageActivity: AccountActivity {
|
||||||
override var activityViewController: UIViewController? {
|
override var activityViewController: UIViewController? {
|
||||||
guard let account = account else { return nil }
|
guard let account = account else { return nil }
|
||||||
|
|
||||||
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
|
return UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct))
|
||||||
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
|
|
||||||
return UINavigationController(rootViewController: compose)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,8 +28,10 @@ class UnfollowAccountActivity: AccountActivity {
|
||||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
|
||||||
let request = Account.unfollow(account.id)
|
let request = Account.unfollow(account.id)
|
||||||
mastodonController.run(request) { (response) in
|
MastodonController.client.run(request) { (response) in
|
||||||
if case .failure(_) = response {
|
if case let .success(relationship, _) = response {
|
||||||
|
MastodonCache.add(relationship: relationship)
|
||||||
|
} else {
|
||||||
// todo: display error message
|
// todo: display error message
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
fatalError()
|
fatalError()
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
//
|
|
||||||
// AccountActivityItemSource.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 5/14/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import LinkPresentation
|
|
||||||
|
|
||||||
class AccountActivityItemSource: NSObject, UIActivityItemSource {
|
|
||||||
let account: AccountMO
|
|
||||||
|
|
||||||
init(_ account: AccountMO) {
|
|
||||||
self.account = account
|
|
||||||
}
|
|
||||||
|
|
||||||
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
|
||||||
return account
|
|
||||||
}
|
|
||||||
|
|
||||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
|
||||||
return account
|
|
||||||
}
|
|
||||||
|
|
||||||
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
|
||||||
let metadata = LPLinkMetadata()
|
|
||||||
metadata.originalURL = account.url
|
|
||||||
metadata.url = account.url
|
|
||||||
metadata.title = "\(account.displayName) (@\(account.username)@\(account.url.host!)"
|
|
||||||
if let data = ImageCache.avatars.get(account.avatar),
|
|
||||||
let image = UIImage(data: data) {
|
|
||||||
metadata.iconProvider = NSItemProvider(object: image)
|
|
||||||
}
|
|
||||||
return metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
//
|
|
||||||
// MastodonActivity.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 1/5/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class MastodonActivity: UIActivity {
|
|
||||||
var mastodonController: MastodonController {
|
|
||||||
let scene = UIApplication.shared.activeOrBackgroundScene!
|
|
||||||
return scene.session.mastodonController!
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -36,10 +36,10 @@ class OpenInSafariActivity: UIActivity {
|
||||||
activityDidFinish(true)
|
activityDidFinish(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
|
static func completionHandler(viewController: UIViewController, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
|
||||||
return { (activityType, _, _, _) in
|
return { (activityType, _, _, _) in
|
||||||
if activityType == .openInSafari {
|
if activityType == .openInSafari {
|
||||||
navigator.show(SFSafariViewController(url: url))
|
viewController.present(SFSafariViewController(url: url), animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
//
|
|
||||||
// BookmarkStatusActivity.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/14/19.
|
|
||||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
class BookmarkStatusActivity: StatusActivity {
|
|
||||||
|
|
||||||
override var activityType: UIActivity.ActivityType? {
|
|
||||||
return .bookmarkStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityTitle: String? {
|
|
||||||
return NSLocalizedString("Bookmark", comment: "bookmark status activity title")
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityImage: UIImage? {
|
|
||||||
return UIImage(systemName: "bookmark")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func perform() {
|
|
||||||
guard let status = status else { return }
|
|
||||||
|
|
||||||
let request = Status.bookmark(status.id)
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
if case let .success(status, _) = response {
|
|
||||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
|
||||||
} else {
|
|
||||||
// todo: display error message
|
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
//
|
|
||||||
// MuteConversationActivity.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 5/14/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
class MuteConversationActivity: StatusActivity {
|
|
||||||
override var activityType: UIActivity.ActivityType? {
|
|
||||||
return .muteConversation
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityTitle: String? {
|
|
||||||
return NSLocalizedString("Mute Conversation", comment: "mute conversation activity title")
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityImage: UIImage? {
|
|
||||||
return UIImage(systemName: "speaker.slash")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func perform() {
|
|
||||||
guard let status = status else { return }
|
|
||||||
|
|
||||||
let request = Status.muteConversation(status.id)
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
if case let .success(status, _) = response {
|
|
||||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
|
||||||
} else {
|
|
||||||
// todo: display error message
|
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
//
|
|
||||||
// PinStatusActivity.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 1/4/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
class PinStatusActivity: StatusActivity {
|
|
||||||
override var activityType: UIActivity.ActivityType? {
|
|
||||||
return .pinStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityTitle: String? {
|
|
||||||
return NSLocalizedString("Pin", comment: "pin status activity title")
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityImage: UIImage? {
|
|
||||||
return UIImage(systemName: "pin")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func perform() {
|
|
||||||
guard let status = status else { return }
|
|
||||||
|
|
||||||
let request = Status.pin(status.id)
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
if case let .success(status, _) = response {
|
|
||||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
|
||||||
} else {
|
|
||||||
// todo: display error message
|
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
//
|
|
||||||
// StatusActivity.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/14/19.
|
|
||||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class StatusActivity: MastodonActivity {
|
|
||||||
|
|
||||||
override class var activityCategory: UIActivity.Category {
|
|
||||||
return .action
|
|
||||||
}
|
|
||||||
|
|
||||||
var status: StatusMO?
|
|
||||||
|
|
||||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
|
||||||
for case is StatusMO in activityItems {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override func prepare(withActivityItems activityItems: [Any]) {
|
|
||||||
for case let status as StatusMO in activityItems {
|
|
||||||
self.status = status
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
//
|
|
||||||
// UnbookmarkStatusActivity.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/14/19.
|
|
||||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
class UnbookmarkStatusActivity: StatusActivity {
|
|
||||||
|
|
||||||
override var activityType: UIActivity.ActivityType? {
|
|
||||||
return .unbookmarkStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityTitle: String? {
|
|
||||||
return NSLocalizedString("Unbookmark", comment: "unbookmark status activity title")
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityImage: UIImage? {
|
|
||||||
return UIImage(systemName: "bookmark.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func perform() {
|
|
||||||
guard let status = status else { return }
|
|
||||||
|
|
||||||
let request = Status.unbookmark(status.id)
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
if case let .success(status, _) = response {
|
|
||||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
|
||||||
} else {
|
|
||||||
// todo: display error message
|
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
//
|
|
||||||
// UnmuteConversationActivity.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 5/14/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
class UnmuteConversationActivity: StatusActivity {
|
|
||||||
override var activityType: UIActivity.ActivityType? {
|
|
||||||
return .unmuteConversation
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityTitle: String? {
|
|
||||||
return NSLocalizedString("Unmute Conversation", comment: "unmute conversation activity title")
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityImage: UIImage? {
|
|
||||||
return UIImage(systemName: "speaker")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func perform() {
|
|
||||||
guard let status = status else { return }
|
|
||||||
|
|
||||||
let request = Status.unmuteConversation(status.id)
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
if case let .success(status, _) = response {
|
|
||||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
|
||||||
} else {
|
|
||||||
// todo: display error message
|
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
//
|
|
||||||
// UnpinStatusActivity.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 1/4/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
class UnpinStatusActivity: StatusActivity {
|
|
||||||
override var activityType: UIActivity.ActivityType? {
|
|
||||||
return .unpinStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityTitle: String? {
|
|
||||||
return NSLocalizedString("Unpin", comment: "unpin status activity title")
|
|
||||||
}
|
|
||||||
|
|
||||||
override var activityImage: UIImage? {
|
|
||||||
return UIImage(systemName: "pin.slash")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func perform() {
|
|
||||||
guard let status = status else { return }
|
|
||||||
|
|
||||||
let request = Status.unpin(status.id)
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
if case let .success(status, _) = response {
|
|
||||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
|
||||||
} else {
|
|
||||||
// todo: display error message
|
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,42 +0,0 @@
|
||||||
//
|
|
||||||
// StatusActivityItemSource.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 5/14/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import LinkPresentation
|
|
||||||
import SwiftSoup
|
|
||||||
|
|
||||||
class StatusActivityItemSource: NSObject, UIActivityItemSource {
|
|
||||||
let status: StatusMO
|
|
||||||
|
|
||||||
init(_ status: StatusMO) {
|
|
||||||
self.status = status
|
|
||||||
}
|
|
||||||
|
|
||||||
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
|
||||||
let metadata = LPLinkMetadata()
|
|
||||||
metadata.originalURL = status.url!
|
|
||||||
metadata.url = status.url!
|
|
||||||
let doc = try! SwiftSoup.parse(status.content)
|
|
||||||
let content = try! doc.text()
|
|
||||||
metadata.title = "\(status.account.displayName): \"\(content)\""
|
|
||||||
if let data = ImageCache.avatars.get(status.account.avatar),
|
|
||||||
let image = UIImage(data: data) {
|
|
||||||
metadata.iconProvider = NSItemProvider(object: image)
|
|
||||||
}
|
|
||||||
return metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -18,11 +18,5 @@ extension UIActivity.ActivityType {
|
||||||
static let unfollowAccount = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unfollow_account")
|
static let unfollowAccount = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unfollow_account")
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
static let bookmarkStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).bookmark_status")
|
|
||||||
static let unbookmarkStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unbookmark_status")
|
|
||||||
static let pinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).pin_status")
|
|
||||||
static let unpinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unpin_status")
|
|
||||||
static let muteConversation = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).mute_conversation")
|
|
||||||
static let unmuteConversation = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unmute_conversation")
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,41 +7,114 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import CrashReporter
|
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
static private(set) var crashReporter: PLCrashReporter!
|
var window: UIWindow?
|
||||||
static var pendingCrashReport: PLCrashReport?
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
#if !DEBUG
|
|
||||||
setupCrashReporter()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
AppShortcutItem.createItems(for: application)
|
AppShortcutItem.createItems(for: application)
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
AudioSessionHelper.disable()
|
|
||||||
AudioSessionHelper.setDefault()
|
if LocalData.shared.onboardingComplete {
|
||||||
|
showAppUI()
|
||||||
|
} else {
|
||||||
|
showOnboardingUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(onUserLoggedOut), name: .userLoggedOut, object: nil)
|
||||||
|
|
||||||
|
window!.makeKeyAndVisible()
|
||||||
|
|
||||||
|
if let shortcutItem = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
||||||
|
_ = AppShortcutItem.handle(shortcutItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupCrashReporter() {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
|
||||||
let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .all)
|
if url.host == "x-callback-url" {
|
||||||
AppDelegate.crashReporter = PLCrashReporter(configuration: config)
|
return XCBManager.handle(url: url)
|
||||||
|
} else if var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
|
let tabBarController = window!.rootViewController as? MainTabBarViewController,
|
||||||
|
let navigationController = tabBarController.viewControllers?[3] as? UINavigationController,
|
||||||
|
let searchController = navigationController.viewControllers.first as? SearchTableViewController {
|
||||||
|
|
||||||
if AppDelegate.crashReporter.hasPendingCrashReport() {
|
components.scheme = "https"
|
||||||
let data = try! AppDelegate.crashReporter.loadPendingCrashReportDataAndReturnError()
|
|
||||||
AppDelegate.crashReporter.purgePendingCrashReport()
|
|
||||||
let report = try! PLCrashReport(data: data)
|
|
||||||
|
|
||||||
AppDelegate.pendingCrashReport = report
|
tabBarController.selectedIndex = 3
|
||||||
|
navigationController.popToRootViewController(animated: false)
|
||||||
|
|
||||||
|
searchController.loadViewIfNeeded()
|
||||||
|
|
||||||
|
let query = components.url!.absoluteString
|
||||||
|
searchController.searchController.searchBar.text = query
|
||||||
|
searchController.performSearch(query: query)
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
AppDelegate.crashReporter.enable()
|
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||||
|
return userActivity.handleResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
completionHandler(AppShortcutItem.handle(shortcutItem))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillResignActive(_ application: UIApplication) {
|
||||||
|
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||||
|
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
|
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||||
|
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||||
|
Preferences.save()
|
||||||
|
DraftsManager.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
|
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
|
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAppUI() {
|
||||||
|
MastodonController.createClient()
|
||||||
|
MastodonController.getOwnAccount()
|
||||||
|
MastodonController.getOwnInstance()
|
||||||
|
|
||||||
|
let tabBarController = MainTabBarViewController()
|
||||||
|
window!.rootViewController = tabBarController
|
||||||
|
}
|
||||||
|
|
||||||
|
func showOnboardingUI() {
|
||||||
|
let onboarding = OnboardingViewController()
|
||||||
|
onboarding.onboardingDelegate = self
|
||||||
|
window!.rootViewController = onboarding
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func onUserLoggedOut() {
|
||||||
|
showOnboardingUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppDelegate: OnboardingViewControllerDelegate {
|
||||||
|
func didFinishOnboarding() {
|
||||||
|
LocalData.shared.onboardingComplete = true
|
||||||
|
showAppUI()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Before Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 580 B |
Before Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 917 B |
Before Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 4.9 KiB |