Compare commits

..

4 Commits

753 changed files with 15276 additions and 69488 deletions

2
.gitignore vendored
View File

@ -1,5 +1,3 @@
Dist.xcconfig
Tusker.xcconfig
.DS_Store
MyPlayground.playground/

15
.gitmodules vendored
View File

@ -1,6 +1,9 @@
[submodule "Embassy"]
path = Embassy
url = https://github.com/envoy/Embassy.git
[submodule "Ambassador"]
path = Ambassador
url = https://github.com/envoy/Ambassador.git
[submodule "SwiftSoup"]
path = SwiftSoup
url = git://github.com/scinfu/SwiftSoup.git
[submodule "Cache"]
path = Cache
url = git@github.com:hyperoslo/Cache.git
[submodule "Gifu"]
path = Gifu
url = git://github.com/kaishin/Gifu.git

@ -1 +0,0 @@
Subproject commit 4fe264af51e0dd7228486c604750909e368241a7

View File

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

View File

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

78
Artwork/Icons/Link.svg Normal file
View File

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

84
Artwork/Icons/More.svg Normal file
View File

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

115
Artwork/Icons/Reblog.svg Normal file
View File

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

78
Artwork/Icons/Reply.svg Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

1
Cache Submodule

@ -0,0 +1 @@
Subproject commit 8c42c575cf28b2ff0e780c9728721e9a8891c92e

View File

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

View File

@ -0,0 +1,355 @@
# X-Callback-URLs in Tusker
Tusker supports inter-app-communication using the [X-Callback-URL standard](http://x-callback-url.com/).
In short, requests are performed by opening the URL `tusker://x-callback-url/[request]` (where `[request]` is one of the requests listed below) with a variety of parameters.
## Callbacks
X-Callback-URLs support three types of callbacks: on success, on cancellation, and on error. Callbacks are specified as query parameters whose keys identify which callback (`x-success`, `x-cancel`, and `x-error`) and whose values are other URLs that should be opened to run the callback.
Data is passed to callbacks by adding additional query parameters to the callback URL. The `x-error` callback always returns a description of the error in the `error` parameter. Other data is provided depending on the request.
### JSON Responses
By default, callback data is included in URL query parameters of the callback URL. If the `json=true` parameter is provided, the response data will be encoded as JSON, converted to [Base64](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding), and provided in the `response` query parameter of the callback.
## Silent Requests
Tusker X-Callback-URL requests can be performed silently, without user confirmation. Each source app requires user permission on the first attempted silent action.
To perform a silent request:
1. Provide the `silent=true` URL query parameter in the request.
2. Specify the `x-source` parameter. It must be a (human interpretable) name of the source application/service. If `x-source` is not specified, the error callback will be invoked with the error message:
```
Cannot perform silent action without source app, x-source parameter must be specified.
```
3. Depending on the current permission state of the source app, one of several things will happen:
1. If the permission is **undecided** (i.e. the user has neither accepted nor rejected the silent action request), an alert will be displayed notifying the user that the source app has requested permission to silently perform actions. After the user either accepts or rejects the request, execution will continue with that permission state.
2. **Accepted**: the request will be carried out silently and the appropriate callback executed.
3. **Rejected**: the request will be performed with the confirmation UI, as if the `silent` parameter had been false/unprovided.
The silent actions permission state of a given source app is not exposed in the callback.
## Other Notes
#### Instance-Local IDs
Instance-local IDs are provided for many responses and accept in place of URLs/URIs/qualified names in many requests. When possible, instance-local IDs should be preferred requests using them can often be performed faster because there's no need to perform a search query or make requests to remote instances.
#### Qualified Usernames
Qualified username refers to the domain-qualified identifier of an account. For example, `shadowfacts@social.shadowfacts.net`. They do not include a leading `@`.
#### Dates
Dates in responses are encoded as Unix timestamps.
## Requests
- [Accounts](#accounts)
- [`showAccount`](#showaccount)
- [`getCurrentUser`](#getcurrentuser)
- [`getAccount`](#getaccount)
- [`followUser`](#followuser)
- [Statuses](#statuses)
- [`showStatus`](#showstatus)
- [`getStatus`](#getstatus)
- [`postStatus`](#poststatus)
- [`favoriteStatus`](#favoritestatus)
- [`reblogStatus`](#reblogstatus)
- [Notifications](#notifications)
- [`getNotification`](#getnotification)
- [`getNotifications`](#getnotifications)
- [`dismissNotification`](#dismissnotification)
- [`dismissAllNotifications`](#dismissallnotifications)
- [Instances](#instances)
- [`getCurrentInstance`](#getcurrentinstance)
- [Misc](#misc)
- [`search`](#search)
### Accounts
#### `showAccount`
Presents the given account in Tusker.
##### Request
| Parameter (type) | Description | Optional |
| -------------------- | ------------------------------------------------------------ | -------- |
| `accountID` (string) | The instance-local ID of the account | Yes |
| `accountURL` (URL) | The URL of the remote account | Yes |
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
##### Response
No data if successful.
#### `getCurrentUser`
Retrieves the currently logged-in user.
##### Request
No parameters.
##### Response:
| Parameter (type) | Description | Optional |
| ---------------------- | --------------------------------------------- | -------- |
| `username` (string) | The [qualified username](#qualifiedusernames) | No |
| `displayName` (string) | The display name | No |
| `locked` (bool) | Whether the user's account is locked | No |
| `followers` (int) | The number of followers the user has | No |
| `following` (int) | The number of accounts user is following | No |
| `url` (URL) | The URL of the user's account | No |
| `avatarURL` (URL) | The URL of the user's avatar image | No |
| `headerURL` (URL) | The URL of the user's header image | No |
#### `getAccount`
Retrieves the given account details. One of `accountID`, `accountURL`, or `acct` must be provided.
##### Request
| Parameter (type) | Description | Optional |
| -------------------- | ------------------------------------------------------------ | -------- |
| `accountID` (string) | The instance-local ID of the account | Yes |
| `accountURL` (URL) | The URL/URI of the account | Yes |
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
##### Response
| Parameter (type) | Description | Optional |
| ---------------------- | ------------------------------------------- | -------- |
| `username` (string) | The qualified username | No |
| `displayName` (string) | The display name | No |
| `locked` (bool) | Whether the account is locked | No |
| `followers` (int) | The number of followers the account has | No |
| `following` (int) | The number of accounts account is following | No |
| `url` (URL) | The URL of the account | No |
| `avatarURL` (URL) | The URL of the account's avatar image | No |
| `headerURL` (URL) | The URL of the account's header image | No |
#### `followUser`
Follows the given account from the logged-in user's account. One of `accountID`, `accountURL`, or `acct` must be provided.
##### Request
| Parameter (type) | Description | Optional |
| -------------------- | ------------------------------------------------------------ | -------- |
| `accountID` (string) | The instance-local ID of the account | Yes |
| `accountURL` (URL) | The URL/URI of the account | Yes |
| `acct` (string) | The [qualified username](#qualifiedusernames) of the account | Yes |
##### Response
| Parameter (type) | Description | Optional |
| ---------------- | ------------------------------- | -------- |
| `url` (URL) | The URL of the followed account | No |
### Statuses
#### `showStatus`
Presents the given status in Tusker.
##### Request
| Parameter (type) | Description | Optional |
| ------------------- | ----------------------------------- | -------- |
| `statusID` (string) | The instance-local ID of the status | Yes |
| `statusURL` (URL) | The URL of a remote status | Yes |
##### Response
No data if successful.
#### `getStatus`
Retrieves the given status details. One of `statusID` or `statusURL` must be provided.
##### Request
| Parameter (type) | Description | Optional |
| ------------------- | ------------------------------------------------------------ | -------- |
| `statusID` (string) | The instance-local ID of the status | Yes |
| `statusURL` (URL) | The URL/URI of the status | Yes |
| `html` (bool) | Whether to return the content as HTML or plain-text only. Default: `false` (plain-text). | Yes |
##### Response
| Parameter (type) | Description | Optional |
| -------------------- | ------------------------------------------------------------ | -------- |
| `url` (URL) | The URL of the status | Yes |
| `uri` (string) | The URI of the status | No |
| `id` (string) | The instance-local ID of the status | |
| `account` (string) | The [qualified username](#qualifiedusernames) of the account that posted (or reblogged if `reblog` is present) the status | No |
| `inReplyTo` (string) | The instance-local ID of the status that this status is a reply to | Yes |
| `posted` (date) | The date the status was posted | No |
| `content` (string) | The content of the status (HTML if the `html` parameter was true, plain-text otherwise) | No |
| `reblog` (string) | The **instance-local** ID of the status that this is a reblog of. If not present, this status was not a reblog. | Yes |
#### `postStatus`
Posts a status from the logged-in user's account.
Can be performed silently.
##### Request
| Parameter (type) | Description | Optional |
| ------------------- | ------------------------------------------------------------ | -------- |
| `mentioning` (bool) | The [qualified username](#qualifiedusernames) to mention in the status | Yes |
| `text` (string) | The text to post/pre-fill the status text field with | Yes |
##### Response
| Parameter (type) | Description | Optional |
| -------------------- | ---------------------------- | -------- |
| `statusURL` (URL) | The URL of the posted status | Yes |
| `statusURI` (string) | The URI of the posted status | No |
#### `favoriteStatus`
Favorites the given status from the logged-in user's account. One of `statusID` or `statusURL` must be provided.
Can be performed silently.
##### Request
| Parameter (type) | Description | Optional |
| ------------------- | ----------------------------------- | -------- |
| `statusID` (string) | The instance-local ID of the status | Yes |
| `statusURL` (URL) | The URL/URI of a status | Yes |
##### Response
| Parameter (type) | Description | Optional |
| -------------------- | ------------------------------- | -------- |
| `statusURL` (URL) | The URL of the favorited status | Yes |
| `statusURI` (string) | The URI of the favorited status | No |
#### `reblogStatus`
Reblogs the given status from the logged-in user's account. One of `statusID` or `statusURL` must be provided.
Can be performed silently.
##### Request
| Parameter (type) | Description | Optional |
| ------------------- | ----------------------------------- | -------- |
| `statusID` (string) | The instance-local ID of the status | Yes |
| `statusURL` (URL) | The URL/URI of a status | Yes |
##### Response
| Parameter (type) | Description | Optional |
| -------------------- | ------------------------------- | -------- |
| `statusURL` (URL) | The URL of the reblogged status | Yes |
| `statusURI` (string) | The URI of the reblogged status | No |
### Notifications
#### `getNotification`
Retrieves the given notification details.
##### Request
| Parameter (type) | Description | Optional |
| ------------------------- | ----------------------------------------- | -------- |
| `notificationID` (string) | The instance-local ID of the notification | No |
##### Response
| Parameter (type) | Description | Optional |
| -------------------- | ------------------------------------------------------------ | -------- |
| `kind` (string) | One of `mention`, `reblog`, `favourite`, or `follow` | No |
| `date` (date) | The date the notification was created. | No |
| `accountID` (string) | The instance-local ID of the account that sent the notification | No |
| `statusID` (string) | The instance-local ID of the status associated with the notification. Not applicable for `kind=follow`. | Yes |
#### `getNotifications`
Retrieves the most recent notifications.
##### Request
| Parameter (type) | Description | Optional |
| ---------------- | ---------------------------------------------------- | -------- |
| `count` (int) | The number of notifications to retrieve. Default: 20 | Yes |
##### Response
| Parameter (type) | Description | Optional |
| ------------------------ | ---------------------------------------------------------- | -------- |
| `notifications` (string) | A comma-delimited array of instance-local notification IDs | No |
#### `dismissNotification`
Dismisses the given notification.
##### Request
| Parameter (type) | Description | Optional |
| ----------------------- | ----------------------------------------- | -------- |
| `notification` (string) | The instance-local ID of the notification | No |
##### Response
No response data if successful.
#### `dismissAllNotifications`
Dismisses all notifications.
##### Request
No parameters.
##### Response
No data if successful.
### Instances
#### `getCurrentInstance`
Retrieves the current instance details.
##### Request
No parameters.
##### Response
| Parameter (type) | Description | Optional |
| ------------------------- | ------------------------------------------------------- | -------- |
| `uri` (string) | The instance URI | No |
| `name` (string) | The instance name | No |
| `description` (string) | The instance description | No |
| `contactAccount` (string) | The instance-local ID of the instance's contact account | No |
### Misc
#### `search`
Performs a search in Tusker with the given query
##### Request
| Parameter (type) | Description | Optional |
| ---------------- | ------------------------ |--------- |
| `query` (string) | The search query to use. | No |
##### Response
No data if successful.

@ -1 +0,0 @@
Subproject commit 189436100c00efbf5fb2653fe7972a9371db0a91

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
GMImagePicker/GMSelected.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
GMImagePicker/GMSelected@2x.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

22
GMImagePicker/Info.plist Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
Gifu Submodule

@ -0,0 +1 @@
Subproject commit ed572f53ce58b8e23499abeb3a926033cbe480f7

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>TuskerInfo</key>
<dict>
<key>PushProxyHost</key>
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
<key>PushProxyScheme</key>
<string>$(TUSKER_PUSH_PROXY_SCHEME)</string>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(BUNDLE_ID_PREFIX).Tusker</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -1,361 +0,0 @@
//
// NotificationService.swift
// NotificationExtension
//
// Created by Shadowfacts on 4/9/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UserNotifications
import UserAccounts
import PushNotifications
import CryptoKit
import OSLog
import Pachyderm
import Intents
import HTMLStreamer
import WebURL
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
class NotificationService: UNNotificationServiceExtension {
private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self)
private var pendingRequest: (UNMutableNotificationContent, (UNNotificationContent) -> Void, Task<Void, Never>)?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
logger.error("Couldn't get mutable content")
contentHandler(request.content)
return
}
guard request.content.userInfo["v"] as? Int == 1,
let accountID = (request.content.userInfo["ctx"] as? String).flatMap(\.removingPercentEncoding),
let account = UserAccountsManager.shared.getAccount(id: accountID),
let subscription = getSubscription(account: account),
let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }),
let salt = (request.content.userInfo["salt"] as? String).flatMap(decodeBase64URL(_:)),
let serverPublicKeyData = (request.content.userInfo["pk"] as? String).flatMap(decodeBase64URL(_:)) else {
logger.error("Missing info from push notification")
contentHandler(request.content)
return
}
guard let body = decryptNotification(subscription: subscription, serverPublicKeyData: serverPublicKeyData, salt: salt, encryptedBody: encryptedBody) else {
contentHandler(request.content)
return
}
let withoutPadding = body.dropFirst(2)
let notification: PushNotification
do {
notification = try JSONDecoder().decode(PushNotification.self, from: withoutPadding)
} catch {
logger.error("Unable to decode push payload: \(String(describing: error))")
contentHandler(request.content)
return
}
mutableContent.title = notification.title
mutableContent.body = notification.body
mutableContent.userInfo["notificationID"] = notification.notificationID
mutableContent.userInfo["accountID"] = accountID
let task = Task {
await updateNotificationContent(mutableContent, account: account, push: notification)
if !Task.isCancelled {
contentHandler(pendingRequest?.0 ?? mutableContent)
pendingRequest = nil
}
}
pendingRequest = (mutableContent, contentHandler, task)
}
override func serviceExtensionTimeWillExpire() {
if let pendingRequest {
logger.debug("Expiring with pending request")
pendingRequest.2.cancel()
pendingRequest.1(pendingRequest.0)
self.pendingRequest = nil
} else {
logger.debug("Expiring without pending request")
}
}
private func updateNotificationContent(_ content: UNMutableNotificationContent, account: UserAccountInfo, push: PushNotification) async {
let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken)
let notification: Pachyderm.Notification
do {
notification = try await client.run(Client.getNotification(id: push.notificationID)).0
} catch {
logger.error("Error fetching notification: \(String(describing: error))")
return
}
let kindStr: String?
switch notification.kind {
case .reblog:
kindStr = "🔁 Reblogged"
case .favourite:
kindStr = "⭐️ Favorited"
case .follow:
kindStr = "👤 Followed by @\(notification.account.acct)"
case .followRequest:
kindStr = "👤 Asked to follow by @\(notification.account.acct)"
case .poll:
kindStr = "📊 Poll finished"
case .update:
kindStr = "✏️ Edited"
case .emojiReaction:
if let emoji = notification.emoji {
kindStr = "\(emoji) Reacted"
} else {
kindStr = nil
}
default:
kindStr = nil
}
let notificationContent: String?
if let status = notification.status {
notificationContent = NotificationService.textConverter.convert(html: status.content)
} else if notification.kind == .follow || notification.kind == .followRequest {
notificationContent = nil
} else {
notificationContent = push.body
}
content.body = [kindStr, notificationContent].compactMap { $0 }.joined(separator: "\n")
let attachmentDataTask: Task<URL?, Never>?
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
// because we risk just fetching the same thing a bunch of times for many senders.
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
let attachment = notification.status?.attachments.first {
let url = attachment.previewURL ?? attachment.url
attachmentDataTask = Task {
do {
let data = try await URLSession.shared.data(from: url).0
let localAttachmentURL = FileManager.default.temporaryDirectory.appendingPathComponent("attachment_\(attachment.id)").appendingPathExtension(url.pathExtension)
try data.write(to: localAttachmentURL)
return localAttachmentURL
} catch {
logger.error("Error setting notification attachments: \(String(describing: error))")
return nil
}
}
} else {
attachmentDataTask = nil
}
let conversationIdentifier: String?
if let status = notification.status {
if let context = status.pleromaExtras?.context {
conversationIdentifier = "context:\(context)"
} else if [Notification.Kind.reblog, .favourite, .poll, .update].contains(notification.kind) {
conversationIdentifier = "status:\(status.id)"
} else {
conversationIdentifier = nil
}
} else {
conversationIdentifier = nil
}
let account: Account?
switch notification.kind {
case .mention, .status:
account = notification.status?.account
default:
account = notification.account
}
let sender: INPerson?
if let account {
let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown)
let image: INImage?
if let avatar = account.avatar,
let (data, resp) = try? await URLSession.shared.data(from: avatar),
let code = (resp as? HTTPURLResponse)?.statusCode,
(200...299).contains(code) {
image = INImage(imageData: data)
} else {
image = nil
}
sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: account.displayName,
image: image,
contactIdentifier: nil,
customIdentifier: account.id
)
} else {
sender = nil
}
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: notificationContent,
speakableGroupName: nil,
conversationIdentifier: conversationIdentifier,
serviceName: nil,
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
do {
try await interaction.donate()
} catch {
logger.error("Error donating interaction: \(String(describing: error))")
return
}
let updatedContent: UNMutableNotificationContent
do {
let newContent = try content.updating(from: intent)
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
pendingRequest?.0 = newMutableContent
updatedContent = newMutableContent
} else {
updatedContent = content
}
} catch {
logger.error("Error updating notification from intent: \(String(describing: error))")
updatedContent = content
}
if let localAttachmentURL = await attachmentDataTask?.value,
let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) {
updatedContent.attachments = [
attachment
]
}
}
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
DispatchQueue.main.sync {
// this is necessary because of a swift bug: https://github.com/apple/swift/pull/72507
MainActor.runUnsafely {
PushManager.shared.pushSubscription(account: account)
}
}
}
private func decryptNotification(subscription: PushNotifications.PushSubscription, serverPublicKeyData: Data, salt: Data, encryptedBody: Data) -> Data? {
// See https://github.com/ClearlyClaire/webpush/blob/f14a4d52e201128b1b00245d11b6de80d6cfdcd9/lib/webpush/encryption.rb
var context = Data()
context.append(0)
let clientPublicKey = subscription.secretKey.publicKey.x963Representation
let clientPublicKeyLength = UInt16(clientPublicKey.count)
context.append(UInt8((clientPublicKeyLength >> 8) & 0xFF))
context.append(UInt8(clientPublicKeyLength & 0xFF))
context.append(clientPublicKey)
let serverPublicKeyLength = UInt16(serverPublicKeyData.count)
context.append(UInt8((serverPublicKeyLength >> 8) & 0xFF))
context.append(UInt8(serverPublicKeyLength & 0xFF))
context.append(serverPublicKeyData)
func info(encoding: String) -> Data {
var info = Data("Content-Encoding: \(encoding)\0P-256".utf8)
info.append(context)
return info
}
let sharedSecret: SharedSecret
do {
let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData)
sharedSecret = try subscription.secretKey.sharedSecretFromKeyAgreement(with: serverPublicKey)
} catch {
logger.error("Error getting shared secret: \(String(describing: error))")
return nil
}
let sharedInfo = Data("Content-Encoding: auth\0".utf8)
let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: subscription.authSecret, sharedInfo: sharedInfo, outputByteCount: 32)
let contentEncryptionKeyInfo = info(encoding: "aesgcm")
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: contentEncryptionKeyInfo, outputByteCount: 16)
let nonceInfo = info(encoding: "nonce")
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceAndEncryptedBody = nonce.withUnsafeBytes { noncePtr in
var data = Data(buffer: noncePtr.bindMemory(to: UInt8.self))
data.append(encryptedBody)
return data
}
do {
let sealedBox = try AES.GCM.SealedBox(combined: nonceAndEncryptedBody)
let decrypted = try AES.GCM.open(sealedBox, using: contentEncryptionKey)
return decrypted
} catch {
logger.error("Error decrypting push: \(String(describing: error))")
return nil
}
}
}
extension MainActor {
@_unavailableFromAsync
@available(macOS, obsoleted: 14.0)
@available(iOS, obsoleted: 17.0)
@available(watchOS, obsoleted: 10.0)
@available(tvOS, obsoleted: 17.0)
@available(visionOS 1.0, *)
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
return try MainActor.assumeIsolated(body)
}
dispatchPrecondition(condition: .onQueue(.main))
return try withoutActuallyEscaping(body) { fn in
try unsafeBitCast(fn, to: (() throws -> T).self)()
}
}
}
private func decodeBase64URL(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
if str.count % 4 != 0 {
str.append(String(repeating: "=", count: 4 - str.count % 4))
}
return Data(base64Encoded: str)
}
// copied from HTMLConverter.Callbacks, blergh
private struct HTMLCallbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? {
// Converting WebURL to URL is a small but non-trivial expense (since it works by
// serializing the WebURL as a string and then having Foundation parse it again),
// so, if available, use the system parser which doesn't require another round trip.
if #available(iOS 16.0, macOS 13.0, *),
let url = try? URL.ParseStrategy().parse(string) {
url
} else if let web = WebURL(string),
let url = URL(web) {
url
} else {
URL(string: string)
}
}
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
guard name == "span" else {
return .default
}
let clazz = attributes.attributeValue(for: "class")
if clazz == "invisible" {
return .skip
} else if clazz == "ellipsis" {
return .append("")
} else {
return .default
}
}
}

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
</dict>
</array>
</dict>
</plist>

View File

@ -1,29 +0,0 @@
//
// Action.js
// OpenInTusker
//
// Created by Shadowfacts on 5/22/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
var Action = function() {};
Action.prototype = {
run: function(arguments) {
const results = {
url: window.location.href,
};
const el = document.querySelector('link[rel=alternate][type="application/activity+json"]');
if (el) {
results.activityPubURL = el.href;
}
arguments.completionFunction(results);
},
finalize: function(arguments) {
}
};
var ExtensionPreprocessingJS = new Action();

View File

@ -1,105 +0,0 @@
//
// ActionViewController.swift
// OpenInTusker
//
// Created by Shadowfacts on 5/23/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
class ActionViewController: UIViewController {
@IBOutlet weak var imageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
findURLFromWebPage { (components) in
DispatchQueue.main.async {
if let components {
self.searchForURLInApp(components)
} else {
self.findURLItem { (components) in
if let components {
DispatchQueue.main.async {
self.searchForURLInApp(components)
}
}
}
}
}
}
}
private func findURLFromWebPage(completion: @escaping @Sendable (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
continue
}
provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { (result, error) in
guard let result = result as? [String: Any],
let jsResult = result[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
let urlString = jsResult["activityPubURL"] as? String ?? jsResult["url"] as? String,
let components = URLComponents(string: urlString) else {
completion(nil)
return
}
completion(components)
}
return
}
}
completion(nil)
}
private func findURLItem(completion: @escaping @Sendable (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {
continue
}
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (result, error) in
guard let result = result as? URL,
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
completion(nil)
return
}
completion(components)
}
return
}
}
completion(nil)
}
private func searchForURLInApp(_ components: URLComponents) {
var components = components
components.scheme = "tusker"
self.openURL(components.url!)
self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
}
@objc private func openURL(_ url: URL) {
var responder: UIResponder = self
while let parent = responder.next {
if let application = parent as? UIApplication {
application.perform(#selector(openURL(_:)), with: url)
break
} else {
responder = parent
}
}
}
@IBAction func done() {
extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
}
}

View File

@ -1,65 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="ObA-dk-sSI">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Image-->
<scene sceneID="7MM-of-jgj">
<objects>
<viewController title="Image" id="ObA-dk-sSI" customClass="ActionViewController" customModule="OpenInTusker" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="zMn-AG-sqS">
<rect key="frame" x="0.0" y="0.0" width="320" height="528"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<navigationBar contentMode="scaleToFill" horizontalCompressionResistancePriority="751" verticalCompressionResistancePriority="751" translatesAutoresizingMaskIntoConstraints="NO" id="NOA-Dm-cuz">
<rect key="frame" x="0.0" y="44" width="320" height="44"/>
<items>
<navigationItem id="3HJ-uW-3hn">
<barButtonItem key="leftBarButtonItem" title="Done" style="done" id="WYi-yp-eM6">
<connections>
<action selector="done" destination="ObA-dk-sSI" id="Qdu-qn-U6V"/>
</connections>
</barButtonItem>
</navigationItem>
</items>
</navigationBar>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Unable to find Mastodon link on this page." textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yho-gp-VyR">
<rect key="frame" x="0.0" y="254" width="320" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="VVe-Uw-JpX"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="VVe-Uw-JpX" firstAttribute="trailing" secondItem="NOA-Dm-cuz" secondAttribute="trailing" id="A05-Pj-hrr"/>
<constraint firstItem="NOA-Dm-cuz" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="HxO-8t-aoh"/>
<constraint firstItem="Yho-gp-VyR" firstAttribute="centerY" secondItem="zMn-AG-sqS" secondAttribute="centerY" id="R7q-OB-hhA"/>
<constraint firstItem="Yho-gp-VyR" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="TEy-zi-dP7"/>
<constraint firstItem="Yho-gp-VyR" firstAttribute="trailing" secondItem="VVe-Uw-JpX" secondAttribute="trailing" id="Uvn-0x-Y6N"/>
<constraint firstItem="NOA-Dm-cuz" firstAttribute="top" secondItem="VVe-Uw-JpX" secondAttribute="top" id="we0-1t-bgp"/>
</constraints>
</view>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<size key="freeformSize" width="320" height="528"/>
<connections>
<outlet property="view" destination="zMn-AG-sqS" id="Qma-de-2ek"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="X47-rx-isc" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-61" y="-57"/>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Open in Tusker</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>Action</string>
<key>NSExtensionServiceAllowsFinderPreviewItem</key>
<true/>
<key>NSExtensionServiceAllowsTouchBarItem</key>
<true/>
<key>NSExtensionServiceFinderPreviewIconName</key>
<string>NSActionTemplate</string>
<key>NSExtensionServiceTouchBarBezelColorName</key>
<string>TouchBarBezel</string>
<key>NSExtensionServiceTouchBarIconName</key>
<string>NSActionTemplate</string>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
</dict>
</dict>
</plist>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 900 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,103 +0,0 @@
{
"images" : [
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "60x60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "60x60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "76x76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "1024x1024@1x.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,14 +0,0 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
},
"colors" : [
{
"idiom" : "mac",
"color" : {
"reference" : "systemPurpleColor"
}
}
]
}

319
Pachyderm/Client.swift Normal file
View File

@ -0,0 +1,319 @@
//
// Client.swift
// Pachyderm
//
// Created by Shadowfacts on 9/8/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
/**
The base Mastodon API client.
*/
public class Client {
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
let baseURL: URL
let session: URLSession
public var accessToken: String?
public var appID: String?
public var clientID: String?
public var clientSecret: String?
public var timeoutInterval: TimeInterval = 60
lazy var decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
self.baseURL = baseURL
self.accessToken = accessToken
self.session = session
}
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) {
guard let request = createURLRequest(request: request) else {
completion(.failure(Error.invalidRequest))
return
}
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data,
let response = response as? HTTPURLResponse else {
completion(.failure(Error.invalidResponse))
return
}
guard response.statusCode == 200 else {
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
completion(.failure(error))
return
}
guard let result = try? self.decoder.decode(Result.self, from: data) else {
completion(.failure(Error.invalidModel))
return
}
if var result = result as? ClientModel {
result.client = self
} else if var result = result as? [ClientModel] {
result.client = self
}
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
completion(.success(result, pagination))
}
task.resume()
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.path
components.queryItems = request.queryParameters.queryItems
guard let url = components.url else { return nil }
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
urlRequest.httpMethod = request.method.name
urlRequest.httpBody = request.body.data
urlRequest.setValue(request.body.mimeType, forHTTPHeaderField: "Content-Type")
if let accessToken = accessToken {
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
return urlRequest
}
// MARK: - Authorization
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
"client_name" => name,
"redirect_uris" => redirectURI,
"scopes" => scopes.scopeString,
"website" => website?.absoluteString
]))
run(request) { result in
defer { completion(result) }
guard case let .success(application, _) = result else { return }
self.appID = application.id
self.clientID = application.clientID
self.clientSecret = application.clientSecret
}
}
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
"client_id" => clientID,
"client_secret" => clientSecret,
"grant_type" => "authorization_code",
"code" => authorizationCode,
"redirect_uri" => redirectURI
]))
run(request) { result in
defer { completion(result) }
guard case let .success(loginSettings, _) = result else { return }
self.accessToken = loginSettings.accessToken
}
}
// MARK: - Self
public func getSelfAccount() -> Request<Account> {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
}
public func getFavourites() -> Request<[Status]> {
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
}
public func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
}
public func getInstance() -> Request<Instance> {
return Request<Instance>(method: .get, path: "/api/v1/instance")
}
public func getCustomEmoji() -> Request<[Emoji]> {
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
}
// MARK: - Accounts
public func getAccount(id: String) -> Request<Account> {
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
}
public func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil) -> Request<[Account]> {
return Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [
"q" => query,
"limit" => limit,
"following" => following
])
}
// MARK: - Blocks
public func getBlocks() -> Request<[Account]> {
return Request<[Account]>(method: .get, path: "/api/v1/blocks")
}
public func getDomainBlocks() -> Request<[String]> {
return Request<[String]>(method: .get, path: "api/v1/domain_blocks")
}
public func block(domain: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
"domain" => domain
]))
}
public func unblock(domain: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
"domain" => domain
]))
}
// MARK: - Filters
public func getFilters() -> Request<[Filter]> {
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
}
public func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
"phrase" => phrase,
"irreversible" => irreversible,
"whole_word" => wholeWord,
"expires_at" => expiresAt
] + "context" => context.contextStrings))
}
public func getFilter(id: String) -> Request<Filter> {
return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
}
// MARK: - Follows
public func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
request.range = range
return request
}
public func getFollowSuggestions() -> Request<[Account]> {
return Request<[Account]>(method: .get, path: "/api/v1/suggestions")
}
public func followRemote(acct: String) -> Request<Account> {
return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
}
// MARK: - Lists
public func getLists() -> Request<[List]> {
return Request<[List]>(method: .get, path: "/api/v1/lists")
}
public func getList(id: String) -> Request<List> {
return Request<List>(method: .get, path: "/api/v1/lists/\(id)")
}
public func createList(title: String) -> Request<List> {
return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
}
// MARK: - Media
public func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
"description" => description,
"focus" => focus
], attachment))
}
// MARK: - Mutes
public func getMutes(range: RequestRange) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
request.range = range
return request
}
// MARK: - Notifications
public func getNotifications(excludeTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
"exclude_types" => excludeTypes.map { $0.rawValue }
)
request.range = range
return request
}
public func clearNotifications() -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
}
// MARK: - Reports
public func getReports() -> Request<[Report]> {
return Request<[Report]>(method: .get, path: "/api/v1/reports")
}
public func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
"account_id" => account.id,
"comment" => comment
] + "status_ids" => statuses.map { $0.id }))
}
// MARK: - Search
public func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
"q" => query,
"resolve" => resolve,
"limit" => limit
])
}
// MARK: - Statuses
public func getStatus(id: String) -> Request<Status> {
return Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
}
public func createStatus(text: String,
contentType: StatusContentType = .plain,
inReplyTo: String? = nil,
media: [Attachment]? = nil,
sensitive: Bool? = nil,
spoilerText: String? = nil,
visibility: Status.Visibility? = nil,
language: String? = nil) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
"status" => text,
"content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo,
"sensitive" => sensitive,
"spoiler_text" => spoilerText,
"visibility" => visibility?.rawValue,
"language" => language
] + "media_ids" => media?.map { $0.id }))
}
// MARK: - Timelines
public func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
return timeline.request(range: range)
}
}
extension Client {
public enum Error: Swift.Error {
case unknownError
case invalidRequest
case invalidResponse
case invalidModel
case mastodonError(String)
}
}

View File

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

22
Pachyderm/Info.plist Normal file
View File

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

View File

@ -8,7 +8,7 @@
import Foundation
public final class Account: AccountProtocol, Decodable, Sendable {
public class Account: Decodable {
public let id: String
public let username: String
public let acct: String
@ -20,20 +20,18 @@ public final class Account: AccountProtocol, Decodable, Sendable {
public let statusesCount: Int
public let note: String
public let url: URL
// required on mastodon, but optional on gotosocial
public let avatar: URL?
public let avatarStatic: URL?
public let avatar: URL
public let avatarStatic: URL
public let header: URL?
public let headerStatic: URL?
public let emojis: [Emoji]
public private(set) var emojis: [Emoji]
public let moved: Bool?
public let movedTo: Account?
public let fields: [Field]
public let fields: [Field]?
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 {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.username = try container.decode(String.self, forKey: .username)
self.acct = try container.decode(String.self, forKey: .acct)
@ -45,61 +43,55 @@ public final class Account: AccountProtocol, Decodable, Sendable {
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
self.note = try container.decode(String.self, forKey: .note)
self.url = try container.decode(URL.self, forKey: .url)
self.avatar = try? container.decode(URL.self, forKey: .avatar)
self.avatarStatic = try? container.decode(URL.self, forKey: .avatarStatic)
self.header = try? container.decode(URL.self, forKey: .header)
self.headerStatic = try? container.decode(URL.self, forKey: .headerStatic)
// even up-to-date pixelfed instances sometimes lack this, for reasons unclear
if let emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) {
self.emojis = emojis
self.avatar = try container.decode(URL.self, forKey: .avatar)
self.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic)
if let header = try? container.decodeIfPresent(String.self, forKey: .header),
let url = URL(string: header) {
self.header = url
} else {
self.emojis = []
self.header = nil
}
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
if let headerStatic = try? container.decodeIfPresent(String.self, forKey: .headerStatic),
let url = URL(string: headerStatic) {
self.headerStatic = url
} else {
self.moved = false
self.movedTo = nil
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(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize")
public static func authorizeFollowRequest(_ account: Account) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
}
public static func rejectFollowRequest(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject")
public static func rejectFollowRequest(_ account: Account) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject")
}
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
}
public static func getFollowers(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/followers")
public static func getFollowers(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/followers")
request.range = range
return request
}
public static func getFollowing(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/following")
public static func getFollowing(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/following")
request.range = range
return request
}
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
"only_media" => onlyMedia,
"pinned" => pinned,
"exclude_replies" => excludeReplies,
"exclude_reblogs" => excludeReblogs,
"exclude_replies" => excludeReplies
])
request.range = range
return request
@ -109,32 +101,26 @@ public final class Account: AccountProtocol, Decodable, Sendable {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/follow")
}
public static func setShowReblogs(_ accountID: String, showReblogs: Bool) -> Request<Relationship> {
return Request(method: .post, path: "/api/v1/accounts/\(accountID)/follow", body: ParametersBody([
"reblogs" => showReblogs
]))
}
public static func unfollow(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
}
public static func block(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/block")
public static func block(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/block")
}
public static func unblock(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unblock")
public static func unblock(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unblock")
}
public static func mute(_ accountID: String, notifications: Bool? = nil) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/mute", body: ParametersBody([
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: .parameters([
"notifications" => notifications
]))
}
public static func unmute(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unmute")
public static func unmute(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unmute")
}
public static func getLists(_ account: Account) -> Request<[List]> {
@ -171,15 +157,8 @@ extension Account: CustomDebugStringConvertible {
}
extension Account {
public struct Field: Codable, Equatable, Sendable {
public struct Field: Codable {
public let name: String
public let value: String
public let verifiedAt: Date?
enum CodingKeys: String, CodingKey {
case name
case value
case verifiedAt = "verified_at"
}
}
}

View File

@ -0,0 +1,19 @@
//
// Application.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class Application: Decodable {
public let name: String
public let website: URL?
private enum CodingKeys: String, CodingKey {
case name
case website
}
}

View File

@ -8,44 +8,41 @@
import Foundation
public struct Attachment: Codable, Sendable {
public class Attachment: Decodable {
public let id: String
public let kind: Kind
public let url: URL
public let remoteURL: URL?
public let previewURL: URL?
public let previewURL: URL
public let textURL: URL?
public let meta: Metadata?
public let description: String?
public let blurHash: String?
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),
"focus" => focus
], nil))
}
public init(id: String, kind: Attachment.Kind, url: URL, remoteURL: URL? = nil, previewURL: URL? = nil, meta: Attachment.Metadata? = nil, description: String? = nil, blurHash: String? = nil) {
self.id = id
self.kind = kind
self.url = url
self.remoteURL = remoteURL
self.previewURL = previewURL
self.meta = meta
self.description = description
self.blurHash = blurHash
}
public init(from decoder: Decoder) throws {
required public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.kind = try container.decode(Kind.self, forKey: .kind)
self.url = try container.decode(URL.self, forKey: .url)
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL)
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL)
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
self.description = try? container.decode(String?.self, forKey: .description)
self.blurHash = try? container.decode(String?.self, forKey: .blurHash)
self.url = URL(lenient: try container.decode(String.self, forKey: .url))!
if let remote = try? container.decode(String.self, forKey: .remoteURL) {
self.remoteURL = URL(lenient: remote.replacingOccurrences(of: " ", with: "%20"))
} else {
self.remoteURL = nil
}
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 {
@ -54,41 +51,35 @@ public struct Attachment: Codable, Sendable {
case url
case remoteURL = "remote_url"
case previewURL = "preview_url"
case textURL = "text_url"
case meta
case description
case blurHash = "blurhash"
}
}
extension Attachment {
public enum Kind: String, Codable, Sendable {
public enum Kind: String, Decodable {
case image
case video
case gifv
case audio
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()
switch try container.decode(String.self) {
// gotosocial uses "gif" for gif images
case "image", "gif":
self = .image
case "video":
self = .video
case "gifv":
self = .gifv
case "audio":
self = .audio
default:
self = .unknown
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 {
public struct Metadata: Codable, Sendable {
public class Metadata: Decodable {
public let length: String?
public let duration: Float?
public let audioEncoding: String?
@ -119,7 +110,7 @@ extension Attachment {
}
}
public struct ImageMetadata: Codable, Sendable {
public class ImageMetadata: Decodable {
public let width: Int?
public let height: Int?
public let size: String?
@ -133,3 +124,14 @@ extension Attachment {
}
}
}
fileprivate extension URL {
private static let allowedChars = CharacterSet.urlHostAllowed.union(.urlPathAllowed).union(.urlQueryAllowed)
init?(lenient string: String) {
guard let escaped = string.addingPercentEncoding(withAllowedCharacters: URL.allowedChars) else {
return nil
}
self.init(string: escaped)
}
}

View File

@ -0,0 +1,48 @@
//
// Card.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class Card: Decodable {
public let url: URL
public let title: String
public let description: String
public let image: URL?
public let kind: Kind
public let authorName: String?
public let authorURL: URL?
public let providerName: String?
public let providerURL: URL?
public let html: String?
public let width: Int?
public let height: Int?
private enum CodingKeys: String, CodingKey {
case url
case title
case description
case image
case kind = "type"
case authorName = "author_name"
case authorURL = "author_url"
case providerName = "provider_name"
case providerURL = "provider_url"
case html
case width
case height
}
}
extension Card {
public enum Kind: String, Decodable {
case link
case photo
case video
case rich
}
}

View File

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

View File

@ -0,0 +1,29 @@
//
// Emoji.swift
// Pachyderm
//
// Created by Shadowfacts on 9/8/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class Emoji: Decodable {
public let shortcode: String
public let url: URL
public let staticURL: URL
public let visibleInPicker: Bool
private enum CodingKeys: String, CodingKey {
case shortcode
case url
case staticURL = "static_url"
case visibleInPicker = "visible_in_picker"
}
}
extension Emoji: CustomDebugStringConvertible {
public var debugDescription: String {
return ":\(shortcode):"
}
}

View File

@ -1,5 +1,5 @@
//
// FilterV1.swift
// Filter.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
@ -8,7 +8,7 @@
import Foundation
public struct FilterV1: Decodable, Sendable {
public class Filter: Decodable {
public let id: String
public let phrase: String
private let context: [String]
@ -22,16 +22,17 @@ public struct FilterV1: Decodable, Sendable {
}
}
public static func update(_ filterID: String, phrase: String, context: [Context], irreversible: Bool, wholeWord: Bool, expiresIn: TimeInterval?) -> Request<FilterV1> {
return Request<FilterV1>(method: .put, path: "/api/v1/filters/\(filterID)", body: ParametersBody([
"phrase" => phrase,
"whole_word" => wholeWord,
"expires_in" => expiresIn,
] + "context" => context.contextStrings))
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([
"phrase" => (phrase ?? filter.phrase),
"irreversible" => (irreversible ?? filter.irreversible),
"whole_word" => (wholeWord ?? filter.wholeWord),
"expires_at" => (expiresAt ?? filter.expiresAt)
] + "context" => (context?.contextStrings ?? filter.context)))
}
public static func delete(_ filterID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filterID)")
public static func delete(_ filter: Filter) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filter.id)")
}
private enum CodingKeys: String, CodingKey {
@ -44,17 +45,16 @@ public struct FilterV1: Decodable, Sendable {
}
}
extension FilterV1 {
public enum Context: String, Decodable, CaseIterable, Sendable {
extension Filter {
public enum Context: String, Decodable {
case home
case notifications
case `public`
case thread
case account
}
}
extension Array where Element == FilterV1.Context {
extension Array where Element == Filter.Context {
var contextStrings: [String] {
return map { $0.rawValue }
}

View File

@ -0,0 +1,51 @@
//
// Hashtag.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class Hashtag: Decodable {
public let name: String
public let url: URL
public let history: [History]?
public init(name: String, url: URL) {
self.name = name
self.url = url
self.history = nil
}
private enum CodingKeys: String, CodingKey {
case name
case url
case history
}
}
extension Hashtag {
public class History: Decodable {
public let day: Date
public let uses: Int
public let accounts: Int
private enum CodingKeys: String, CodingKey {
case day
case uses
case accounts
}
}
}
extension Hashtag: Equatable, Hashable {
public static func ==(lhs: Hashtag, rhs: Hashtag) -> Bool {
return lhs.url == rhs.url
}
public func hash(into hasher: inout Hasher) {
hasher.combine(url)
}
}

View File

@ -0,0 +1,87 @@
//
// Instance.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class Instance: Decodable {
public let uri: String
public let title: String
public let description: String
public let email: String?
public let version: String
public let urls: [String: URL]
public let thumbnail: URL?
public let languages: [String]?
public let stats: Stats?
// pleroma doesn't currently implement these
public let contactAccount: Account?
// MARK: Unofficial additions to the Mastodon API.
public let maxStatusCharacters: Int?
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.uri = try container.decode(String.self, forKey: .uri)
self.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description)
self.email = try container.decodeIfPresent(String.self, forKey: .email)
self.version = try container.decode(String.self, forKey: .version)
if let urls = try? container.decodeIfPresent([String: URL].self, forKey: .urls) {
self.urls = urls
} else {
self.urls = [:]
}
self.languages = try? container.decodeIfPresent([String].self, forKey: .languages)
self.contactAccount = try? container.decodeIfPresent(Account.self, forKey: .contactAccount)
self.stats = try? container.decodeIfPresent(Stats.self, forKey: .stats)
self.thumbnail = try? container.decodeIfPresent(URL.self, forKey: .thumbnail)
if let maxStatusCharacters = try? container.decodeIfPresent(Int.self, forKey: .maxStatusCharacters) {
self.maxStatusCharacters = maxStatusCharacters
} else if let str = try? container.decodeIfPresent(String.self, forKey: .maxStatusCharacters),
let maxStatusCharacters = Int(str, radix: 10) {
self.maxStatusCharacters = maxStatusCharacters
} else {
self.maxStatusCharacters = nil
}
}
private enum CodingKeys: String, CodingKey {
case uri
case title
case description
case email
case version
case urls
case thumbnail
case languages
case stats
case contactAccount = "contact_account"
case maxStatusCharacters = "max_toot_chars"
}
}
extension Instance {
public class Stats: Decodable {
public let domainCount: Int?
public let statusCount: Int?
public let userCount: Int?
private enum CodingKeys: String, CodingKey {
case domainCount = "domain_count"
case statusCount = "status_count"
case userCount = "user_count"
}
}
}

View File

@ -0,0 +1,45 @@
//
// List.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class List: Decodable {
public let id: String
public let title: String
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
request.range = range
return request
}
public static func update(_ list: List, title: String) -> Request<List> {
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: .parameters(["title" => title]))
}
public static func delete(_ list: List) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
}
public static func add(_ list: List, accounts: [Account]) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
"account_ids" => accounts.map { $0.id }
))
}
public static func remove(_ list: List, accounts: [Account]) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
"account_ids" => accounts.map { $0.id }
))
}
private enum CodingKeys: String, CodingKey {
case id
case title
}
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct LoginSettings: Decodable, Sendable {
public class LoginSettings: Decodable {
public let accessToken: String
private let scope: String?

View File

@ -0,0 +1,17 @@
//
// MastodonError.swift
// Pachyderm
//
// Created by Shadowfacts on 9/8/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
struct MastodonError: Decodable, CustomStringConvertible {
var description: String
private enum CodingKeys: String, CodingKey {
case description = "error"
}
}

View File

@ -0,0 +1,23 @@
//
// Mention.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class Mention: Decodable {
public let url: URL
public let username: String
public let acct: String
public let id: String
private enum CodingKeys: String, CodingKey {
case url
case username
case acct
case id
}
}

View File

@ -0,0 +1,42 @@
//
// Notification.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class Notification: Decodable {
public let id: String
public let kind: Kind
public let createdAt: Date
public let account: Account
public let status: Status?
public static func dismiss(id notificationID: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([
"id" => notificationID
]))
}
private enum CodingKeys: String, CodingKey {
case id
case kind = "type"
case createdAt = "created_at"
case account
case status
}
}
extension Notification {
public enum Kind: String, Decodable, CaseIterable {
case mention
case reblog
case favourite
case follow
}
}
extension Notification: Identifiable {}

View File

@ -0,0 +1,24 @@
//
// PushSubscription.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class PushSubscription: Decodable {
public let id: String
public let endpoint: URL
public let serverKey: String
// TODO: WTF is this?
// public let alerts
private enum CodingKeys: String, CodingKey {
case id
case endpoint
case serverKey = "server_key"
// case alerts
}
}

View File

@ -0,0 +1,42 @@
//
// RegisteredApplication.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class RegisteredApplication: Decodable {
public let id: String
public let clientID: String
public let clientSecret: String
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let id = try? container.decode(String.self, forKey: .id) {
self.id = id
} else if let id = try? container.decode(Int.self, forKey: .id) {
self.id = String(id)
} else {
throw DecodingError.dataCorruptedError(forKey: CodingKeys.id, in: container, debugDescription: "Expect application id to be string or number")
}
if let clientID = try? container.decode(String.self, forKey: .clientID) {
self.clientID = clientID
} else if let clientID = try? container.decode(Int.self, forKey: .clientID) {
self.clientID = String(clientID)
} else {
throw DecodingError.dataCorruptedError(forKey: CodingKeys.id, in: container, debugDescription: "Expect client id to be string or number")
}
self.clientSecret = try container.decode(String.self, forKey: .clientSecret)
}
private enum CodingKeys: String, CodingKey {
case id
case clientID = "client_id"
case clientSecret = "client_secret"
}
}

View File

@ -0,0 +1,33 @@
//
// Relationship.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class Relationship: Decodable {
public let id: String
public let following: Bool
public let followedBy: Bool
public let blocking: Bool
public let muting: Bool
public let mutingNotifications: Bool
public let followRequested: Bool
public let domainBlocking: Bool
public let showingReblogs: Bool
private enum CodingKeys: String, CodingKey {
case id
case following
case followedBy = "followed_by"
case blocking
case muting
case mutingNotifications = "muting_notifications"
case followRequested = "requested"
case domainBlocking = "domain_blocking"
case showingReblogs = "showing_reblogs"
}
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct Report: Decodable, Sendable {
public class Report: Decodable {
public let id: String
public let actionTaken: Bool

View File

@ -8,11 +8,10 @@
import Foundation
public enum Scope: String, Sendable {
public enum Scope: String {
case read
case write
case follow
case push
}
extension Array where Element == Scope {

View File

@ -8,7 +8,7 @@
import Foundation
public struct SearchResults: Decodable, Sendable {
public class SearchResults: Decodable {
public let accounts: [Account]
public let statuses: [Status]
public let hashtags: [Hashtag]

View File

@ -0,0 +1,133 @@
//
// Status.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
public class Status: Decodable {
public let id: String
public let uri: String
public let url: URL?
public let account: Account
public let inReplyToID: String?
public let inReplyToAccountID: String?
public let reblog: Status?
public let content: String
public let createdAt: Date
public let emojis: [Emoji]
// TODO: missing from pleroma
// public let repliesCount: Int
public let reblogsCount: Int
public let favouritesCount: Int
public let reblogged: Bool?
public let favourited: Bool?
public let muted: Bool?
public let sensitive: Bool
public let spoilerText: String
public let visibility: Visibility
public let attachments: [Attachment]
public let mentions: [Mention]
public let hashtags: [Hashtag]
public let application: Application?
public let language: String?
public let pinned: Bool?
public static func getContext(_ status: Status) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
}
public static func getCard(_ status: Status) -> Request<Card> {
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
}
public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
request.range = range
return request
}
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by")
request.range = range
return request
}
public static func delete(_ status: Status) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
}
public static func reblog(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog")
}
public static func unreblog(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
}
public static func favourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
}
public static func unfavourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
}
public static func pin(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/pin")
}
public static func unpin(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unpin")
}
public static func muteConversation(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/mute")
}
public static func unmuteConversation(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unmute")
}
private enum CodingKeys: String, CodingKey {
case id
case uri
case url
case account
case inReplyToID = "in_reply_to_id"
case inReplyToAccountID = "in_reply_to_account_id"
case reblog
case content
case createdAt = "created_at"
case emojis
// case repliesCount = "replies_count"
case reblogsCount = "reblogs_count"
case favouritesCount = "favourites_count"
case reblogged
case favourited
case muted
case sensitive
case spoilerText = "spoiler_text"
case visibility
case attachments = "media_attachments"
case mentions
case hashtags = "tags"
case application
case language
case pinned
}
}
extension Status {
public enum Visibility: String, Codable, CaseIterable {
case `public`
case unlisted
case `private`
case direct
}
}
extension Status: Identifiable {}

View File

@ -8,7 +8,7 @@
import Foundation
public enum StatusContentType: String, Codable, CaseIterable, Sendable {
public enum StatusContentType: String, Codable, CaseIterable {
case plain, markdown, html
var mimeType: String {

View File

@ -8,7 +8,7 @@
import Foundation
public enum Timeline: Equatable, Hashable, Sendable {
public enum Timeline {
case home
case `public`(local: Bool)
case tag(hashtag: String)
@ -17,7 +17,7 @@ public enum Timeline: Equatable, Hashable, Sendable {
}
extension Timeline {
var endpoint: Endpoint {
var endpoint: String {
switch self {
case .home:
return "/api/v1/timelines/home"
@ -33,13 +33,11 @@ extension Timeline {
}
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 {
request.queryParameters.append("local" => true)
}
request.range = range
// 206 can happen when the timeline is being regenerated and therefore is incomplete
request.additionalAcceptableHTTPCodes = [206]
return request
}
}

19
Pachyderm/Pachyderm.h Normal file
View File

@ -0,0 +1,19 @@
//
// Pachyderm.h
// Pachyderm
//
// Created by Shadowfacts on 9/8/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
#import <UIKit/UIKit.h>
//! Project version number for Pachyderm.
FOUNDATION_EXPORT double PachydermVersionNumber;
//! Project version string for Pachyderm.
FOUNDATION_EXPORT const unsigned char PachydermVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <Pachyderm/PublicHeader.h>

View File

@ -0,0 +1,63 @@
//
// Body.swift
// Pachyderm
//
// Created by Shadowfacts on 9/8/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
enum Body {
case parameters([Parameter]?)
case formData([Parameter]?, FormAttachment?)
case empty
}
extension Body {
private static let boundary: String = "PachydermBoundary"
var data: Data? {
switch self {
case let .parameters(parameters):
return parameters?.urlEncoded.data(using: .utf8)
case let .formData(parameters, attachment):
var data = Data()
parameters?.forEach { param in
guard let value = param.value else { return }
data.append("--\(Body.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
data.append("\(value)\r\n")
}
if let attachment = attachment {
data.append("--\(Body.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
data.append(attachment.data)
data.append("\r\n")
}
data.append("--\(Body.boundary)--\r\n")
return data
case .empty:
return nil
}
}
var mimeType: String? {
switch self {
case let .parameters(parameters):
if parameters == nil {
return nil
}
return "application/x-www-form-urlencoded; charset=utf-8"
case let .formData(parameters, attachment):
if parameters == nil && attachment == nil {
return nil
}
return "multipart/form-data; boundary=\(Body.boundary)"
case .empty:
return nil
}
}
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct FormAttachment: Sendable {
public struct FormAttachment {
let mimeType: String
let data: Data
let fileName: String

View File

@ -8,12 +8,12 @@
import Foundation
public enum Method: Sendable {
enum Method {
case get, post, put, patch, delete
}
extension Method {
public var name: String {
var name: String {
switch self {
case .get:
return "GET"

View File

@ -8,7 +8,7 @@
import Foundation
struct Parameter: Sendable {
struct Parameter {
let name: String
let value: String?
}
@ -42,10 +42,6 @@ extension String {
}
}
static func =>(name: String, value: TimeInterval?) -> Parameter {
return name => (value == nil ? nil : Int(value!))
}
static func =>(name: String, focus: (Float, Float)?) -> Parameter {
guard let focus = focus else { return Parameter(name: name, value: nil) }
return Parameter(name: name, value: "\(focus.0),\(focus.1)")
@ -56,10 +52,6 @@ extension String {
let name = "\(name)[]"
return values.map { Parameter(name: name, value: $0) }
}
static func =>(name: String, values: [Int]) -> [Parameter] {
return name => values.map { $0.description }
}
}
extension Parameter: CustomStringConvertible {
@ -75,11 +67,8 @@ extension Parameter: CustomStringConvertible {
extension Array where Element == Parameter {
var urlEncoded: String {
return compactMap {
guard let value = $0.value,
let escapedValue = value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else {
return nil
}
return "\($0.name)=\(escapedValue)"
guard let value = $0.value else { return nil }
return "\($0.name)=\(value)"
}.joined(separator: "&")
}

View File

@ -8,17 +8,15 @@
import Foundation
public struct Request<ResultType: Decodable>: Sendable {
public struct Request<ResultType: Decodable> {
let method: Method
let endpoint: Endpoint
let path: String
let body: Body
var queryParameters: [Parameter]
var headers: [String: String] = [:]
var additionalAcceptableHTTPCodes: [Int] = []
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
self.method = method
self.endpoint = path
self.path = path
self.body = body
self.queryParameters = queryParameters
}

View File

@ -8,24 +8,11 @@
import Foundation
public enum RequestRange: Sendable {
public enum RequestRange {
case `default`
case count(Int)
/// Chronologically immediately before the given ID
case before(id: String, count: Int?)
/// Chronologically immediately after the given ID
case after(id: String, count: Int?)
public func withCount(_ count: Int) -> Self {
switch self {
case .default, .count(_):
return .count(count)
case .before(id: let id, count: _):
return .before(id: id, count: count)
case .after(id: let id, count: _):
return .after(id: id, count: count)
}
}
}
extension RequestRange {

View File

@ -8,6 +8,6 @@
import Foundation
public struct Empty: Decodable, Sendable {
public struct Empty: Decodable {
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct Pagination: Sendable {
public struct Pagination {
public let older: RequestRange?
public let newer: RequestRange?
}

View File

@ -8,7 +8,7 @@
import Foundation
public enum Response<Result: Decodable & Sendable>: Sendable {
public enum Response<Result: Decodable> {
case success(Result, Pagination?)
case failure(Client.Error)
case failure(Error)
}

View File

@ -1,25 +1,24 @@
//
// CharacterCounter.swift
// ComposeUI
// Pachyderm
//
// Created by Shadowfacts on 9/29/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
import InstanceFeatures
public struct CharacterCounter {
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
public static func count(text: String) -> Int {
let mentionsRemoved = removeMentions(in: text)
var count = mentionsRemoved.count
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
count -= match.range.length
count += instanceFeatures.charsReservedPerURL
count += 23 // Mastodon link length
}
return count
}

View File

@ -12,7 +12,7 @@ public class InstanceSelector {
private static let decoder = JSONDecoder()
public static func getInstances(category: String?, completion: @escaping (Result<[Instance], Client.ErrorType>) -> Void) {
public static func getInstances(category: String?, completion: @escaping Client.Callback<[Instance]>) {
let url: URL
if let category = category {
url = URL(string: "https://api.joinmastodon.org/servers?category=\(category)")!
@ -22,26 +22,23 @@ public class InstanceSelector {
let request = URLRequest(url: url)
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
completion(.failure(.networkError(error)))
completion(.failure(error))
return
}
guard let data = data,
let response = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse))
completion(.failure(Client.Error.invalidResponse))
return
}
guard response.statusCode == 200 else {
completion(.failure(.unexpectedStatus(response.statusCode)))
completion(.failure(Client.Error.unknownError))
return
}
let result: [Instance]
do {
result = try decoder.decode([Instance].self, from: data)
} catch {
completion(.failure(.invalidModel(error)))
guard let result = try? decoder.decode([Instance].self, from: data) else {
completion(.failure(Client.Error.invalidModel))
return
}
completion(.success(result))
completion(.success(result, nil))
}
task.resume()
}
@ -49,12 +46,12 @@ public class InstanceSelector {
}
public extension InstanceSelector {
struct Instance: Codable, Sendable {
struct Instance: Codable {
public let domain: String
public let description: String
public let proxiedThumbnailURL: URL
public let language: String
public let category: String
public let category: Category
enum CodingKeys: String, CodingKey {
case domain
@ -65,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
}
}

View File

@ -0,0 +1,24 @@
//
// InstanceType.swift
// Pachyderm
//
// Created by Shadowfacts on 9/11/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import Foundation
public enum InstanceType {
case mastodon, pleroma
}
public extension Instance {
var instanceType: InstanceType {
let lowercased = version.lowercased()
if lowercased.contains("pleroma") {
return .pleroma
} else {
return .mastodon
}
}
}

View File

@ -0,0 +1,42 @@
//
// NotificationGroup.swift
// Pachyderm
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import Foundation
public class NotificationGroup {
public let notificationIDs: [String]
public let id: String
public let kind: Notification.Kind
init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil }
self.notificationIDs = notifications.map { $0.id }
self.id = notifications.first!.id
self.kind = notifications.first!.kind
}
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
return notifications.reduce(into: [[Notification]]()) { (groups, notification) in
if allowedTypes.contains(notification.kind),
let lastGroup = groups.last,
let firstStatus = lastGroup.first,
firstStatus.kind == notification.kind,
firstStatus.status?.id == notification.status?.id {
groups[groups.count - 1].append(notification)
} else {
groups.append([notification])
}
}.map {
NotificationGroup(notifications: $0)!
}
}
}
extension NotificationGroup: Identifiable {}

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