Compare commits

..

257 Commits

Author SHA1 Message Date
Shadowfacts 14e8c11f02
Bump build number and update changelog 2020-09-16 19:19:40 -04:00
Shadowfacts 9d9ea565f1
Fix crash opening Preferences with deleted accounts 2020-09-16 17:52:00 -04:00
Shadowfacts 99db842411
Fix content warning not being copied when replying 2020-09-16 17:41:27 -04:00
Shadowfacts 184fe49c0f
Fix visibility not being copied when replying 2020-09-16 17:32:01 -04:00
Shadowfacts 4719342a06
Bump build number and update changelog 2020-09-15 22:22:20 -04:00
Shadowfacts 6df5f7fb08
Add preferences for auto-expanding CW'd posts and disabling long post
collapsing

See #105
2020-09-15 21:37:08 -04:00
Shadowfacts 02135aa0de
Use inset list style for preferences on iOS 14 2020-09-15 20:48:53 -04:00
Shadowfacts be5a4c03a6
Fix attachments not being posted in the correct order. 2020-09-14 23:29:31 -04:00
Shadowfacts 2c1ba7926e
Support JSON request bodies 2020-09-14 23:25:26 -04:00
Shadowfacts 911e66a159
Allow more browsing of instance public timelines
Closes #74
2020-09-13 15:51:08 -04:00
Shadowfacts ab4bcfa50f
Fix profile screen title not being set 2020-09-13 15:34:45 -04:00
Shadowfacts b94bfca406
Fix crash tapping attachments on instance public timelines 2020-09-13 13:55:33 -04:00
Shadowfacts 7999ecafd0
Update SheetController 2020-09-13 13:27:52 -04:00
Shadowfacts 1c6e464a4c
Start Compose screen tests 2020-09-13 13:19:56 -04:00
Shadowfacts acd01a81cc
More UI tests for onboarding/my profile 2020-09-12 22:16:58 -04:00
Shadowfacts 8ac3deb55a
Remove old file 2020-09-12 22:04:41 -04:00
Shadowfacts 5e9cc430c6
Use cross fade transitions for displaying gallery and asset picker if
Reduce Motion/Prefer Cross Fade is enabled

Closes #108
2020-09-12 13:25:59 -04:00
Shadowfacts 0b6ef6517b
Fix gallery action buttons not being centered in device "ears" on iPhone
XR and 11
2020-09-12 12:01:16 -04:00
Shadowfacts 34a01094f7
Fix gallery expand animation description not starting at correct
position

Safe are insets weren't being taken into account when hiding the
controls, because the toVC had not yet been added to the container view
and thus didn't have anything to receive insets from.
2020-09-12 12:01:16 -04:00
Shadowfacts 95b215c6b5
Add Clear Image Cache option to Advanced prefs 2020-09-12 12:01:16 -04:00
Shadowfacts e21dceb3b3
Tweak gallery spring animation parameters 2020-09-12 12:01:16 -04:00
Shadowfacts 9534f19262
Show BlurHash previews of attachments 2020-09-12 12:01:08 -04:00
Shadowfacts e44ae29775
Improve asset picker opening animation 2020-09-10 23:24:24 -04:00
Shadowfacts a5b30c4243
Update PLCrashReporter 2020-09-10 23:24:14 -04:00
Shadowfacts 479ca23e00
Tweak follow request notification cells 2020-09-10 22:54:01 -04:00
Shadowfacts 5b03e0cf12
Fix follow notifications not showing names for users without explicit
display names
2020-09-09 18:45:38 -04:00
Shadowfacts 7c4bbfd730
Improve compose posting error messages 2020-09-09 18:33:59 -04:00
Shadowfacts e19a6528ad
Improve gallery expand animation
Use spring timing, slide in top/bottom controls
2020-09-08 23:41:15 -04:00
Shadowfacts f5110c773a
Tweak default font sizes 2020-09-07 18:49:25 -04:00
Shadowfacts fe1db72f19
Fix save draft sheet showing even when draft had no content 2020-09-07 17:15:18 -04:00
Shadowfacts b4ddb8f533
Fix safe area on Compose screen not including keyboard on iOS 13 2020-09-07 17:05:50 -04:00
Shadowfacts 9a4ddfea3f
Fix Compose reply scroll effect not working on iOS 13 2020-09-07 16:56:06 -04:00
Shadowfacts dd8a196630
Show custom emoji in display names on Compose screen 2020-09-07 15:22:06 -04:00
Shadowfacts 3da7aacb35
Fix visiblity context menu in main text view accessory not updating 2020-09-07 14:46:17 -04:00
Shadowfacts 39c8162931
Prevent attempting to add an attachment when the possibility would be
invalid
2020-09-07 14:44:56 -04:00
Shadowfacts fe95cb9e1a
Replace Draw Something context menu item with dedicated button
Fixes add attachment button not working on iOS 13. Adding a context menu
to a Button inside a List on iOS 13 prevents the button from ever
recognizing taps.
2020-09-07 14:41:31 -04:00
Shadowfacts ec2d510be2
Fix crash when opening Compose screen on iOS 13 2020-09-06 23:27:43 -04:00
Shadowfacts 262aadf807
Fix very bad performance when laying out Compose reply view
Using a non-scrolling UITextView wrapped in SwiftUI combined with the
old hack of fixing its layout by passing the view controller's width
down to the wrapped view caused very slow layouts, resulting in
significant lag when typing into the main text view of the compose
screen.
2020-09-06 22:47:02 -04:00
Shadowfacts 9dce94c014
Fix acounts not updating locally
Fix reblogged statuses potentially not updating
2020-09-06 16:03:03 -04:00
Shadowfacts d008b882cb
Use context menu for visibility on iOS 14 2020-08-31 23:07:41 -04:00
Shadowfacts 3d13df87f0
Add pointer interaction to main status favorites/reblogs buttons 2020-08-31 21:40:18 -04:00
Shadowfacts f0582739cc
Re-add Compose button to Profile screen
Add menu with Direct Message option
2020-08-31 21:39:36 -04:00
Shadowfacts 4c82b1a341
Rewrite Compose screen in SwiftUI 2020-08-31 19:28:50 -04:00
Shadowfacts b55a96d649
Fix crash when resizing split view on iPad if Explore search controller
hadn't been created yet
2020-08-30 19:57:32 -04:00
Shadowfacts 77ac8cbe40
Bump deployment target to iOS 13.4 2020-08-30 19:28:11 -04:00
Shadowfacts e026c9a6c6
Bump build number and update changelog 2020-08-17 19:06:56 -04:00
Shadowfacts 3937dde2bf
Fix crash when selecting attachments on iOS 13 2020-08-17 18:52:54 -04:00
Shadowfacts 95ebca04d2
Disable automatic GIF playback in low-power mode 2020-08-16 19:14:32 -04:00
Shadowfacts 0986fa285e
Fix crash due to leaked table view cell 2020-08-16 15:07:59 -04:00
Shadowfacts 1cd3e6adf9
Show custom emoji in profile field names 2020-08-16 15:07:55 -04:00
Shadowfacts 722b81dad9
Group appearance prefs into sections 2020-08-16 14:58:10 -04:00
Shadowfacts 059f7307b3
Let system uppercase section headers 2020-08-16 14:58:02 -04:00
Shadowfacts ee20c95a5d
Prevent link activation when outside character 2020-08-16 14:52:08 -04:00
Shadowfacts be81ffb61f
Allow display names to shrink to fit available width 2020-08-16 14:49:44 -04:00
Shadowfacts 08e0c3769f
Make link preview background opaque 2020-08-16 14:45:01 -04:00
Shadowfacts 6d7c9fd553
Make tap targets on status action buttons larger 2020-08-16 14:41:30 -04:00
Shadowfacts 9b04b75949
Prevent potential race condition when loading additional statuses 2020-08-16 10:29:31 -04:00
Shadowfacts 273b74ddfb
Bump build number and update changelog 2020-08-15 22:10:44 -04:00
Shadowfacts ae055f1ffd
Remove debug code 2020-08-15 18:00:47 -04:00
Shadowfacts eef9b96a1a
Fix crash when showing profile for uncached account 2020-08-15 18:00:18 -04:00
Shadowfacts 29aed65b99
Fix crash if profile header view outlives VC 2020-08-15 17:59:14 -04:00
Shadowfacts 090746f292
Disallow opening universal links from Open in Safari context menu action 2020-08-15 17:48:58 -04:00
Shadowfacts af300a3559
Remove unused TuskerNavigationDelegate customization points 2020-08-15 17:47:33 -04:00
Shadowfacts 79eb23ef5d
Remove unused preference 2020-08-15 17:43:31 -04:00
Shadowfacts 60565f9625
Fix crash if status table view cell outlives VC 2020-08-15 17:37:56 -04:00
Shadowfacts 70bedf17a8
Set app category 2020-08-15 17:36:23 -04:00
Shadowfacts 392e51eb3e
Remove unnecessary prefernces change notification 2020-08-15 17:31:24 -04:00
Shadowfacts 86d5a73c85
Change menu item order
Open in Safari should be the closest to the user's finger when tapping a
menu button
2020-08-15 17:20:09 -04:00
Shadowfacts eaefa366b7
Fix displaying images on iOS 14 2020-08-15 17:03:02 -04:00
Shadowfacts 79b23127e9
Fix crash on refreshing 2020-08-15 14:15:38 -04:00
Shadowfacts f9b85c87b4
Fix crash on launch due to unloaded sidebar VC 2020-08-15 13:55:47 -04:00
Shadowfacts 260bedcf10
Fix retain cycle between status cells and menu actions 2020-07-07 23:23:39 -04:00
Shadowfacts fe09c5e522
Switch asset picker to use diffable data sources 2020-07-06 18:16:18 -04:00
Shadowfacts 985d30a401 Add background to image descriptions so they're visible against light backgrounds
Closes #102
2020-07-06 17:48:19 -04:00
Shadowfacts 794594805c Prevent needlessly prefetching non-image attachments 2020-07-06 00:00:55 -04:00
Shadowfacts 1c708732f2 Exclude iOS 14-specific code from compilation on Xcode 11 to allow building for TestFlight 2020-07-06 00:00:51 -04:00
Shadowfacts db30471011 Fix not being able to refresh timelines 2020-07-05 16:30:16 -04:00
Shadowfacts 2825345c7e Add switching between Posts, Posts and Replies, and Media pages of user profiles
Closes #103
2020-07-05 16:17:56 -04:00
Shadowfacts f3d01c47c3 Merge branch 'develop-xcode-12' into ios-14 2020-07-04 11:21:00 -04:00
Shadowfacts caab5e357a Fix crash loading audio attachment uploaded on Mastodon
Closes #104
2020-07-03 22:13:49 -04:00
Shadowfacts 2916d7a72d Add tapping the active tab bar item to scroll to top
Closes #106
2020-07-03 19:36:52 -04:00
Shadowfacts d190636fbd Fix Preferences button not appearing (again) 2020-07-03 19:36:08 -04:00
Shadowfacts 4e4701ead5 Use SwiftSoup from SPM instead of Git submodule 2020-07-03 19:09:58 -04:00
Shadowfacts b07efc150c Use App Group for user defaults 2020-07-03 18:54:21 -04:00
Shadowfacts 19fa12391d Fix Preferences button not appearing 2020-07-03 18:53:19 -04:00
Shadowfacts c55ea2e005 More link context menu preview tweaks 2020-07-03 18:52:35 -04:00
Shadowfacts 47dc00ab8f Fix sometimes broken masking of text view link preview animations 2020-07-03 18:52:23 -04:00
Shadowfacts fdcdbced38 Limit context menu previews in ContentTextView to link's text line rects 2020-07-03 18:50:37 -04:00
Shadowfacts e70a84274e Fix showing instance public timeline 2020-07-03 18:50:22 -04:00
Shadowfacts 641ab765a7 Fix crash when displaying search results 2020-07-03 18:50:05 -04:00
Shadowfacts 986fc5b833 Prevent crash when displaying accounts with no pinned statuses 2020-07-03 18:49:55 -04:00
Shadowfacts cf5b97d9c8 Fix crash showing custom instance on iOS 14 2020-07-03 18:49:28 -04:00
Shadowfacts 7f0fd119c5 Use App Group for user defaults 2020-07-03 18:45:37 -04:00
Shadowfacts b2c7735256 Fix Preferences button not appearing 2020-07-03 18:44:38 -04:00
Shadowfacts 1d815d6cd6 More link context menu preview tweaks 2020-07-03 17:01:52 -04:00
Shadowfacts f86d3a0ed1 Fix sometimes broken masking of text view link preview animations 2020-07-01 00:01:36 -04:00
Shadowfacts 864fd77ecc Sync active tab and navigation stack between split view/tab bar controllers 2020-06-29 22:21:03 -04:00
Shadowfacts 78da04162f Fix missing file from project.pbxproj 2020-06-29 21:47:11 -04:00
Shadowfacts 40a742139b Fix menu state getting out of sync with bookmarked/muted state 2020-06-27 13:13:04 -04:00
Shadowfacts 8bbc572fa7 Replace more with share button for timeline status swipe actions 2020-06-27 10:47:31 -04:00
Shadowfacts 2a8e970738 Use context menus as primary actions for 'More Actions' buttons on >= iOS 14 2020-06-27 00:22:14 -04:00
Shadowfacts 3abb5972b9 Limit context menu previews in ContentTextView to link's text line rects 2020-06-25 10:42:46 -04:00
Shadowfacts 0c06d91f6b Fix showing instance public timeline 2020-06-24 16:41:01 -04:00
Shadowfacts 6cf6db6a8d Add sidebar on iPadOS 14 2020-06-24 16:40:45 -04:00
Shadowfacts fb11e36467 Fix crash when displaying search results 2020-06-24 15:42:56 -04:00
Shadowfacts 0fa87e9177 Prevent crash when displaying accounts with no pinned statuses 2020-06-23 22:21:50 -04:00
Shadowfacts 5cb84e271a Prefer ephemeral sessions in ASWebAuthneticationSession 2020-06-23 21:35:14 -04:00
Shadowfacts 50f1a9a7de Change ComposeDrawingViewController to use drawingPolicy on iOS 14 2020-06-23 19:33:14 -04:00
Shadowfacts 154fc7cd02 Fix ASWebAuthenticationSession usage in Catalyst 2020-06-23 19:32:30 -04:00
Shadowfacts 01d765fa45 Enable Catalyst 2020-06-23 19:32:04 -04:00
Shadowfacts 04aad1252a Use SwiftSoup from SPM instead of Git submodule 2020-06-23 19:31:32 -04:00
Shadowfacts 43779e42df Fix crash showing custom instance on iOS 14 2020-06-23 19:27:34 -04:00
Shadowfacts a5a2cd147e
Fix attachment blur view missing corner radius 2020-06-22 21:03:08 -04:00
Shadowfacts 0e91fc239d
Fix missing anchor for Compose screen visibility popover 2020-06-22 09:53:20 -04:00
Shadowfacts 0e5aab75df
Bump build number 2020-06-21 19:32:47 -04:00
Shadowfacts c715d11fc2
Add CHANGELOG.md 2020-06-21 19:32:08 -04:00
Shadowfacts 8010e86711
Change attachment views to be 16:9 2020-06-21 16:01:34 -04:00
Shadowfacts a41d27f18c
Move status action buttons back below attachments 2020-06-21 16:01:34 -04:00
Shadowfacts 083add273b
Prevent audio from other apps pausing when showing gifv attachments
Fixes #101
2020-06-21 16:01:29 -04:00
Shadowfacts 64365bdf2b
Fix compose attachments being cut off at the bottom of the safe area 2020-06-21 10:31:40 -04:00
Shadowfacts 6adcad63b3
Add crash report helper 2020-06-20 23:11:35 -04:00
Shadowfacts 393a134648
Don't show Follow activity for user's own account 2020-06-19 23:00:59 -04:00
Shadowfacts ba3e9e7491
Fix compose attachment description text view not expanding to fit text 2020-06-19 19:46:08 -04:00
Shadowfacts 920f926b48
Add text recognition image description for image attachments 2020-06-19 19:14:24 -04:00
Shadowfacts 6e27399e10
Fix loading additional statuses on profiles not working
This was a regression introduced in
d27bddb2ca which removed the didSet
handlers which called reloadData on the pinnedStatuses/timelineSegments
property without adding the appropriate insertRows calls where they were
modified.
2020-06-18 22:39:04 -04:00
Shadowfacts c3c19b1994
Fix pin image still showing on statuses after cell reuse 2020-06-18 22:23:19 -04:00
Shadowfacts 1f40cc9928
Show controls/description for gifv attachments
See #98
2020-06-17 23:33:48 -04:00
Shadowfacts 66020b7847
Add preference for always showing status visiblity icon 2020-06-17 18:00:13 -04:00
Shadowfacts 00bf99334f
Add preference for status reply icons 2020-06-17 17:45:34 -04:00
Shadowfacts 3aef7d4d93
Organize Preferences.swift 2020-06-17 17:40:36 -04:00
Shadowfacts a901af6be9
Merge branch 'private-beta' into develop 2020-06-16 23:19:33 -04:00
Shadowfacts b623e348c2 Fix crash when opening compose screen before initial network requests completed 2020-06-16 23:13:46 -04:00
Shadowfacts 056346cee9
Add reply indicator to statuses in timelines 2020-06-16 23:06:36 -04:00
Shadowfacts 30c04b49e7
Add visibility indicator to statuses 2020-06-16 23:00:39 -04:00
Shadowfacts 848022ec6e
Disable reblog button for private posts 2020-06-16 22:47:30 -04:00
Shadowfacts 39e847bda8
Fix reblog label showing incorrect account 2020-06-16 22:47:04 -04:00
Shadowfacts 5d751cd994
Prevent redundant status database lookups 2020-06-15 23:22:45 -04:00
Shadowfacts d27bddb2ca Fix profile header image not showing up on first load
The issue occurred because the profile header would kick off a request
upon loading, then the profile table would request the initial set of
statuses shortly thereafter which would result in reloadData being called
which would cancel the request without removing the group, so the request
generated by the newly-reloaded header cell would attach a callback to
the cancelled request, resulting in the header image never displaying.
2020-06-15 22:34:42 -04:00
Shadowfacts 36326e4469
Make network requests in viewWillAppear instead of viewDidLoad 2020-06-15 19:41:51 -04:00
Shadowfacts 6b7904ed52
Improve profile field layout 2020-06-15 19:02:09 -04:00
Shadowfacts 61c6d63c67
Fix profile fields not displaying
Closes #96
2020-06-15 18:36:04 -04:00
Shadowfacts c0316f55ef
Fix crash when sharing large image on iPad 2020-06-15 18:29:04 -04:00
Shadowfacts 803ba50f53
Add pointer interaction to remove attachment, large image share/dismiss buttons 2020-06-15 18:26:56 -04:00
Shadowfacts 5d0c59e863
Prompt for Photos access before showing asset picker 2020-06-15 18:15:05 -04:00
Shadowfacts c7b4d00da7
Fix race condition loading bookmarks 2020-06-15 18:02:07 -04:00
Shadowfacts f2a8b91769
Provide metadata to UIActivityViewController
Closes #56
2020-05-14 22:43:56 -04:00
Shadowfacts ce464dfb9f
Add mute/unmute conversation status activities
Closes #70
2020-05-14 22:43:47 -04:00
Shadowfacts d4bf289716
Fx more actions not workign 2020-05-14 22:43:37 -04:00
Shadowfacts cf48e4e973
Bump build number 2020-05-13 21:21:57 -04:00
Shadowfacts 2eaeaf3277
Fix previewing gifv attacments 2020-05-13 21:20:22 -04:00
Shadowfacts d396eb0823
Change background CoreData context to be a child of the main context so
that updates on the background context propogate up to the view context
on save
2020-05-13 19:49:35 -04:00
Shadowfacts 35a510e8ed
Add cache reset button to Advanced Preferences 2020-05-13 18:58:11 -04:00
Shadowfacts 0582812563
Remove strong references to MastodonController 2020-05-13 18:57:04 -04:00
Shadowfacts e581f384e4
Fix account descriptions being squashed in the follows list 2020-05-12 22:24:51 -04:00
Shadowfacts c42a48ee12
Fix header images not displaying 2020-05-12 22:05:57 -04:00
Shadowfacts 1c9b1b9ac3
Add support (sort of) for gifv attachments
See #98
2020-05-12 21:46:08 -04:00
Shadowfacts 82ad3b9fc4
Add reference counting for accounts
Closes #97
2020-05-11 22:03:17 -04:00
Shadowfacts 0a89dd3041
Don't double update accounts
Adding a status to the cache will also cache the status' account
2020-05-11 18:27:54 -04:00
Shadowfacts 40863ef130
Fix crash when opening more options for status in instance public timeline 2020-05-11 17:58:43 -04:00
Shadowfacts cd78287a87
Fix crash when viewing instance public timelines
Use a CoreData in-memory store for public timelines.
2020-05-11 17:57:50 -04:00
Shadowfacts 04496aca1d
Apply avatar style to local account avatar images 2020-05-10 19:30:19 -04:00
Shadowfacts 5a098df931
Fix crash when searching 2020-05-10 15:47:50 -04:00
Shadowfacts 9812d4aff2
Prevent double-decrementing reference count for conversation main status 2020-05-10 15:08:45 -04:00
Shadowfacts f4f2a5546c
Prevent race in status action account list 2020-05-10 15:04:22 -04:00
Shadowfacts b220948e2b
Only initialize NSManagedObjectModel once
Prevents CoreData warnings when switching accounts and constructing a
second MastodonCachePersistentStore
2020-05-10 14:54:43 -04:00
Shadowfacts 866edc472d
Show avatar and instance domain in account list in Preferences 2020-05-10 14:54:20 -04:00
Shadowfacts 88e4f52b5d
Fix crash when adding account
Adding a UserData.LocalAccountInfo with a nil username while the
PreferencesView is on screen will cause a crash, since it triggers a
Combine publish upon which the PreferencesView expects to be able to
display the username of all accounts.
2020-05-10 14:41:07 -04:00
Shadowfacts 98529ca5af
Remove notifications from the bottom when scrolling up notifications list 2020-05-10 12:56:03 -04:00
Shadowfacts 6d8c5f632c
Fix scroll-to-top sometimes not scrolling all the way to the top 2020-05-10 12:56:01 -04:00
Shadowfacts 4fdafa893e
Add drawing attachments using PencilKit 2020-05-09 22:14:48 -04:00
Shadowfacts 9f75106706
Fix crash when opening statuses in Safari 2020-05-09 13:31:07 -04:00
Shadowfacts bbd7d82620
Fix test in ContentTextView not being de-selectable 2020-05-07 21:46:59 -04:00
Shadowfacts 02088b1f55
Remove MastodonCache 🎉 2020-05-06 23:29:57 -04:00
Shadowfacts 1e41c8fa17
Remove MastodonCache usgae from XCBActions 2020-05-06 23:05:15 -04:00
Shadowfacts ebbfc7a132
Fix race condition on loading notifications 2020-05-06 19:32:32 -04:00
Shadowfacts aa625a41f5
Merge branch 'develop' into coredata 2020-05-06 19:18:58 -04:00
Shadowfacts 7fb92c9ce3
Prevent avatars in action notification group cell from overflowing 2020-05-06 19:18:47 -04:00
Shadowfacts 90bc9b91de
Add AccountProtocol and StatusProtocol
Provides a single interfaces for API and CoreData statuses and accounts
2020-05-06 18:40:12 -04:00
Shadowfacts d6c506488b
Replace a bunch of MastodonCache uses with CoreData 2020-05-02 19:52:35 -04:00
Shadowfacts 5786c24846
Fix statuses/accounts updating 2020-05-02 12:45:28 -04:00
Shadowfacts 2cba168804
Fix account cells using old cache 2020-04-27 19:33:36 -04:00
Shadowfacts 49d00bb1b0
Fix swipe actions not showing up 2020-04-27 19:32:16 -04:00
Shadowfacts ee5e049355
Use CoreData for bookmarks and search results 2020-04-27 19:25:41 -04:00
Shadowfacts f53474ac90
Use CoreData for notifications screen 2020-04-27 19:20:09 -04:00
Shadowfacts fa1daa682f
Convert profile VC to use CoreData objects
Does not yet remove old statuses when scrolling up, like timeline VC
2020-04-13 22:51:21 -04:00
Shadowfacts 030bee1948
Convert conversation VC to use CoreData models 2020-04-13 22:51:15 -04:00
Shadowfacts ed37b16463
Start adding CoreData-based "reference" counting for statuses
Prune old statuses that aren't likely to be shown again when scrolling
in timeline table view
2020-04-12 23:08:33 -04:00
Shadowfacts 2c8ba878b7
Start converting UI to use CoreData backed objects instead of API
objects directly
2020-04-12 12:54:27 -04:00
Shadowfacts a0e95d4577
Remove unnecessary attachment decoding code
For some reason, creating a URL from a string decoded from the container
was producing URL objects that could not be round-tripped through
PropertyListEncoder/Decoder. Decoding a URL directly from the container
works correctly.
2020-04-12 12:52:51 -04:00
Shadowfacts 465aedd43f
Make account info username optional
Onboarding view controller needs to set the account info object on the
mastodon controller before calling getOwnAccount since getOwnAccount
will upsert the user's account into the persistent container, which
requires the account info to exist to create a unique-per-account
identifier.
2020-04-12 11:14:10 -04:00
Shadowfacts 102fe6ed91
Convert API objects to CoreData models and save them 2020-04-11 22:23:31 -04:00
Shadowfacts 7deb4fc5b4
Add LazilyDecoding for CoreData embedded objects 2020-04-11 15:35:00 -04:00
Shadowfacts 2a419eb87c
Add basic Status/Account CoreData model 2020-04-11 15:32:25 -04:00
Shadowfacts fcab6818b0
Hide large image source view during expand/shrink animation 2020-03-25 23:10:48 -04:00
Shadowfacts 80cf1850dd
Add trackpad/magic mouse support for navigation controller interactive push gesture 2020-03-25 22:29:32 -04:00
Shadowfacts e612964464
Allow scrolling w/ trackpad/magic mouse to dismiss gallery 2020-03-25 22:12:26 -04:00
Shadowfacts 49a437583e
Fix incorrect large image size during expand/shrink animation in some
cases
2020-03-25 22:09:00 -04:00
Shadowfacts 8a513186aa
Add pointer interactions status buttons and profile header more button 2020-03-24 23:02:40 -04:00
Shadowfacts d9517047d7
Fix previewing video/audio attachments 2020-03-20 22:48:28 -04:00
Shadowfacts bef3388fe8
Move attachment view corner radius to individual views
Masking the container makes context menu interactions look weird
2020-03-20 22:34:50 -04:00
Shadowfacts 2e8241d734
Move attachment context menu interaction to AttachmentView 2020-03-20 22:28:23 -04:00
Shadowfacts c9c001d403
Improve attachment previewing
- Set correct preview size
- Don't show controls
2020-03-20 22:13:04 -04:00
Shadowfacts 4ce8de280e
Bump build number 2020-03-17 21:58:14 -04:00
Shadowfacts 4018d39312
Fix double gestures in attachments gallery 2020-03-17 21:56:29 -04:00
Shadowfacts ae416bb604
Prevent crash if BaseStatusTableViewCell is leaked
If prefernces change and the the view controller the cell belongs to is dealloced, the
mastodonController will be nil, previously causing a crash.
2020-03-17 21:44:06 -04:00
Shadowfacts 5e9caf9179
Use LoadingLargeImageViewController for account avatar/header
Prevents crash when tapping unloaded avatar/header images
2020-03-17 21:42:09 -04:00
Shadowfacts 3bbbb05083
Rename AttachmentsViewController to LoadingLargeImageViewController and
make non-specific to attachments
2020-03-17 21:24:15 -04:00
Shadowfacts bd3e74c611
Remove unnecessary XIB 2020-03-17 21:07:44 -04:00
Shadowfacts 2e8c416e04
Merge gallery and large image animations 2020-03-17 21:05:45 -04:00
Shadowfacts 955f9e5916
Fix attachment descriptions not being set correctly 2020-03-17 21:03:29 -04:00
Shadowfacts 17f15db32d
Don't round bottom corners of asset picker
Corner radius doesn't match that used on 2019 iPad Pro, so rounding the
bottom corners results in the view controller beneath the asset picker
showing through in some split-screen configurations
2020-03-16 20:50:16 -04:00
Shadowfacts 1a11dd2a69
Present asset picker as popover in regular horizontal size class 2020-03-16 20:45:51 -04:00
Shadowfacts b5fa0bceab
Fix pasting using compose app shortcut while app isn't running 2020-03-16 19:09:25 -04:00
Shadowfacts c224d11417
Allow pasting and drag/dropping video attachments on compose screen 2020-03-16 19:05:58 -04:00
Shadowfacts bebf47f05c
Prevent incompatible items from being pasted on compose screen 2020-03-16 17:31:43 -04:00
Shadowfacts e76b719c6a
Add context menu previews to explore VC 2020-03-15 23:54:04 -04:00
Shadowfacts 478c7b7a23
Fix crash when long-presing add attachment button 2020-03-15 22:59:43 -04:00
Shadowfacts e3cc0df283
Remove unnecessary URL escaping 2020-03-15 21:09:11 -04:00
Shadowfacts 9ed05de3ee
Add compose attachments preview 2020-03-15 14:25:02 -04:00
Shadowfacts 64f41ea2b7
Fix crash when updating timeline status cell timestamp 2020-03-15 12:17:19 -04:00
Shadowfacts 9af4118dfc
Show truncated note in account cell 2020-03-15 11:56:41 -04:00
Shadowfacts 64a8f6d733
Reorganize code 2020-03-15 11:43:41 -04:00
Shadowfacts ca76568c79
Remove old code 2020-03-15 11:40:28 -04:00
Shadowfacts 18e91feb00
Fix requires attachment descriptions preference not working 2020-03-15 11:39:35 -04:00
Shadowfacts c5d2e9af68
Fix preferences/drafts not saving on iPad in some circumstances 2020-03-15 11:26:30 -04:00
Shadowfacts 0691c3b9d6
Fix asset preview size 2020-03-14 23:32:54 -04:00
Shadowfacts 1ccb450477
Support dragging and dropping attachments in the compose view controller
Allos dragging in attachments from other apps and drag/dropping with the
compose VC to reorder attachments
2020-03-14 20:08:36 -04:00
Shadowfacts 7117ce6320
Support pasting images to create attachments
Closes #91
2020-03-14 16:46:50 -04:00
Shadowfacts 34dccf1f37
Extract compose attachments into separate VC 2020-03-14 15:47:15 -04:00
Shadowfacts a3303dc8fb
Use same order for status and account preview actions 2020-03-11 22:54:38 -04:00
Shadowfacts d15fa2199e
Fix attachments container more view not beign removed on cell reuse
Closes #92
2020-03-11 22:49:53 -04:00
Shadowfacts fadddeda7f
Fix crash when deleting draft
Closes #94
2020-03-11 22:18:31 -04:00
Shadowfacts b232bec80f
Show custom emojis in content warnings
Closes #95
2020-03-11 21:56:35 -04:00
Shadowfacts 1b19a13b05
Decode status cards 2020-03-04 21:14:58 -05:00
Shadowfacts cd5b4c1145
Remove old code 2020-03-02 22:31:37 -05:00
Shadowfacts b61418e062
Bump build 2020-03-02 19:45:14 -05:00
Shadowfacts c7746d3084
Add unknown notification fallback
Closes #90
2020-03-02 19:44:10 -05:00
Shadowfacts 315ea39682
Fix crash in silent action prefs 2020-03-02 19:44:10 -05:00
Shadowfacts 44fbbd6a80
Revert "Fix custom emojis in display namesnot showing in conversation main"
This reverts commit 73da828e7cec09bcfbe65295bbd2f02e3b719ff6.

Fixes #89
2020-03-02 19:44:10 -05:00
Shadowfacts fa4b5d3542
Fix custom emojis not being shown in display names when scrolling
quickly

If the emojiIdentifier didn't change, the emojis wouldn't be re-added
even after the text had been reset.
2020-03-02 19:44:10 -05:00
Shadowfacts de02c73957
Fix custom emojis in display namesnot showing in conversation main
statuses

Caused by the cell updating it's UI multiple times in quick succession.
As a workaround, prevent the main cell from being reloaded.
2020-03-02 19:44:10 -05:00
Shadowfacts 2cebb6bd7d
Show custom emojis in display names (where possible) 2020-03-02 19:44:09 -05:00
Shadowfacts 53707593a6
Show custom emojis in display names (where possible) 2020-03-01 19:40:32 -05:00
Shadowfacts 244659c262
Fix intermittent crash
If a status in a conversation view controller creates a work item to
update the timestamp in 1 minute, but the view controller is deinit'd
before that time elapses, the mastodonController instance will be nil,
resulting in a crash.

The DispatchWorkItems's are cancelled by the respective cell deinit
methods. But if the work item has already begun, cancelling it has no
effect, potentially leading to a crash in the conditions described above
are true. Using a weak reference to self fixes this.

Additionally, don't unnecessarily recreate the work items every time.
They don't capture any local variables, only self, so nothing changes.
2020-03-01 18:33:44 -05:00
Shadowfacts d4ca39761e
Change version, disable UI test web server temporarily 2020-03-01 18:23:10 -05:00
Shadowfacts f87944b47e
Add app icon 2020-03-01 13:11:09 -05:00
Shadowfacts af821081b0
Temporary fix for crash that occurs when switching accounts immediately
after adding a new one
2020-02-29 17:36:54 -05:00
Shadowfacts 804636dcbb
Don't show warning when loading draft on top of for empty statuses
Closes #87
2020-02-28 19:50:04 -05:00
Shadowfacts 5bed38f661
Show gallery instead of large image when previewing status attachments
Fixes crash when attempting to preview audio/video attachments
2020-02-28 19:47:38 -05:00
Shadowfacts 56de0ab359
Update profile header to always reflect most recently cached data 2020-02-28 19:47:31 -05:00
Shadowfacts 387623a309
Remove old code 2020-02-28 19:24:14 -05:00
Shadowfacts 70bca052c4
Tweak notification grouping
Notifications that are of the same type but are separated by a groupable
notification of a different type are now considered groupable. For
example:

favorite 1 (status 1)
reblog 1 (status 1)
favorite 2 (status 1)
reblog 2 (status 1)
mention 1
reblog 3 (status 1)

will be grouped into:

favorite 1, 2 (status 1)
reblog 1, 2 (status 1)
mention 1
reblog 3 (status 1)
2020-02-28 19:21:39 -05:00
Shadowfacts d9bae42f81
Prevent empty drafts from being saved 2020-02-22 15:43:17 -05:00
Shadowfacts a814ee37cc
Update SheetController
Fixes image picker losing velocity during dismiss animation
2020-02-22 15:29:42 -05:00
Shadowfacts 1a8e84f5fa
Reorganize behavior preferences 2020-02-22 13:19:31 -05:00
Shadowfacts 1f56823a17
Add preference to disable gif animation in timelines 2020-02-22 13:12:28 -05:00
Shadowfacts 65d57df949
Add interacting pushing to navigation controllers
Allows people to move forward in the navigation stack after popping
(making popping a non-destructive action).
2020-02-19 22:07:12 -05:00
222 changed files with 10650 additions and 4566 deletions

3
.gitmodules vendored
View File

@ -1,6 +1,3 @@
[submodule "SwiftSoup"]
path = SwiftSoup
url = git://github.com/scinfu/SwiftSoup.git
[submodule "Cache"]
path = Cache
url = git@github.com:hyperoslo/Cache.git

138
CHANGELOG.md Normal file
View File

@ -0,0 +1,138 @@
# Changelog
## 2020.1 (10)
This build is a hotfix for a couple pressing issues. The changelog for the previous build is included below.
Bugfixes:
- Fix crash when opening Preferences while signed in with a deleted account
- Fix visibility and content warning not being copied when replying to a post
## 2020.1 (9)
The marquee feature of this build is the new and improved Compose screen. It's been rewritten to use SwiftUI, is significantly more resilient to data loss, and now shows the toolbar when the main text field is not focused. It also turns out Apple is surprise-releasing iOS 14 very soon (or possibly already has, depending when you're reading this). For those who were not already on the beta train, iOS 14 brings a number of new features including a sidebar on iPadOS and lots and lots of context menus (a home screen widget is coming Soon™).
Known Issues:
- Pasting images to create attachments when composing a post is not currently supported due to an iOS bug (#109)
- Full-size previews do not display in context menus for attachments on the Compose screen due to an iOS issue (#110)
Features/Improvements:
- Rewrite Compose screen using SwiftUI
- Prevent draft posts being lost if the app crahes or is killed by the system while composing
- Show toolbar while post content is not being edited
- Save post visibility in drafts
- Move Draw Something action out of the context menu
- iOS 14: Use context menus for setting post visibility
- Show BlurHash previews for attachments on Mastodon
- Add Expand All Content Warnings preference (Preferences -> Behavior)
- Add Collapse Long Posts preference (Preferences -> Behavior)
- Improve image gallery opening animation
- Use fade in/out animations for opening/closing gallery and attachment picker when the Reduce Motion system setting is enabled
- iOS 14: Also requires the "Prefer Cross Fade" setting be enabled
- Slightly reduce default status font sizes
- Add "Direct Message" context menu action to Compose button on profile screen
- Allow viewing attachments and navigating through posts/accounts on instance public timelines
Bugfixes:
- Fix errors when uploading attachments not displaying
- Fix attachments not posting in the correct, user-specified order
- Fix accounts displaying with outdated information (avatars, display names, etc.)
- Fix Compose not showing button on profile screen
- Fix navigation title not being set on profile screen
- Fix follow notifications not showing names for users without display names set
- iPadOS 14: Fix crash when resizing app in split view mode
## 2020.1 (8)
This is just an emergency build to fix crashes on iOS 13 when selecting attachments. The changelog of the previous build is included below.
Features/Improvements:
- Enlarge tap targets on status reply/favorite/reblog/more buttons
- Disable automatic GIF playback when Low Power Mode is enabled
- Show custom emoji in user profile field names
Bugfixes:
- Fix crash when attempting to add attachments on iOS 13
- Fix potential crashes
## 2020.1 (7)
This is the first update since WWDC and the introduction of iOS 14. As such, most of the focus has been on fixing iOS 14-specific problems. However, there are still a couple new features, both for those on the iOS 14 beta and those not.
Features/Improvements:
- Add toggle between Posts, Posts and Replies, and Media on user profiles
- Remove 'Show Replies in Profiles' preference
- Limit link preview animation to only link text
- Add additional context menu actions for statuses, accounts, and hashtags
- Add semi-translucent background to image descriptions, so they're legible against light images
- iPadOS 14: Add sidebar
- When using multitasking on iPad and switching in and out of "compact" mode, the active tab as well as the navigation history for all tabs will be transferred between the sidebar and tab bar modes.
- iOS 14: Use context menus on status/account '...' buttons
- iOS 14: Replace 'More' status swipe action with 'Share'
Bugfixes:
- Fix crash when attempting to change post visibility on iPad
- Fix attachment view corners not being rounded
- Fix crash when viewing instance public timelines
- Fix Preferences button not appearing on My Profile tab
- Fix tapping current tab bar item not scrolling to top
- Fix crash showing audio attachments on Mastodon
- Fix timeline refreshing forever
- Set app category (fixes usage not being categorized correctly under Screen Time)
- iOS 14: Fix crash when searching for instances
- iOS 14: Fix crash when displaying accounts with no pinned posts
- iOS 14: Fix crash when displaying search results
## 2020.1 (6)
This is the pre-WWDC update with lots of bugfixes and some small features. There will likely be another build this week to fix any pressing issues that arise from iOS 14.
Features/Improvements:
- Add mute/unmute conversation status action
- iPadOS: Add pointer interactions to remove attachment button, gallery view share/dismiss buttons
- Disable reblog button for direct/followers-only posts
- On Pleroma, the reblog button is still enabled for your own followers-only posts to match Pleroma's "Boost to original audience" feature.
- Add preference to always display status visibilities below account avatars
- Add preference to show reply indicators for statuses in timelines
- Show share/dismiss controls and image description for gifv attachments
- 'Share' is currently disabled for gifv attachments, it will be enabled in a future build
- Add crash report helper
- If the app detects that it crashed the last time it was running, it will allow you to review the crash report and email it to me
- Add Recognize Text context menu option for images on the Compose screen
- This uses iOS' builtin Vision framework to perform on-device OCR and generate an image description from the recognized text
- Tweak attachment previews to always have a 16:9 aspect ratio
Bugfixes:
- Fix account/status More actions not working
- Improve share sheet loading speed
- Fix crash when loading bookmarks
- Prompt for Photos access before showing photo picker. Prevents empty sheet displaying.
- Fix profile fields not displaying and improve layout
- Fix profile header image not displaying the first time an account is loaded
- Don't show Follow action for your own account
- Fix attachments on the Compose screen being cut-off above the home indicator on iPhone X-style devices
- Fix audio being played by other apps pausing when displaying a gifv attachment on Mastodon
## 2020.1 (5)
The main focus of this update has been switching to using CoreData internally to cache/synchronize the most up-to-date versions of all statuses. Currently, this does not provide any new functionality, however, it lays the groundwork for several significant features coming in the future, including multiple window support on iPadOS and state restoration/persistence between launches.
Even though there aren't a huge number of new features in this build, a great deal has changed under the hood. As such, this build may suffer somewhat in the stability department. Please bear with me and report any issues you encounter; you can send me a message on the fediverse, email me at me@shadowfacts.net, or file an issue on the project issue tracker at https://git.shadowfacts.net/shadowfacts/Tusker/issues. Thank you!
Features:
- iPadOS: Add pointer interactions to status action buttons and profile header button
- iPadOS: Allow scrolling w/ trackpad/magic mouse to dismiss attachment gallery
- iPadOS: Enable interactive push gesture with trackpad/magic mouse
- Add drawing attachments using PencilKit
- Long-press to open context menu on the 'Add Attachment' button on the Compose screen, select 'Draw Something'
- Supports Apple Pencil on iPad, including tilt and pressure sensitivity
- Add avatar and instance domain in accounts switcher in Preferences
- Show gifv attachments on Mastodon
- Currently doesn't show attachment description or share/close buttons
- Add 'Clear Cache' option to Preferences -> Advanced for debugging
Bugfixes:
- Fix size of attachment previews in context menu
- Fix previewing audio/video attachments
- Fix incorrect image size during attachment expand/shrink animation
- Prevent avatars in grouped action notification from overflowing the cell and hiding the timestamp
- Fix text in conversation main statuses not being de-selectable
- Fix scroll-to-top sometimes not scrolling all the way to the top
- Fix account profile descriptions being squashed in the follow notification account list

2
Gifu

@ -1 +1 @@
Subproject commit ed572f53ce58b8e23499abeb3a926033cbe480f7
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007

View File

@ -7,7 +7,6 @@
//
import Foundation
import Combine
/**
The base Mastodon API client.
@ -27,7 +26,7 @@ public class Client {
public var timeoutInterval: TimeInterval = 60
lazy var decoder: JSONDecoder = {
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
@ -37,6 +36,16 @@ public class Client {
return decoder
}()
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
encoder.dateEncodingStrategy = .formatted(formatter)
return encoder
}()
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
self.baseURL = baseURL
self.accessToken = accessToken
@ -51,29 +60,24 @@ public class Client {
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
completion(.failure(.networkError(error)))
return
}
guard let data = data,
let response = response as? HTTPURLResponse else {
completion(.failure(Error.invalidResponse))
completion(.failure(.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
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(error))
return
}
guard let result = try? self.decoder.decode(Result.self, from: data) else {
completion(.failure(Error.invalidModel))
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
completion(.failure(.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))
@ -81,43 +85,6 @@ public class Client {
task.resume()
}
public func run<Result>(_ request: Request<Result>) -> Promise<(Result, Pagination?)> {
return Promise { (resolve, reject) in
self.run(request) { (response) in
switch response {
case let .success(result, pagination):
resolve((result, pagination))
case let .failure(error):
reject(error)
}
}
}
}
public func run<Result: Decodable>(_ request: Request<Result>) -> AnyPublisher<(Result, Pagination?), Swift.Error> {
guard let request = createURLRequest(request: request) else {
return Fail(error: Error.invalidRequest).eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: request)
.mapError { Error.urlError($0) }
.tryMap {
guard let response = $0.response as? HTTPURLResponse else {
throw Error.invalidResponse
}
guard response.statusCode == 200 else {
if let mastodonError = try? self.decoder.decode(MastodonError.self, from: $0.data) {
throw Error.mastodonError(mastodonError.description)
} else {
throw Error.unknownError
}
}
let result = try self.decoder.decode(Result.self, from: $0.data)
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
return (result, pagination)
}
.eraseToAnyPublisher()
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.path
@ -135,7 +102,7 @@ public class Client {
// 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([
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
"client_name" => name,
"redirect_uris" => redirectURI,
"scopes" => scopes.scopeString,
@ -152,7 +119,7 @@ public class Client {
}
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
"client_id" => clientID,
"client_secret" => clientSecret,
"grant_type" => "authorization_code",
@ -211,13 +178,13 @@ public class Client {
}
public static func block(domain: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
"domain" => domain
]))
}
public static func unblock(domain: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
"domain" => domain
]))
}
@ -228,7 +195,7 @@ public class Client {
}
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
"phrase" => phrase,
"irreversible" => irreversible,
"whole_word" => wholeWord,
@ -252,7 +219,7 @@ public class Client {
}
public static func followRemote(acct: String) -> Request<Account> {
return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
}
// MARK: - Lists
@ -265,12 +232,12 @@ public class Client {
}
public static func createList(title: String) -> Request<List> {
return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
}
// MARK: - Media
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
"description" => description,
"focus" => focus
], attachment))
@ -302,7 +269,7 @@ public class Client {
}
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
"account_id" => account.id,
"comment" => comment
] + "status_ids" => statuses.map { $0.id }))
@ -330,7 +297,7 @@ public class Client {
spoilerText: String? = nil,
visibility: Status.Visibility? = nil,
language: String? = nil) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
"status" => text,
"content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo,
@ -357,12 +324,32 @@ public class Client {
}
extension Client {
public enum Error: Swift.Error {
case unknownError
public enum Error: LocalizedError {
case networkError(Swift.Error)
case unexpectedStatus(Int)
case invalidRequest
case invalidResponse
case invalidModel
case mastodonError(String)
case urlError(URLError)
public var localizedDescription: String {
switch self {
case .networkError(let error):
return "Network Error: \(error.localizedDescription)"
// todo: support more status codes
case .unexpectedStatus(413):
return "HTTP 413: Payload Too Large"
case .unexpectedStatus(let code):
return "HTTP Code \(code)"
case .invalidRequest:
return "Invalid Request"
case .invalidResponse:
return "Invalid Response"
case .invalidModel:
return "Invalid Model"
case .mastodonError(let error):
return "Server Error: \(error)"
}
}
}
}

View File

@ -1,39 +0,0 @@
//
// 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
}
}
}
}

View File

@ -8,7 +8,7 @@
import Foundation
public class Account: Decodable {
public final class Account: AccountProtocol, Decodable {
public let id: String
public let username: String
public let acct: String
@ -27,7 +27,7 @@ public class Account: Decodable {
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?
public required init(from decoder: Decoder) throws {
@ -47,9 +47,9 @@ public class Account: Decodable {
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: .url)
self.headerStatic = try container.decode(URL.self, forKey: .headerStatic)
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
self.fields = try? container.decode([Field].self, forKey: .fields)
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) {
@ -115,7 +115,7 @@ public class Account: Decodable {
}
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([
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([
"notifications" => notifications
]))
}

View File

@ -8,18 +8,19 @@
import Foundation
public class Attachment: Decodable {
public class Attachment: Codable {
public let id: String
public let kind: Kind
public let url: URL
public let remoteURL: URL?
public let previewURL: URL
public let 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: .formData([
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: FormDataBody([
"description" => (description ?? attachment.description),
"focus" => focus
], nil))
@ -29,20 +30,13 @@ public class Attachment: Decodable {
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 = 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)
self.url = try container.decode(URL.self, forKey: .url)
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL)
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL)
self.textURL = try? container.decode(URL?.self, forKey: .textURL)
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
self.description = try? container.decode(String?.self, forKey: .description)
self.blurHash = try? container.decode(String?.self, forKey: .blurHash)
}
private enum CodingKeys: String, CodingKey {
@ -54,11 +48,12 @@ public class Attachment: Decodable {
case textURL = "text_url"
case meta
case description
case blurHash = "blurhash"
}
}
extension Attachment {
public enum Kind: String, Decodable {
public enum Kind: String, Codable {
case image
case video
case gifv
@ -68,7 +63,7 @@ extension Attachment {
}
extension Attachment {
public class Metadata: Decodable {
public struct Metadata: Codable {
public let length: String?
public let duration: Float?
public let audioEncoding: String?
@ -99,7 +94,7 @@ extension Attachment {
}
}
public class ImageMetadata: Decodable {
public struct ImageMetadata: Codable {
public let width: Int?
public let height: Int?
public let size: String?
@ -113,14 +108,3 @@ 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

@ -22,6 +22,23 @@ public class Card: Decodable {
public let width: Int?
public let height: Int?
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.url = try container.decode(URL.self, forKey: .url)
self.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description)
self.kind = try container.decode(Kind.self, forKey: .kind)
self.image = try? container.decode(URL.self, forKey: .image)
self.authorName = try? container.decode(String.self, forKey: .authorName)
self.authorURL = try? container.decode(URL.self, forKey: .authorURL)
self.providerName = try? container.decode(String.self, forKey: .providerName)
self.providerURL = try? container.decode(URL.self, forKey: .providerURL)
self.html = try? container.decode(String.self, forKey: .html)
self.width = try? container.decode(Int.self, forKey: .width)
self.height = try? container.decode(Int.self, forKey: .height)
}
private enum CodingKeys: String, CodingKey {
case url
case title

View File

@ -8,7 +8,7 @@
import Foundation
public class Emoji: Decodable {
public class Emoji: Codable {
public let shortcode: String
public let url: URL
public let staticURL: URL

View File

@ -23,7 +23,7 @@ public class Filter: Decodable {
}
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
"phrase" => (phrase ?? filter.phrase),
"irreversible" => (irreversible ?? filter.irreversible),
"whole_word" => (wholeWord ?? filter.wholeWord),

View File

@ -8,7 +8,7 @@
import Foundation
public class List: Decodable {
public class List: Decodable, Equatable, Hashable {
public let id: String
public let title: String
@ -16,6 +16,14 @@ public class List: Decodable {
return .list(id: id)
}
public static func ==(lhs: List, rhs: List) -> Bool {
return lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
request.range = range
@ -23,7 +31,7 @@ public class List: Decodable {
}
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]))
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
}
public static func delete(_ list: List) -> Request<Empty> {
@ -31,13 +39,13 @@ public class List: Decodable {
}
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
"account_ids" => accountIDs
))
}
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
"account_ids" => accountIDs
))
}

View File

@ -8,7 +8,7 @@
import Foundation
public class Mention: Decodable {
public class Mention: Codable {
public let url: URL
public let username: String
public let acct: String

View File

@ -15,8 +15,26 @@ public class Notification: Decodable {
public let account: Account
public let status: Status?
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
if let kind = try? container.decode(Kind.self, forKey: .kind) {
self.kind = kind
} else {
self.kind = .unknown
}
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.account = try container.decode(Account.self, forKey: .account)
if container.contains(.status) {
self.status = try container.decode(Status.self, forKey: .status)
} else {
self.status = nil
}
}
public static func dismiss(id notificationID: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
"id" => notificationID
]))
}
@ -37,6 +55,7 @@ extension Notification {
case favourite
case follow
case followRequest = "follow_request"
case unknown
}
}

View File

@ -0,0 +1,33 @@
//
// AccountProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
public protocol AccountProtocol {
associatedtype Account: AccountProtocol
var id: String { get }
var username: String { get }
var acct: String { get }
var displayName: String { get }
var locked: Bool { get }
var createdAt: Date { get }
var followersCount: Int { get }
var followingCount: Int { get }
var statusesCount: Int { get }
var note: String { get }
var url: URL { get }
var avatar: URL { get }
var header: URL { get }
var moved: Bool? { get }
var bot: Bool? { get }
var movedTo: Account? { get }
var emojis: [Emoji] { get }
var fields: [Pachyderm.Account.Field] { get }
}

View File

@ -0,0 +1,38 @@
//
// StatusProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
public protocol StatusProtocol {
associatedtype Status: StatusProtocol
associatedtype Account: AccountProtocol
var id: String { get }
var uri: String { get }
var inReplyToID: String? { get }
var inReplyToAccountID: String? { get }
var content: String { get }
var createdAt: Date { get }
var reblogsCount: Int { get }
var favouritesCount: Int { get }
var reblogged: Bool { get }
var favourited: Bool { get }
var sensitive: Bool { get }
var spoilerText: String { get }
var visibility: Pachyderm.Status.Visibility { get }
var applicationName: String? { get }
var pinned: Bool? { get }
var bookmarked: Bool? { get }
var account: Account { get }
var reblog: Status? { get }
var attachments: [Attachment] { get }
var emojis: [Emoji] { get }
var hashtags: [Hashtag] { get }
var mentions: [Mention] { get }
}

View File

@ -8,7 +8,7 @@
import Foundation
public class Status: Decodable {
public final class Status: /*StatusProtocol,*/ Decodable {
public let id: String
public let uri: String
public let url: URL?
@ -36,23 +36,26 @@ public class Status: Decodable {
public let language: String?
public let pinned: Bool?
public let bookmarked: Bool?
public let card: Card?
public static func getContext(_ status: Status) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
public var applicationName: String? { application?.name }
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
}
public static func getCard(_ status: Status) -> Request<Card> {
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
}
public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
request.range = range
return request
}
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by")
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
request.range = range
return request
}
@ -61,44 +64,44 @@ public class Status: Decodable {
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 reblog(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
}
public static func unreblog(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
public static func unreblog(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
}
public static func favourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
public static func favourite(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
}
public static func unfavourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
public static func unfavourite(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
}
public static func pin(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/pin")
public static func pin(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/pin")
}
public static func unpin(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unpin")
public static func unpin(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unpin")
}
public static func bookmark(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/bookmark")
public static func bookmark(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/bookmark")
}
public static func unbookmark(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unbookmark")
public static func unbookmark(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
}
public static func muteConversation(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/mute")
public static func muteConversation(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/mute")
}
public static func unmuteConversation(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unmute")
public static func unmuteConversation(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
}
private enum CodingKeys: String, CodingKey {
@ -128,6 +131,7 @@ public class Status: Decodable {
case language
case pinned
case bookmarked
case card
}
}

View File

@ -1,170 +0,0 @@
//
// Promise.swift
// Pachyderm
//
// Created by Shadowfacts on 2/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
public class Promise<Result> {
private var handlers: [(Result) -> Void] = []
private var result: Result?
private var catchers: [(Error) -> Void] = []
private var error: Error?
func resolve(_ result: Result) {
self.result = result
self.handlers.forEach { $0(result) }
}
func reject(_ error: Error) {
self.error = error
self.catchers.forEach { $0(error) }
}
func addHandler(_ handler: @escaping (Result) -> Void) {
if let result = result {
handler(result)
} else {
handlers.append(handler)
}
}
func addCatcher(_ catcher: @escaping (Error) -> Void) {
if let error = error {
catcher(error)
} else {
catchers.append(catcher)
}
}
}
public extension Promise {
static func resolve<Result>(_ value: Result) -> Promise<Result> {
let promise = Promise<Result>()
promise.resolve(value)
return promise
}
static func reject<Result>(_ error: Error) -> Promise<Result> {
let promise = Promise<Result>()
promise.reject(error)
return promise
}
static func all<Result>(_ promises: [Promise<Result>], queue: DispatchQueue = .main) -> Promise<[Result]> {
let group = DispatchGroup()
var results = [Result?](repeating: nil, count: promises.count)
var firstError: Error?
for (index, promise) in promises.enumerated() {
group.enter()
promise.then { (res) in
queue.async {
results[index] = res
group.leave()
}
}.catch { (err) -> Void in
if firstError == nil {
firstError = err
}
group.leave()
}
}
return Promise<[Result]> { (resolve, reject) in
group.notify(queue: queue) {
if let firstError = firstError {
reject(firstError)
} else {
resolve(results.compactMap { $0 })
}
}
}
}
convenience init(resultProvider: @escaping (_ resolve: @escaping (Result) -> Void, _ reject: @escaping (Error) -> Void) -> Void) {
self.init()
resultProvider(self.resolve, self.reject)
}
convenience init<ErrorType>(_ resultProvider: @escaping ((Swift.Result<Result, ErrorType>) -> Void) -> Void) {
self.init { (resolve, reject) in
resultProvider { (result) in
switch result {
case let .success(res):
resolve(res)
case let .failure(error):
reject(error)
}
}
}
}
@discardableResult
func then(_ func: @escaping (Result) -> Void) -> Promise<Result> {
addHandler(`func`)
return self
}
func then<Next>(_ mapper: @escaping (Result) -> Promise<Next>) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newPromise = mapper(parentResult)
newPromise.addHandler(next.resolve)
newPromise.addCatcher(next.reject)
}
addCatcher(next.reject)
return next
}
func then<Next>(_ mapper: @escaping (Result) -> Next) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newResult = mapper(parentResult)
next.resolve(newResult)
}
addCatcher(next.reject)
return next
}
@discardableResult
func `catch`(_ catcher: @escaping (Error) -> Void) -> Promise<Result> {
addCatcher(catcher)
return self
}
func `catch`(_ catcher: @escaping (Error) -> Promise<Result>) -> Promise<Result> {
let next = Promise<Result>()
addHandler(next.resolve)
addCatcher { (error) in
let newPromise = catcher(error)
newPromise.addHandler(next.resolve)
newPromise.addCatcher(next.reject)
}
return next
}
func `catch`(_ catcher: @escaping (Error) -> Result) -> Promise<Result> {
let next = Promise<Result>()
addHandler(next.resolve)
addCatcher { (error) in
let newResult = catcher(error)
next.resolve(newResult)
}
return next
}
func handle(on queue: DispatchQueue) -> Promise<Result> {
return self.then { (result) in
return Promise { (resolve, reject) in
queue.async {
resolve(result)
}
}
}
}
}

View File

@ -8,56 +8,82 @@
import Foundation
enum Body {
case parameters([Parameter]?)
case formData([Parameter]?, FormAttachment?)
case empty
protocol Body {
var mimeType: String? { get }
var data: Data? { get }
}
extension Body {
private static let boundary: String = "PachydermBoundary"
struct EmptyBody: Body {
var mimeType: String? { nil }
var data: Data? { nil }
}
struct ParametersBody: Body {
let parameters: [Parameter]?
init(_ parmaeters: [Parameter]?) {
self.parameters = parmaeters
}
var mimeType: String? {
if parameters == nil || parameters!.isEmpty {
return nil
}
return "application/x-www-form-urlencoded; charset=utf-8"
}
var data: Data? {
switch self {
case let .parameters(parameters):
return parameters?.urlEncoded.data(using: .utf8)
case let .formData(parameters, attachment):
}
}
struct FormDataBody: Body {
private static let boundary = "PachydermBoundary"
let parameters: [Parameter]?
let attachment: FormAttachment?
init(_ parameters: [Parameter]?, _ attachment: FormAttachment?) {
self.parameters = parameters
self.attachment = attachment
}
var mimeType: String? {
if parameters == nil && attachment == nil {
return nil
}
return "multipart/form-data; boundary=\(FormDataBody.boundary)"
}
var data: Data? {
var data = Data()
parameters?.forEach { param in
guard let value = param.value else { return }
data.append("--\(Body.boundary)\r\n")
data.append("--\(FormDataBody.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
data.append("\(value)\r\n")
}
if let attachment = attachment {
data.append("--\(Body.boundary)\r\n")
data.append("--\(FormDataBody.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
data.append(attachment.data)
data.append("\r\n")
}
data.append("--\(Body.boundary)--\r\n")
data.append("--\(FormDataBody.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
}
struct JsonBody<T: Encodable>: Body {
let value: T
init(_ value: T) {
self.value = value
}
var mimeType: String? { "application/json" }
var data: Data? { try? Client.encoder.encode(value) }
}

View File

@ -14,7 +14,7 @@ public struct Request<ResultType: Decodable> {
let body: Body
var queryParameters: [Parameter]
init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
init(method: Method, path: String, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
self.method = method
self.path = path
self.body = body

View File

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

View File

@ -22,16 +22,16 @@ 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(error))
completion(.failure(.networkError(error)))
return
}
guard let data = data,
let response = response as? HTTPURLResponse else {
completion(.failure(Client.Error.invalidResponse))
completion(.failure(.invalidResponse))
return
}
guard response.statusCode == 200 else {
completion(.failure(Client.Error.unknownError))
completion(.failure(.unexpectedStatus(response.statusCode)))
return
}
guard let result = try? decoder.decode([Instance].self, from: data) else {

View File

@ -9,14 +9,14 @@
import Foundation
public class NotificationGroup {
public let notificationIDs: [String]
public let notifications: [Notification]
public let id: String
public let kind: Notification.Kind
public let statusState: StatusState?
init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil }
self.notificationIDs = notifications.map { $0.id }
self.notifications = notifications
self.id = notifications.first!.id
self.kind = notifications.first!.kind
if kind == .mention {
@ -27,18 +27,24 @@ public class NotificationGroup {
}
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 {
var groups = [[Notification]]()
for notification in notifications {
if allowedTypes.contains(notification.kind) {
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
groups[groups.count - 1].append(notification)
} else {
continue
} else if groups.count >= 2 {
let secondToLastGroup = groups[groups.count - 2]
if allowedTypes.contains(groups[groups.count - 1][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
groups[groups.count - 2].append(notification)
continue
}
}
}
groups.append([notification])
}
}.map {
return groups.map {
NotificationGroup(notifications: $0)!
}
}

View File

@ -1,126 +0,0 @@
//
// PromiseTests.swift
// PachydermTests
//
// Created by Shadowfacts on 2/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import XCTest
@testable import Pachyderm
class PromiseTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func assertResultEqual<Result: Equatable>(_ promise: Promise<Result>, _ value: Result, message: String? = nil) {
let expectation = self.expectation(description: message ?? "promise result assertion")
promise.then {
XCTAssertEqual($0, value)
expectation.fulfill()
}
self.waitForExpectations(timeout: 2) { (error) in
if let error = error {
XCTFail("didn't resolve promise: \(error)")
}
}
}
func testResolveImmediate() {
assertResultEqual(Promise<String>.resolve("blah"), "blah")
}
func testResolveImmediateMapped() {
let promise = Promise<String>.resolve("foo").then {
"test \($0)"
}.then {
Promise<String>.resolve("\($0) bar")
}
assertResultEqual(promise, "test foo bar")
}
func testContinueAfterReject() {
let promise = Promise<String>.reject(TestError()).then { (res) in
XCTFail("then on rejected promise is unreachable")
}.catch { (error) -> String in
XCTAssertTrue(error is TestError)
return "caught"
}.then {
"\($0) error"
}
assertResultEqual(promise, "caught error")
}
func testResolveDelayed() {
let promise = Promise<String> { (resolve, reject) in
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
resolve("blah")
}
}
assertResultEqual(promise, "blah")
}
func testResolveMappedDelayed() {
let promise = Promise<String> { (resolve, reject) in
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
resolve("foo")
}
}.then {
"\($0) bar"
}.then { (result) in
Promise<String> { (resolve, reject) in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
resolve("\(result) baz")
}
}
}
assertResultEqual(promise, "foo bar baz")
}
func testResolveAll() {
let promise = Promise<[String]>.all([
Promise<String>.resolve("a"),
Promise<String>.resolve("b"),
Promise<String>.resolve("c"),
])
assertResultEqual(promise, ["a", "b", "c"])
}
func testIntermediateReject() {
let promise = Promise<String>.resolve("foo").then { (_) -> Promise<String> in
Promise<String>.reject(TestError())
}.catch { (error) -> String in
XCTAssertTrue(error is TestError)
return "caught"
}.then { (result) -> String in
"\(result) error"
}
assertResultEqual(promise, "caught error")
}
func testResultHelper() {
let success = Promise<String> { (handler) in
handler(Result<String, Never>.success("asdf"))
}
assertResultEqual(success, "asdf")
let failure = Promise<String> { (handler) in
handler(Result<String, TestError>.failure(TestError()))
}.catch { (error) -> String in
"blah"
}
assertResultEqual(failure, "blah")
}
}
struct TestError: Error {
var localizedDescription: String {
"test error"
}
}

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

File diff suppressed because it is too large Load Diff

View File

@ -27,15 +27,6 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
BuildableName = "Tusker.app"
BlueprintName = "Tusker"
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO">
@ -92,6 +83,12 @@
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@ -10,9 +10,6 @@
<FileRef
location = "group:Cache/Cache.xcodeproj">
</FileRef>
<FileRef
location = "group:SwiftSoup/SwiftSoup.xcodeproj">
</FileRef>
<FileRef
location = "group:Gifu/Gifu.xcodeproj">
</FileRef>

View File

@ -1,14 +1,32 @@
{
"object": {
"pins": [
{
"package": "PLCrashReporter",
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
"state": {
"branch": null,
"revision": "6b7ca9a2faad6ea990ff60b0a3ee4fdf3db59150",
"version": "1.7.2"
}
},
{
"package": "SheetController",
"repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git",
"state": {
"branch": "master",
"revision": "6ee1ad24ec8620f5c17416d6141643f0787708ba",
"revision": "aa0f5192eaf19d01c89dbfa9ec5878a700376f23",
"version": null
}
},
{
"package": "SwiftSoup",
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
"state": {
"branch": null,
"revision": "774dc9c7213085db8aa59595e27c1cd22e428904",
"version": "2.3.2"
}
}
]
},

View File

@ -7,7 +7,6 @@
//
import UIKit
import Pachyderm
class AccountActivity: MastodonActivity {
@ -15,17 +14,17 @@ class AccountActivity: MastodonActivity {
return .action
}
var account: Account?
var account: AccountMO?
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
for case is Account in activityItems {
for case is AccountMO in activityItems {
return true
}
return false
}
override func prepare(withActivityItems activityItems: [Any]) {
for case let account as Account in activityItems {
for case let account as AccountMO in activityItems {
self.account = account
return
}

View File

@ -28,11 +28,12 @@ class FollowAccountActivity: AccountActivity {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
let request = Account.follow(account.id)
mastodonController.run(request).then { (relationship, _) -> Void in
self.mastodonController.cache.add(relationship: relationship)
}.catch { (error) -> Void in
print("could not follow account")
mastodonController.run(request) { (response) in
if case .failure(_) = response {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}

View File

@ -28,7 +28,9 @@ class SendMessageActivity: AccountActivity {
override var activityViewController: UIViewController? {
guard let account = account else { return nil }
return UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController))
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
return UINavigationController(rootViewController: compose)
}
}

View File

@ -28,11 +28,12 @@ class UnfollowAccountActivity: AccountActivity {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
let request = Account.unfollow(account.id)
mastodonController.run(request).then { (relationship, _) -> Void in
self.mastodonController.cache.add(relationship: relationship)
}.catch { (error) -> Void in
print("could not unfollow account: \(error)")
mastodonController.run(request) { (response) in
if case .failure(_) = response {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}

View File

@ -0,0 +1,39 @@
//
// AccountActivityItemSource.swift
// Tusker
//
// Created by Shadowfacts on 5/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import LinkPresentation
class AccountActivityItemSource: NSObject, UIActivityItemSource {
let account: AccountMO
init(_ account: AccountMO) {
self.account = account
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return account
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
return account
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
let metadata = LPLinkMetadata()
metadata.originalURL = account.url
metadata.url = account.url
metadata.title = "\(account.displayName) (@\(account.username)@\(account.url.host!)"
if let data = ImageCache.avatars.get(account.avatar),
let image = UIImage(data: data) {
metadata.iconProvider = NSItemProvider(object: image)
}
return metadata
}
}

View File

@ -36,10 +36,10 @@ class OpenInSafariActivity: UIActivity {
activityDidFinish(true)
}
static func completionHandler(viewController: UIViewController, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
return { (activityType, _, _, _) in
if activityType == .openInSafari {
viewController.present(SFSafariViewController(url: url), animated: true)
navigator.show(SFSafariViewController(url: url))
}
}
}

View File

@ -26,12 +26,15 @@ class BookmarkStatusActivity: StatusActivity {
override func perform() {
guard let status = status else { return }
let request = Status.bookmark(status)
mastodonController.run(request).then { (status, _) -> Void in
self.mastodonController.cache.add(status: status)
}.catch { (error) -> Void in
print("could not bookmark status: \(error)")
let request = Status.bookmark(status.id)
mastodonController.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}

View File

@ -0,0 +1,40 @@
//
// MuteConversationActivity.swift
// Tusker
//
// Created by Shadowfacts on 5/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class MuteConversationActivity: StatusActivity {
override var activityType: UIActivity.ActivityType? {
return .muteConversation
}
override var activityTitle: String? {
return NSLocalizedString("Mute Conversation", comment: "mute conversation activity title")
}
override var activityImage: UIImage? {
return UIImage(systemName: "speaker.slash")
}
override func perform() {
guard let status = status else { return }
let request = Status.muteConversation(status.id)
mastodonController.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}
}

View File

@ -25,12 +25,15 @@ class PinStatusActivity: StatusActivity {
override func perform() {
guard let status = status else { return }
let request = Status.pin(status)
mastodonController.run(request).then { (status, _) -> Void in
self.mastodonController.cache.add(status: status)
}.catch { (error) -> Void in
print("could not pin status: \(error)")
let request = Status.pin(status.id)
mastodonController.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}
}

View File

@ -7,7 +7,6 @@
//
import UIKit
import Pachyderm
class StatusActivity: MastodonActivity {
@ -15,17 +14,17 @@ class StatusActivity: MastodonActivity {
return .action
}
var status: Status?
var status: StatusMO?
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
for case is Status in activityItems {
for case is StatusMO in activityItems {
return true
}
return false
}
override func prepare(withActivityItems activityItems: [Any]) {
for case let status as Status in activityItems {
for case let status as StatusMO in activityItems {
self.status = status
return
}

View File

@ -26,12 +26,15 @@ class UnbookmarkStatusActivity: StatusActivity {
override func perform() {
guard let status = status else { return }
let request = Status.unbookmark(status)
mastodonController.run(request).then { (status, _) -> Void in
self.mastodonController.cache.add(status: status)
}.catch { (error) -> Void in
print("could not unbookmark status: \(error)")
let request = Status.unbookmark(status.id)
mastodonController.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}

View File

@ -0,0 +1,40 @@
//
// UnmuteConversationActivity.swift
// Tusker
//
// Created by Shadowfacts on 5/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class UnmuteConversationActivity: StatusActivity {
override var activityType: UIActivity.ActivityType? {
return .unmuteConversation
}
override var activityTitle: String? {
return NSLocalizedString("Unmute Conversation", comment: "unmute conversation activity title")
}
override var activityImage: UIImage? {
return UIImage(systemName: "speaker")
}
override func perform() {
guard let status = status else { return }
let request = Status.unmuteConversation(status.id)
mastodonController.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}
}

View File

@ -25,12 +25,15 @@ class UnpinStatusActivity: StatusActivity {
override func perform() {
guard let status = status else { return }
let request = Status.unpin(status)
mastodonController.run(request).then { (status, _) -> Void in
self.mastodonController.cache.add(status: status)
}.catch { (error) -> Void in
print("could not unpin status: \(error)")
let request = Status.unpin(status.id)
mastodonController.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}
}

View File

@ -0,0 +1,42 @@
//
// StatusActivityItemSource.swift
// Tusker
//
// Created by Shadowfacts on 5/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import LinkPresentation
import SwiftSoup
class StatusActivityItemSource: NSObject, UIActivityItemSource {
let status: StatusMO
init(_ status: StatusMO) {
self.status = status
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return status
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
return status
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
let metadata = LPLinkMetadata()
metadata.originalURL = status.url!
metadata.url = status.url!
let doc = try! SwiftSoup.parse(status.content)
let content = try! doc.text()
metadata.title = "\(status.account.displayName): \"\(content)\""
if let data = ImageCache.avatars.get(status.account.avatar),
let image = UIImage(data: data) {
metadata.iconProvider = NSItemProvider(object: image)
}
return metadata
}
}

View File

@ -22,5 +22,7 @@ extension UIActivity.ActivityType {
static let unbookmarkStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unbookmark_status")
static let pinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).pin_status")
static let unpinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unpin_status")
static let muteConversation = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).mute_conversation")
static let unmuteConversation = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unmute_conversation")
}

View File

@ -7,13 +7,41 @@
//
import UIKit
import CrashReporter
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
static private(set) var crashReporter: PLCrashReporter!
static var pendingCrashReport: PLCrashReport?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
#if !DEBUG
setupCrashReporter()
#endif
AppShortcutItem.createItems(for: application)
DispatchQueue.global(qos: .userInitiated).async {
AudioSessionHelper.disable()
AudioSessionHelper.setDefault()
}
return true
}
private func setupCrashReporter() {
let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .all)
AppDelegate.crashReporter = PLCrashReporter(configuration: config)
if AppDelegate.crashReporter.hasPendingCrashReport() {
let data = try! AppDelegate.crashReporter.loadPendingCrashReportDataAndReturnError()
AppDelegate.crashReporter.purgePendingCrashReport()
let report = try! PLCrashReport(data: data)
AppDelegate.pendingCrashReport = report
}
AppDelegate.crashReporter.enable()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

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

View File

@ -0,0 +1,28 @@
//
// AudioSessionHelper.swift
// Tusker
//
// Created by Shadowfacts on 6/21/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import AVFoundation
struct AudioSessionHelper {
static func enable() {
try? AVAudioSession.sharedInstance().setActive(true, options: [])
}
static func disable() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
static func setDefault() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers)
}
static func setVideoPlayback() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
}
}

View File

@ -0,0 +1,35 @@
//
// CachedDictionary.swift
// Tusker
//
// Created by Shadowfacts on 5/6/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
class CachedDictionary<Value> {
private let name: String
private var dict = [String: Value]()
private let queue: DispatchQueue
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "CachedDictionary (\(name)) Coordinator", attributes: .concurrent)
}
subscript(key: String) -> Value? {
get {
var result: Value? = nil
queue.sync {
result = dict[key]
}
return result
}
set(value) {
queue.async(flags: .barrier) {
self.dict[key] = value
}
}
}
}

View File

@ -47,4 +47,15 @@ enum Cache<T> {
try hybrid.setObject(object, forKey: key, expiry: expiry)
}
}
func removeAll() throws {
switch self {
case let .memory(memory):
memory.removeAll()
case let .disk(disk):
try disk.removeAll()
case let .hybrid(hybrid):
try hybrid.removeAll()
}
}
}

View File

@ -8,7 +8,6 @@
import UIKit
import Cache
import Pachyderm
class ImageCache {
@ -17,9 +16,9 @@ class ImageCache {
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
let cache: Cache<Data>
private let cache: Cache<Data>
var requests = [URL: RequestGroup]()
private var groups = [URL: RequestGroup]()
init(name: String, memoryExpiry expiry: Expiry) {
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
@ -44,59 +43,56 @@ class ImageCache {
completion?(data)
return nil
} else {
if let completion = completion, let group = requests[url] {
if let completion = completion, let group = groups[url] {
return group.addCallback(completion)
} else {
let group = RequestGroup(url: url)
let request = group.addCallback(completion)
group.run { (data) in
let group = RequestGroup(url: url) { (data) in
if let data = data {
try? self.cache.setObject(data, forKey: key)
}
self.groups.removeValue(forKey: url)
}
groups[url] = group
let request = group.addCallback(completion)
group.run()
return request
}
}
}
func get(_ url: URL) -> Promise<Data> {
return Promise<Data> { (resolve, reject) in
_ = self.get(url) { (data) in
if let data = data {
resolve(data)
} else {
reject(Error.unknown)
}
}
}
}
func get(_ url: URL) -> Data? {
return try? cache.object(forKey: url.absoluteString)
}
func cancelWithoutCallback(_ url: URL) {
requests[url]?.cancelWithoutCallback()
groups[url]?.cancelWithoutCallback()
}
class RequestGroup {
func reset() throws {
try cache.removeAll()
}
private class RequestGroup {
let url: URL
private let onFinished: (Data?) -> Void
private var task: URLSessionDataTask?
private var requests = [Request]()
init(url: URL) {
init(url: URL, onFinished: @escaping (Data?) -> Void) {
self.url = url
self.onFinished = onFinished
}
deinit {
task?.cancel()
}
func run(cache: @escaping (Data) -> Void) {
func run() {
task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
guard error == nil, let data = data else {
self.complete(with: nil)
return
}
cache(data)
self.complete(with: data)
})
task!.resume()
@ -136,11 +132,12 @@ class ImageCache {
callback(data)
}
}
self.onFinished(data)
}
}
class Request {
weak var group: RequestGroup?
private weak var group: RequestGroup?
private(set) var callback: ((Data?) -> Void)?
private(set) var cancelled: Bool = false
@ -155,8 +152,4 @@ class ImageCache {
}
}
enum Error: Swift.Error {
case unknown
}
}

View File

@ -8,7 +8,6 @@
import Foundation
import Pachyderm
import Combine
class MastodonController {
@ -31,34 +30,36 @@ class MastodonController {
}
}
private(set) lazy var cache = MastodonCache(mastodonController: self)
static func resetAll() {
all = [:]
}
private let transient: Bool
private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL
private(set) var accountInfo: LocalData.UserAccountInfo?
var accountInfo: LocalData.UserAccountInfo?
let client: Client!
var account: Account!
var instance: Instance!
init(instanceURL: URL) {
var loggedIn: Bool {
accountInfo != nil
}
init(instanceURL: URL, transient: Bool = false) {
self.instanceURL = instanceURL
self.accountInfo = nil
self.client = Client(baseURL: instanceURL)
self.transient = transient
}
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
client.run(request, completion: completion)
}
func run<Result: Decodable>(_ request: Request<Result>) -> Promise<(Result, Pagination?)> {
return client.run(request)
}
func run<Result: Decodable>(_ request: Request<Result>) -> AnyPublisher<(Result, Pagination?), Error> {
return client.run(request)
}
func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) {
guard client.clientID == nil,
client.clientSecret == nil else {
@ -83,28 +84,47 @@ class MastodonController {
}
}
func getOwnAccount(completion: ((Account) -> Void)? = nil) {
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
if account != nil {
completion?(account)
completion?(.success(account))
} else {
let request = Client.getSelfAccount()
run(request).then { (account, _) -> Void in
run(request) { response in
switch response {
case let .failure(error):
completion?(.failure(error))
case let .success(account, _):
self.account = account
self.cache.add(account: account)
completion?(account)
}.catch { (error) -> Void in
fatalError("couldn't get own account: \(error)")
self.persistentContainer.backgroundContext.perform {
if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self.persistentContainer)
} else {
// the first time the user's account is added to the store,
// increment its reference count so that it's never removed
self.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true)
}
completion?(.success(account))
}
}
}
}
}
func getOwnInstance() {
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
if let instance = self.instance {
completion?(instance)
} else {
let request = Client.getInstance()
run(request).then { (instance, _) -> Void in
run(request) { (response) in
guard case let .success(instance, _) = response else { fatalError() }
self.instance = instance
}.catch { (error) -> Void in
fatalError("couldn't get own instance: \(error)")
completion?(instance)
}
}
}
}
// ObservableObject so that SwiftUI views can receive it through @EnvironmentObject
extension MastodonController: ObservableObject {}

View File

@ -0,0 +1,105 @@
//
// AccountMO.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
//
import Foundation
import CoreData
import Pachyderm
@objc(AccountMO)
public final class AccountMO: NSManagedObject, AccountProtocol {
@nonobjc public class func fetchRequest() -> NSFetchRequest<AccountMO> {
return NSFetchRequest<AccountMO>(entityName: "Account")
}
@NSManaged public var acct: String
@NSManaged public var avatar: URL
@NSManaged public var botCD: Bool
@NSManaged public var createdAt: Date
@NSManaged public var displayName: String
@NSManaged private var emojisData: Data?
@NSManaged private var fieldsData: Data?
@NSManaged public var followersCount: Int
@NSManaged public var followingCount: Int
@NSManaged public var header: URL
@NSManaged public var id: String
@NSManaged public var locked: Bool
@NSManaged public var movedCD: Bool
@NSManaged public var note: String
@NSManaged public var referenceCount: Int
@NSManaged public var statusesCount: Int
@NSManaged public var url: URL
@NSManaged public var username: String
@NSManaged public var movedTo: AccountMO?
@LazilyDecoding(arrayFrom: \AccountMO.emojisData)
public var emojis: [Emoji]
@LazilyDecoding(arrayFrom: \AccountMO.fieldsData)
public var fields: [Pachyderm.Account.Field]
public var bot: Bool? { botCD }
public var moved: Bool? { movedCD }
func incrementReferenceCount() {
referenceCount += 1
}
func decrementReferenceCount() {
referenceCount -= 1
if referenceCount <= 0 {
managedObjectContext!.delete(self)
}
}
public override func prepareForDeletion() {
super.prepareForDeletion()
movedTo?.decrementReferenceCount()
}
}
extension AccountMO {
convenience init(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiAccount: account, container: container)
movedTo?.incrementReferenceCount()
}
func updateFrom(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore) {
guard let context = managedObjectContext else {
// we've been deleted, don't bother updating
return
}
self.acct = account.acct
self.avatar = account.avatarStatic // we don't animate avatars
self.botCD = account.bot ?? false
self.createdAt = account.createdAt
self.displayName = account.displayName
self.emojis = account.emojis
self.fields = account.fields
self.followersCount = account.followersCount
self.followingCount = account.followingCount
self.header = account.headerStatic // we don't animate headers
self.id = account.id
self.locked = account.locked
self.movedCD = account.moved ?? false
self.note = account.note
self.statusesCount = account.statusesCount
self.url = account.url
self.username = account.username
if let movedTo = account.movedTo {
self.movedTo = container.account(for: movedTo.id, in: context) ?? AccountMO(apiAccount: movedTo, container: container, context: context)
} else {
self.movedTo = nil
}
}
}

View File

@ -0,0 +1,190 @@
//
// MastodonCachePersistentStore.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
import Combine
class MastodonCachePersistentStore: NSPersistentContainer {
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = self.viewContext
return context
}()
let statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>()
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
if transient {
super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
let storeDescription = NSPersistentStoreDescription()
storeDescription.type = NSInMemoryStoreType
persistentStoreDescriptions = [storeDescription]
} else {
super.init(name: "\(accountInfo!.id)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
}
loadPersistentStores { (description, error) in
if let error = error {
fatalError("Unable to load persistent store: \(error)")
}
}
}
func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? {
let context = context ?? viewContext
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
request.predicate = NSPredicate(format: "id = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let status = result.first {
return status
} else {
return nil
}
}
@discardableResult
private func upsert(status: Status, incrementReferenceCount: Bool) -> StatusMO {
if let statusMO = self.status(for: status.id, in: self.backgroundContext) {
statusMO.updateFrom(apiStatus: status, container: self)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
return statusMO
} else {
let statusMO = StatusMO(apiStatus: status, container: self, context: self.backgroundContext)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
return statusMO
}
}
func addOrUpdate(status: Status, incrementReferenceCount: Bool, completion: ((StatusMO) -> Void)? = nil) {
backgroundContext.perform {
let statusMO = self.upsert(status: status, incrementReferenceCount: incrementReferenceCount)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?(statusMO)
self.statusSubject.send(status.id)
}
}
func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
backgroundContext.perform {
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
statuses.forEach { self.statusSubject.send($0.id) }
completion?()
}
}
func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? {
let context = context ?? viewContext
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
request.predicate = NSPredicate(format: "id = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let account = result.first {
return account
} else {
return nil
}
}
@discardableResult
private func upsert(account: Account, incrementReferenceCount: Bool) -> AccountMO {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self)
if incrementReferenceCount {
accountMO.incrementReferenceCount()
}
return accountMO
} else {
let accountMO = AccountMO(apiAccount: account, container: self, context: self.backgroundContext)
if incrementReferenceCount {
accountMO.incrementReferenceCount()
}
return accountMO
}
}
func addOrUpdate(account: Account, incrementReferenceCount: Bool, completion: ((AccountMO) -> Void)? = nil) {
backgroundContext.perform {
let accountMO = self.upsert(account: account, incrementReferenceCount: incrementReferenceCount)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?(accountMO)
self.accountSubject.send(account.id)
}
}
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform {
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
accounts.forEach { self.accountSubject.send($0.id) }
}
}
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
backgroundContext.perform {
let statuses = notifications.compactMap { $0.status }
// filter out mentions, otherwise we would double increment the reference count of those accounts
// since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
statuses.forEach { self.statusSubject.send($0.id) }
accounts.forEach { self.accountSubject.send($0.id) }
}
}
func performBatchUpdates(_ block: @escaping (_ context: NSManagedObjectContext, _ addAccounts: ([Account]) -> Void, _ addStatuses: ([Status]) -> Void) -> Void, completion: (() -> Void)? = nil) {
backgroundContext.perform {
var updatedAccounts = [String]()
var updatedStatuses = [String]()
block(self.backgroundContext, { (accounts) in
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
updatedAccounts.append(contentsOf: accounts.map { $0.id })
}, { (statuses) in
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
updatedStatuses.append(contentsOf: statuses.map { $0.id })
})
updatedAccounts.forEach(self.accountSubject.send)
updatedStatuses.forEach(self.statusSubject.send)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
}
}
}

View File

@ -0,0 +1,144 @@
//
// StatusMO.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
//
import Foundation
import CoreData
import Pachyderm
@objc(StatusMO)
public final class StatusMO: NSManagedObject, StatusProtocol {
@nonobjc public class func fetchRequest() -> NSFetchRequest<StatusMO> {
return NSFetchRequest<StatusMO>(entityName: "Status")
}
@NSManaged public var applicationName: String?
@NSManaged private var attachmentsData: Data?
@NSManaged private var bookmarkedInternal: Bool
@NSManaged public var content: String
@NSManaged public var createdAt: Date
@NSManaged private var emojisData: Data?
@NSManaged public var favourited: Bool
@NSManaged public var favouritesCount: Int
@NSManaged private var hashtagsData: Data?
@NSManaged public var id: String
@NSManaged public var inReplyToAccountID: String?
@NSManaged public var inReplyToID: String?
@NSManaged private var mentionsData: Data?
@NSManaged public var muted: Bool
@NSManaged private var pinnedInternal: Bool
@NSManaged public var reblogged: Bool
@NSManaged public var reblogsCount: Int
@NSManaged public var referenceCount: Int
@NSManaged public var sensitive: Bool
@NSManaged public var spoilerText: String
@NSManaged public var uri: String // todo: are both uri and url necessary?
@NSManaged public var url: URL?
@NSManaged private var visibilityString: String
@NSManaged public var account: AccountMO
@NSManaged public var reblog: StatusMO?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment]
@LazilyDecoding(arrayFrom: \StatusMO.emojisData)
public var emojis: [Emoji]
@LazilyDecoding(arrayFrom: \StatusMO.hashtagsData)
public var hashtags: [Hashtag]
@LazilyDecoding(arrayFrom: \StatusMO.mentionsData)
public var mentions: [Mention]
public var pinned: Bool? { pinnedInternal }
public var bookmarked: Bool? { bookmarkedInternal }
public var visibility: Pachyderm.Status.Visibility {
get {
Pachyderm.Status.Visibility(rawValue: visibilityString) ?? .public
}
set {
visibilityString = newValue.rawValue
}
}
func incrementReferenceCount() {
referenceCount += 1
}
func decrementReferenceCount() {
referenceCount -= 1
if referenceCount <= 0 {
managedObjectContext!.delete(self)
}
}
public override func prepareForDeletion() {
super.prepareForDeletion()
reblog?.decrementReferenceCount()
account.decrementReferenceCount()
}
}
extension StatusMO {
convenience init(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiStatus: status, container: container)
reblog?.incrementReferenceCount()
account.incrementReferenceCount()
}
func updateFrom(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore) {
guard let context = managedObjectContext else {
// we have been deleted, don't bother updating
return
}
self.applicationName = status.application?.name
self.attachments = status.attachments
self.bookmarkedInternal = status.bookmarked ?? false
self.content = status.content
self.createdAt = status.createdAt
self.emojis = status.emojis
self.favourited = status.favourited ?? false
self.favouritesCount = status.favouritesCount
self.hashtags = status.hashtags
self.inReplyToAccountID = status.inReplyToAccountID
self.inReplyToID = status.inReplyToID
self.id = status.id
self.mentions = status.mentions
self.muted = status.muted ?? false
self.pinnedInternal = status.pinned ?? false
self.reblogged = status.reblogged ?? false
self.reblogsCount = status.reblogsCount
self.sensitive = status.sensitive
self.spoilerText = status.spoilerText
self.uri = status.uri
self.url = status.url
self.visibility = status.visibility
if let existing = container.account(for: status.account.id, in: context) {
existing.updateFrom(apiAccount: status.account, container: container)
self.account = existing
} else {
self.account = AccountMO(apiAccount: status.account, container: container, context: context)
}
if let reblog = status.reblog {
if let existing = container.status(for: reblog.id, in: context) {
existing.updateFrom(apiStatus: reblog, container: container)
self.reblog = existing
} else {
self.reblog = StatusMO(apiStatus: reblog, container: container, context: context)
}
} else {
self.reblog = nil
}
}
}

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19D76" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="URI"/>
<attribute name="botCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="emojisData" attributeType="Binary"/>
<attribute name="fieldsData" optional="YES" attributeType="Binary"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="URI"/>
<attribute name="id" attributeType="String"/>
<attribute name="locked" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="movedCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="note" attributeType="String"/>
<attribute name="referenceCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="url" attributeType="URI"/>
<attribute name="username" attributeType="String"/>
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Status" representedClassName="StatusMO" syncable="YES">
<attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="attachmentsData" attributeType="Binary"/>
<attribute name="bookmarkedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="emojisData" attributeType="Binary" customClassName="[Data]"/>
<attribute name="favourited" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hashtagsData" attributeType="Binary"/>
<attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="mentionsData" attributeType="Binary"/>
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogged" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="referenceCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" attributeType="String"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/>
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="328"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="418"/>
</elements>
</model>

View File

@ -1,92 +0,0 @@
//
// DraftsManager.swift
// Tusker
//
// Created by Shadowfacts on 10/22/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
class DraftsManager: Codable {
private(set) static var shared: DraftsManager = load()
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
static func save() {
DispatchQueue.global(qos: .userInitiated).async {
let encoder = PropertyListEncoder()
let data = try? encoder.encode(shared)
try? data?.write(to: archiveURL, options: .noFileProtection)
}
}
static func load() -> DraftsManager {
let decoder = PropertyListDecoder()
if let data = try? Data(contentsOf: archiveURL),
let draftsManager = try? decoder.decode(DraftsManager.self, from: data) {
return draftsManager
}
return DraftsManager()
}
private init() {}
var drafts: [Draft] = []
var sorted: [Draft] {
return drafts.sorted(by: { $0.lastModified > $1.lastModified })
}
func create(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [DraftAttachment]) -> Draft {
let draft = Draft(accountID: accountID, text: text, contentWarning: contentWarning, inReplyToID: inReplyToID, attachments: attachments)
drafts.append(draft)
return draft
}
func remove(_ draft: Draft) {
let index = drafts.firstIndex(of: draft)!
drafts.remove(at: index)
}
}
extension DraftsManager {
class Draft: Codable, Equatable {
let id: UUID
private(set) var accountID: String
private(set) var text: String
private(set) var contentWarning: String?
private(set) var attachments: [DraftAttachment]
private(set) var inReplyToID: String?
private(set) var lastModified: Date
init(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [DraftAttachment], lastModified: Date = Date()) {
self.id = UUID()
self.accountID = accountID
self.text = text
self.contentWarning = contentWarning
self.inReplyToID = inReplyToID
self.attachments = attachments
self.lastModified = lastModified
}
func update(accountID: String, text: String, contentWarning: String?, attachments: [DraftAttachment]) {
self.accountID = accountID
self.text = text
self.contentWarning = contentWarning
self.lastModified = Date()
self.attachments = attachments
}
static func ==(lhs: Draft, rhs: Draft) -> Bool {
return lhs.id == rhs.id
}
}
struct DraftAttachment: Codable {
let attachment: CompositionAttachment
let description: String
}
}

View File

@ -9,23 +9,29 @@
import Foundation
import Pachyderm
extension Account {
extension AccountMO {
var realDisplayName: String {
var displayOrUserName: String {
if displayName.isEmpty {
return username
} else if Preferences.shared.hideCustomEmojiInUsernames {
return stripCustomEmoji(from: displayName)
} else {
return displayName
}
}
var displayNameWithoutCustomEmoji: String {
if displayName.isEmpty {
return username
} else {
return stripCustomEmoji(from: displayName)
}
}
private static let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: [])
private func stripCustomEmoji(from string: String) -> String {
let range = NSRange(location: 0, length: string.utf16.count)
return Account.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
return AccountMO.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
}
}

View File

@ -10,31 +10,6 @@ import Foundation
extension Date {
// var timeAgo: String {
// let calendar = NSCalendar.current
// let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])
//
// let components = calendar.dateComponents(unitFlags, from: self, to: Date())
//
// if components.year! >= 1 {
// return "\(components.year!)y"
// } else if components.month! >= 1 {
// return "\(components.month!)mo"
// } else if components.weekOfYear! >= 1 {
// return "\(components.weekOfYear!)w"
// } else if components.day! >= 1 {
// return "\(components.day!)d"
// } else if components.hour! >= 1 {
// return "\(components.hour!)h"
// } else if components.minute! >= 1 {
// return "\(components.minute!)m"
// } else if components.second! >= 3 {
// return "\(components.second!)s"
// } else {
// return "Now"
// }
// }
func timeAgo() -> (Int, Calendar.Component) {
let calendar = NSCalendar.current
let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])

View File

@ -0,0 +1,29 @@
//
// NSTextAttachment+Emoji.swift
// Tusker
//
// Created by Shadowfacts on 3/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
extension NSTextAttachment {
// Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m
convenience init(emojiImage image: UIImage, in font: UIFont, with textColor: UIColor = .label) {
let adjustedCapHeight = font.capHeight - 1
var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight)
let defaultScale: CGFloat = 1.4
imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale)
UIGraphicsBeginImageContextWithOptions(imageSizeMatchingFontSize, false, 0.0)
textColor.set()
image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize))
let attachmentImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
self.init()
self.image = attachmentImage
}
}

View File

@ -0,0 +1,33 @@
//
// PKDrawing+Render.swift
// Tusker
//
// Created by Shadowfacts on 5/9/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import PencilKit
extension PKDrawing {
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
var drawingImage: UIImage!
lightTraitCollection.performAsCurrent {
drawingImage = self.image(from: rect, scale: scale)
}
let imageRect = CGRect(origin: .zero, size: rect.size)
let format = UIGraphicsImageRendererFormat()
format.opaque = false
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: rect.size, format: format)
return renderer.image { (context) in
UIColor.white.setFill()
context.fill(imageRect)
drawingImage.draw(in: imageRect)
}
}
}

View File

@ -0,0 +1,30 @@
//
// StatusStateResolver.swift
// Tusker
//
// Created by Shadowfacts on 9/15/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
extension StatusState {
func resolveFor(status: StatusMO, text: String?) {
let longEnoughToCollapse: Bool
if Preferences.shared.collapseLongPosts,
let text = text,
text.count > 500 {
longEnoughToCollapse = true
} else {
longEnoughToCollapse = false
}
let contentWarningCollapsible = !status.spoilerText.isEmpty
self.collapsible = contentWarningCollapsible || longEnoughToCollapse
self.collapsed = longEnoughToCollapse || (!Preferences.shared.expandAllContentWarnings && contentWarningCollapsible)
}
}

View File

@ -0,0 +1,21 @@
//
// UIAccessibility.swift
// Tusker
//
// Created by Shadowfacts on 9/12/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
extension UIAccessibility {
static var prefersCrossFadeTransitionsBackwardsCompat: Bool {
if #available(iOS 14.0, *) {
return prefersCrossFadeTransitions
} else {
return isReduceMotionEnabled
}
}
}

View File

@ -0,0 +1,67 @@
//
// UIBezierPath+Helpers.swift
// Tusker
//
// Created by Shadowfacts on 6/25/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
// TODO: write unit tests for this
extension UIBezierPath {
/// Create a new UIBezierPath that wraps around the given array of rectangles.
/// This is not a convex hull aglorithm. What this does is it takes a set of rectangles
/// and draws a line around the outer borders of the combined shape.
convenience init(wrappingAround rects: [CGRect]) {
precondition(rects.count > 0)
if rects.count == 1 {
self.init(rect: rects.first!)
return
}
let rects = rects.sorted { $0.minY < $1.minY }
self.init()
// start at the top left corner
self.move(to: CGPoint(x: rects.first!.minX, y: rects.first!.minY))
// walk down the left side
var prevLeft = rects.first!.minX
for rect in rects where !rect.minX.isEqual(to: prevLeft) {
self.addLine(to: CGPoint(x: prevLeft, y: rect.minY))
self.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
prevLeft = rect.minX
}
// ensure at the bottom left if not already
let bottomLeft = CGPoint(x: rects.last!.minX, y: rects.last!.maxY)
if !self.currentPoint.equalTo(bottomLeft) {
self.addLine(to: bottomLeft)
}
// across the bottom of the last rect
self.addLine(to: CGPoint(x: rects.last!.maxX, y: rects.last!.maxY))
// walk up the right side
var prevRight = rects.last!.maxX
for rect in rects.reversed() where !rect.maxX.isEqual(to: prevRight) {
self.addLine(to: CGPoint(x: prevRight, y: rect.maxY))
self.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
prevRight = rect.maxX
}
// ensure at the top right if not already
let topRight = CGPoint(x: rects.first!.maxX, y: rects.first!.minY)
if !self.currentPoint.equalTo(topRight) {
self.addLine(to: topRight)
}
// across the top of the first rect
self.addLine(to: CGPoint(x: rects.first!.minX, y: rects.first!.minY))
}
}

View File

@ -10,23 +10,17 @@ import UIKit
extension UIViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let presented = presented as? LargeImageViewController,
presented.sourceInfo?.image != nil {
if let presented = presented as? LargeImageAnimatableViewController,
presented.animationImage != nil {
return LargeImageExpandAnimationController()
} else if let presented = presented as? GalleryViewController,
presented.sourcesInfo[presented.startIndex]?.image != nil {
return GalleryExpandAnimationController()
}
return nil
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let dismissed = dismissed as? LargeImageViewController,
dismissed.imageForDismissalAnimation() != nil {
if let dismissed = dismissed as? LargeImageAnimatableViewController,
dismissed.animationImage != nil {
return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
} else if let dismissed = dismissed as? GalleryViewController,
dismissed.imageForDismissalAnimation() != nil {
return GalleryShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
}
return nil
}
@ -36,10 +30,6 @@ extension UIViewController: UIViewControllerTransitioningDelegate {
let interactionController = animator.interactionController,
interactionController.inProgress {
return interactionController
} else if let animator = animator as? GalleryShrinkAnimationController,
let interactionController = animator.interactionController,
interactionController.inProgress {
return interactionController
}
return nil
}

View File

@ -0,0 +1,22 @@
//
// View+ConditionalModifier.swift
// Tusker
//
// Created by Shadowfacts on 8/31/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
extension View {
@ViewBuilder
func conditionally<Modified: View>(_ condition: Bool, modifier: (Self) -> Modified) -> some View {
if condition {
modifier(self)
} else {
self
}
}
}

View File

@ -37,4 +37,17 @@ extension Status.Visibility {
}
}
var unfilledImageName: String {
switch self {
case .public:
return "globe"
case .unlisted:
return "lock.open"
case .private:
return "lock"
case .direct:
return "envelope"
}
}
}

View File

@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -30,7 +30,9 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
@ -98,5 +100,16 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array/>
<key>UTTypeIdentifier</key>
<string>space.vaccor.Tusker.composition-attachment</string>
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
</array>
</dict>
</plist>

View File

@ -0,0 +1,64 @@
//
// LazyDecoding.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder()
// todo: invalidate cache on underlying data change using KVO?
@propertyWrapper
public struct LazilyDecoding<Enclosing, Value: Codable> {
private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?>
private let fallback: Value
private var value: Value?
init(from keyPath: ReferenceWritableKeyPath<Enclosing, Data?>, fallback: Value) {
self.keyPath = keyPath
self.fallback = fallback
}
public var wrappedValue: Value {
get { fatalError("called LazilyDecoding wrappedValue getter") }
set { fatalError("called LazilyDecoding wrappedValue setter") }
}
public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>, storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Self>) -> Value {
get {
var wrapper = instance[keyPath: storageKeyPath]
if let value = wrapper.value {
return value
} else {
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
do {
let value = try decoder.decode(Value.self, from: data)
wrapper.value = value
instance[keyPath: storageKeyPath] = wrapper
return value
} catch {
return wrapper.fallback
}
}
}
set {
var wrapper = instance[keyPath: storageKeyPath]
wrapper.value = newValue
instance[keyPath: storageKeyPath] = wrapper
let newData = try? encoder.encode(newValue)
instance[keyPath: wrapper.keyPath] = newData
}
}
}
extension LazilyDecoding {
init(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) {
self.init(from: keyPath, fallback: [] as! Value)
}
}

View File

@ -18,6 +18,7 @@ class LocalData: ObservableObject {
private init() {
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") {
defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")!
defaults.removePersistentDomain(forName: "\(Bundle.main.bundleIdentifier!).uitesting")
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
accounts = [
UserAccountInfo(
@ -30,7 +31,20 @@ class LocalData: ObservableObject {
]
}
} else {
defaults = UserDefaults()
defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
tryMigrateOldDefaults()
}
}
// TODO: remove me before public beta
private func tryMigrateOldDefaults() {
let old = UserDefaults()
if let accounts = old.array(forKey: accountsKey) as? [[String: String]],
let mostRecentAccount = old.string(forKey: mostRecentAccountKey) {
defaults.setValue(accounts, forKey: accountsKey)
defaults.setValue(mostRecentAccount, forKey: mostRecentAccountKey)
old.removeObject(forKey: accountsKey)
old.removeObject(forKey: mostRecentAccountKey)
}
}
@ -44,11 +58,10 @@ class LocalData: ObservableObject {
let url = URL(string: instanceURL),
let clientId = info["clientID"],
let secret = info["clientSecret"],
let username = info["username"],
let accessToken = info["accessToken"] else {
return nil
}
return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: username, accessToken: accessToken)
return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: info["username"], accessToken: accessToken)
}
} else {
return []
@ -56,15 +69,18 @@ class LocalData: ObservableObject {
}
set {
objectWillChange.send()
let array = newValue.map { (info) in
return [
let array = newValue.map { (info) -> [String: String] in
var res = [
"id": info.id,
"instanceURL": info.instanceURL.absoluteString,
"clientID": info.clientID,
"clientSecret": info.clientSecret,
"username": info.username,
"accessToken": info.accessToken
]
if let username = info.username {
res["username"] = username
}
return res
}
defaults.set(array, forKey: accountsKey)
}
@ -85,7 +101,7 @@ class LocalData: ObservableObject {
return !accounts.isEmpty
}
func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String, accessToken: String) -> UserAccountInfo {
func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo {
var accounts = self.accounts
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index)
@ -97,6 +113,13 @@ class LocalData: ObservableObject {
return info
}
func setUsername(for info: UserAccountInfo, username: String) {
var info = info
info.username = username
removeAccount(info)
accounts.append(info)
}
func removeAccount(_ info: UserAccountInfo) {
accounts.removeAll(where: { $0.id == info.id })
}
@ -128,7 +151,7 @@ extension LocalData {
let instanceURL: URL
let clientID: String
let clientSecret: String
let username: String
fileprivate(set) var username: String!
let accessToken: String
func hash(into hasher: inout Hasher) {

View File

@ -1,207 +0,0 @@
//
// StatusCache.swift
// Tusker
//
// Created by Shadowfacts on 9/17/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
import Combine
import Pachyderm
class MastodonCache {
private var statuses = CachedDictionary<Status>(name: "Statuses")
private var accounts = CachedDictionary<Account>(name: "Accounts")
private var relationships = CachedDictionary<Relationship>(name: "Relationships")
private var notifications = CachedDictionary<Pachyderm.Notification>(name: "Notifications")
let statusSubject = PassthroughSubject<Status, Never>()
let accountSubject = PassthroughSubject<Account, Never>()
weak var mastodonController: MastodonController?
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
}
// MARK: - Statuses
func status(for id: String) -> Status? {
return statuses[id]
}
func set(status: Status, for id: String) {
statuses[id] = status
add(account: status.account)
if let reblog = status.reblog {
add(status: reblog)
add(account: reblog.account)
}
statusSubject.send(status)
}
func status(for id: String, completion: @escaping (Status?) -> Void) {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getStatus(id: id)
mastodonController.run(request) { response in
guard case let .success(status, _) = response else {
completion(nil)
return
}
self.set(status: status, for: id)
completion(status)
}
}
func status(for id: String) -> Promise<Status> {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getStatus(id: id)
return mastodonController.run(request).then { (status, _) in
status
}.then(self.add(status:))
}
func add(status: Status) {
set(status: status, for: status.id)
}
func addAll(statuses: [Status]) {
statuses.forEach(add)
}
// MARK: - Accounts
func account(for id: String) -> Account? {
return accounts[id]
}
func set(account: Account, for id: String) {
accounts[id] = account
accountSubject.send(account)
}
func account(for id: String, completion: @escaping (Account?) -> Void) {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getAccount(id: id)
mastodonController.run(request) { response in
guard case let .success(account, _) = response else {
completion(nil)
return
}
self.set(account: account, for: account.id)
completion(account)
}
}
func account(for id: String) -> Promise<Account> {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getAccount(id: id)
return mastodonController.run(request).then { (account, _) in
account
}.then(self.add(account:))
}
func add(account: Account) {
set(account: account, for: account.id)
}
func addAll(accounts: [Account]) {
accounts.forEach(add)
}
// MARK: - Relationships
func relationship(for id: String) -> Relationship? {
return relationships[id]
}
func set(relationship: Relationship, id: String) {
relationships[id] = relationship
}
func relationship(for id: String, completion: @escaping (Relationship?) -> Void) {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getRelationships(accounts: [id])
mastodonController.run(request) { response in
guard case let .success(relationships, _) = response,
let relationship = relationships.first else {
completion(nil)
return
}
self.set(relationship: relationship, id: relationship.id)
completion(relationship)
}
}
func relationship(for id: String) -> Promise<Relationship> {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getRelationships(accounts: [id])
return mastodonController.run(request).then { (relationships, _) in
relationships.first!
}.then(self.add(relationship:))
}
func add(relationship: Relationship) {
set(relationship: relationship, id: relationship.id)
}
func addAll(relationships: [Relationship]) {
relationships.forEach(add)
}
// MARK: - Notifications
func notification(for id: String) -> Pachyderm.Notification? {
return notifications[id]
}
func set(notification: Pachyderm.Notification, id: String) {
notifications[id] = notification
}
func add(notification: Pachyderm.Notification) {
set(notification: notification, id: notification.id)
}
func addAll(notifications: [Pachyderm.Notification]) {
notifications.forEach(add)
}
}
class CachedDictionary<Value> {
private let name: String
private var dict = [String: Value]()
private let queue: DispatchQueue
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "CachedDictionary (\(name)) Coordinator", attributes: .concurrent)
}
subscript(key: String) -> Value? {
get {
var result: Value? = nil
queue.sync {
result = dict[key]
}
return result
}
set(value) {
queue.async(flags: .barrier) {
self.dict[key] = value
}
}
}
}

View File

@ -0,0 +1,116 @@
//
// CompositionAttachment.swift
// Tusker
//
// Created by Shadowfacts on 3/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import UIKit
import MobileCoreServices
final class CompositionAttachment: NSObject, Codable, ObservableObject {
static let typeIdentifier = "space.vaccor.Tusker.composition-attachment"
let id: UUID
@Published var data: CompositionAttachmentData
@Published var attachmentDescription: String
init(data: CompositionAttachmentData, description: String = "") {
self.id = UUID()
self.data = data
self.attachmentDescription = description
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.data = try container.decode(CompositionAttachmentData.self, forKey: .data)
self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(data, forKey: .data)
try container.encode(attachmentDescription, forKey: .attachmentDescription)
}
static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool {
return lhs.id == rhs.id
}
enum CodingKeys: String, CodingKey {
case id
case data
case attachmentDescription
}
}
extension CompositionAttachment: Identifiable {}
private let imageType = kUTTypeImage as String
private let mp4Type = kUTTypeMPEG4 as String
private let quickTimeType = kUTTypeQuickTimeMovie as String
private let dataType = kUTTypeData as String
extension CompositionAttachment: NSItemProviderWriting {
static var writableTypeIdentifiersForItemProvider: [String] {
[typeIdentifier]
}
func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
if typeIdentifier == CompositionAttachment.typeIdentifier {
do {
completionHandler(try PropertyListEncoder().encode(self), nil)
} catch {
completionHandler(nil, error)
}
}
completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier)
return nil
}
enum ItemProviderError: Error {
case incompatibleTypeIdentifier
var localizedDescription: String {
switch self {
case .incompatibleTypeIdentifier:
return "Cannot provide data for given type"
}
}
}
}
extension CompositionAttachment: NSItemProviderReading {
static var readableTypeIdentifiersForItemProvider: [String] {
// todo: is there a better way of handling movies than manually adding all possible UTI types?
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
[typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider
}
static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self {
if typeIdentifier == CompositionAttachment.typeIdentifier {
return try PropertyListDecoder().decode(Self.self, from: data)
} else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) {
return CompositionAttachment(data: .image(image)) as! Self
} else if typeIdentifier == mp4Type || typeIdentifier == quickTimeType {
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let temporaryFileName = ProcessInfo().globallyUniqueString
let fileExt = UTTypeCopyPreferredTagWithClass(typeIdentifier as CFString, kUTTagClassFilenameExtension)!
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt.takeUnretainedValue() as String)
try data.write(to: temporaryFileURL)
return CompositionAttachment(data: .video(temporaryFileURL)) as! Self
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {
return CompositionAttachment(data: .video(url)) as! Self
} else {
throw ItemProviderError.incompatibleTypeIdentifier
}
}
}

View File

@ -1,5 +1,5 @@
//
// CompositionAttachment.swift
// CompositionAttachmentData.swift
// Tusker
//
// Created by Shadowfacts on 1/1/20.
@ -9,12 +9,13 @@
import UIKit
import Photos
import MobileCoreServices
import Pachyderm
import PencilKit
enum CompositionAttachment {
enum CompositionAttachmentData {
case asset(PHAsset)
case image(UIImage)
case video(URL)
case drawing(PKDrawing)
var type: AttachmentType {
switch self {
@ -24,6 +25,8 @@ enum CompositionAttachment {
return .image
case .video(_):
return .video
case .drawing(_):
return .image
}
}
@ -45,7 +48,7 @@ enum CompositionAttachment {
}
}
func getData(completion: @escaping (Data, String) -> Void) {
func getData(completion: @escaping (_ data: Data, _ mimeType: String) -> Void) {
switch self {
case let .image(image):
completion(image.pngData()!, "image/png")
@ -80,7 +83,7 @@ enum CompositionAttachment {
options.version = .current
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
guard let exportSession = exportSession else { fatalError("failed to create export session") }
CompositionAttachment.exportVideoData(session: exportSession, completion: completion)
CompositionAttachmentData.exportVideoData(session: exportSession, completion: completion)
}
} else {
fatalError("assetType must be either image or video")
@ -90,15 +93,11 @@ enum CompositionAttachment {
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
fatalError("failed to create export session")
}
CompositionAttachment.exportVideoData(session: session, completion: completion)
}
}
CompositionAttachmentData.exportVideoData(session: session, completion: completion)
func getData() -> Promise<(Data, String)> {
return Promise { (resolve, reject) in
self.getData { (data, mimeType) in
resolve((data, mimeType))
}
case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(image.pngData()!, "image/png")
}
}
@ -122,7 +121,7 @@ enum CompositionAttachment {
}
extension PHAsset {
var attachmentType: CompositionAttachment.AttachmentType? {
var attachmentType: CompositionAttachmentData.AttachmentType? {
switch self.mediaType {
case .image:
return .image
@ -134,7 +133,7 @@ extension PHAsset {
}
}
extension CompositionAttachment: Codable {
extension CompositionAttachmentData: Codable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
@ -147,6 +146,10 @@ extension CompositionAttachment: Codable {
try container.encode(image.pngData()!, forKey: .imageData)
case .video(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "video CompositionAttachments cannot be encoded"))
case let .drawing(drawing):
try container.encode("drawing", forKey: .type)
let drawingData = drawing.dataRepresentation()
try container.encode(drawingData, forKey: .drawing)
}
}
@ -165,6 +168,10 @@ extension CompositionAttachment: Codable {
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "Could not decode UIImage from image data")
}
self = .image(image)
case "drawing":
let drawingData = try container.decode(Data.self, forKey: .drawing)
let drawing = try PKDrawing(data: drawingData)
self = .drawing(drawing)
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'")
}
@ -175,16 +182,22 @@ extension CompositionAttachment: Codable {
case imageData
/// The local identifier of the PHAsset for this attachment
case assetIdentifier
/// The PKDrawing object for this attachment.
case drawing
}
}
extension CompositionAttachment: Equatable {
static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool {
extension CompositionAttachmentData: Equatable {
static func ==(lhs: CompositionAttachmentData, rhs: CompositionAttachmentData) -> Bool {
switch (lhs, rhs) {
case let (.asset(a), .asset(b)):
return a.localIdentifier == b.localIdentifier
case let (.image(a), .image(b)):
return a == b
case let (.video(a), .video(b)):
return a == b
case let (.drawing(a), .drawing(b)):
return a == b
default:
return false
}

164
Tusker/Models/Draft.swift Normal file
View File

@ -0,0 +1,164 @@
//
// Draft.swift
// Tusker
//
// Created by Shadowfacts on 8/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
class Draft: Codable, ObservableObject {
let id: UUID
var lastModified: Date
@Published var accountID: String
@Published var text: String
@Published var contentWarningEnabled: Bool
@Published var contentWarning: String
@Published var attachments: [CompositionAttachment]
@Published var inReplyToID: String?
@Published var visibility: Status.Visibility
var initialText: String
var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty) ||
attachments.count > 0
}
init(accountID: String) {
self.id = UUID()
self.lastModified = Date()
self.accountID = accountID
self.text = ""
self.contentWarningEnabled = false
self.contentWarning = ""
self.attachments = []
self.inReplyToID = nil
self.visibility = Preferences.shared.defaultPostVisibility
self.initialText = ""
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
self.accountID = try container.decode(String.self, forKey: .accountID)
self.text = try container.decode(String.self, forKey: .text)
if let enabled = try? container.decode(Bool.self, forKey: .contentWarningEnabled) {
self.contentWarningEnabled = enabled
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
} else {
// todo: temporary until migration away from old drafts manager is complete
let cw = try container.decode(String?.self, forKey: .contentWarning)
if let cw = cw {
self.contentWarningEnabled = !cw.isEmpty
self.contentWarning = cw
} else {
self.contentWarningEnabled = false
self.contentWarning = ""
}
}
self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments)
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility)
self.initialText = try container.decode(String.self, forKey: .initialText)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(lastModified, forKey: .lastModified)
try container.encode(accountID, forKey: .accountID)
try container.encode(text, forKey: .text)
try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled)
try container.encode(contentWarning, forKey: .contentWarning)
try container.encode(attachments, forKey: .attachments)
try container.encode(inReplyToID, forKey: .inReplyToID)
try container.encode(visibility, forKey: .visibility)
try container.encode(initialText, forKey: .initialText)
}
}
extension Draft: Equatable {
static func ==(lhs: Draft, rhs: Draft) -> Bool {
return lhs.id == rhs.id
}
}
extension Draft {
enum CodingKeys: String, CodingKey {
case id
case lastModified
case accountID
case text
case contentWarningEnabled
case contentWarning
case attachments
case inReplyToID
case visibility
case initialText
}
}
extension MastodonController {
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft {
var acctsToMention = [String]()
var visibility = Preferences.shared.defaultPostVisibility
var contentWarning = ""
if let inReplyToID = inReplyToID,
let inReplyTo = persistentContainer.status(for: inReplyToID) {
acctsToMention.append(inReplyTo.account.acct)
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
visibility = inReplyTo.visibility
if !inReplyTo.spoilerText.isEmpty {
switch Preferences.shared.contentWarningCopyMode {
case .doNotCopy:
break
case .asIs:
contentWarning = inReplyTo.spoilerText
case .prependRe:
if inReplyTo.spoilerText.lowercased().starts(with: "re:") {
contentWarning = inReplyTo.spoilerText
} else {
contentWarning = "re: \(inReplyTo.spoilerText)"
}
}
}
}
if let mentioningAcct = mentioningAcct {
acctsToMention.append(mentioningAcct)
}
if let ownAccount = self.account {
acctsToMention.removeAll(where: { $0 == ownAccount.acct })
}
acctsToMention = acctsToMention.uniques()
let draft = Draft(accountID: accountInfo!.id)
draft.inReplyToID = inReplyToID
draft.text = acctsToMention.map { "@\($0) " }.joined()
draft.initialText = draft.text
draft.visibility = visibility
draft.contentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty
return draft
}
}

View File

@ -0,0 +1,50 @@
//
// DraftsManager.swift
// Tusker
//
// Created by Shadowfacts on 10/22/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
class DraftsManager: Codable {
private(set) static var shared: DraftsManager = load()
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
static func save() {
DispatchQueue.global(qos: .utility).async {
let encoder = PropertyListEncoder()
let data = try? encoder.encode(shared)
try? data?.write(to: archiveURL, options: .noFileProtection)
}
}
static func load() -> DraftsManager {
let decoder = PropertyListDecoder()
if let data = try? Data(contentsOf: archiveURL),
let draftsManager = try? decoder.decode(DraftsManager.self, from: data) {
return draftsManager
}
return DraftsManager()
}
private init() {}
var drafts: [Draft] = []
var sorted: [Draft] {
return drafts.sorted(by: { $0.lastModified > $1.lastModified })
}
func add(_ draft: Draft) {
drafts.append(draft)
}
func remove(_ draft: Draft) {
drafts.removeAll { $0 == draft }
}
}

View File

@ -38,19 +38,29 @@ class Preferences: Codable, ObservableObject {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
self.showRepliesInProfiles = try container.decode(Bool.self, forKey: .showRepliesInProfiles)
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia)
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
if container.contains(.expandAllContentWarnings) {
self.expandAllContentWarnings = try container.decode(Bool.self, forKey: .expandAllContentWarnings)
}
if container.contains(.collapseLongPosts) {
self.collapseLongPosts = try container.decode(Bool.self, forKey: .collapseLongPosts)
}
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
@ -63,19 +73,25 @@ class Preferences: Codable, ObservableObject {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(theme, forKey: .theme)
try container.encode(showRepliesInProfiles, forKey: .showRepliesInProfiles)
try container.encode(avatarStyle, forKey: .avatarStyle)
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts)
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
try container.encode(mentionReblogger, forKey: .mentionReblogger)
try container.encode(blurAllMedia, forKey: .blurAllMedia)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
try container.encode(openLinksInApps, forKey: .openLinksInApps)
try container.encode(useInAppSafari, forKey: .useInAppSafari)
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
@ -84,46 +100,60 @@ class Preferences: Codable, ObservableObject {
try container.encode(statusContentType, forKey: .statusContentType)
}
// MARK: - Appearance
// MARK: Appearance
@Published var theme = UIUserInterfaceStyle.unspecified
@Published var showRepliesInProfiles = false
@Published var avatarStyle = AvatarStyle.roundRect
@Published var hideCustomEmojiInUsernames = false
@Published var showIsStatusReplyIcon = false
@Published var alwaysShowStatusVisibilityIcon = false
// MARK: - Behavior
// MARK: Composing
@Published var defaultPostVisibility = Status.Visibility.public
@Published var automaticallySaveDrafts = true
@Published var requireAttachmentDescriptions = false
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
@Published var mentionReblogger = false
// MARK: Media
@Published var blurAllMedia = false
@Published var automaticallyPlayGifs = true
// MARK: Behavior
@Published var openLinksInApps = true
@Published var useInAppSafari = true
@Published var inAppSafariAutomaticReaderMode = false
@Published var expandAllContentWarnings = false
@Published var collapseLongPosts = true
// MARK: - Digital Wellness
// MARK: Digital Wellness
@Published var showFavoriteAndReblogCounts = true
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
// MARK: - Advanced
// MARK: Advanced
@Published var silentActions: [String: Permission] = [:]
@Published var statusContentType: StatusContentType = .plain
enum CodingKeys: String, CodingKey {
case theme
case showRepliesInProfiles
case avatarStyle
case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon
case defaultPostVisibility
case automaticallySaveDrafts
case requireAttachmentDescriptions
case contentWarningCopyMode
case mentionReblogger
case blurAllMedia
case automaticallyPlayGifs
case openLinksInApps
case useInAppSafari
case inAppSafariAutomaticReaderMode
case expandAllContentWarnings
case collapseLongPosts
case showFavoriteAndReblogCounts
case defaultNotificationsType

View File

@ -8,6 +8,8 @@
import UIKit
import Pachyderm
import CrashReporter
import MessageUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@ -21,15 +23,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
window = UIWindow(windowScene: windowScene)
if LocalData.shared.onboardingComplete {
if session.mastodonController == nil {
let account = LocalData.shared.getMostRecentAccount()!
session.mastodonController = MastodonController.getForAccount(account)
}
showAppUI()
if let report = AppDelegate.pendingCrashReport {
AppDelegate.pendingCrashReport = nil
handlePendingCrashReport(report, session: session)
} else {
showOnboardingUI()
showAppOrOnboardingUI(session: session)
}
window!.makeKeyAndVisible()
@ -82,6 +80,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
Preferences.save()
DraftsManager.save()
}
func sceneDidBecomeActive(_ scene: UIScene) {
@ -92,6 +93,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call).
Preferences.save()
DraftsManager.save()
}
func sceneWillEnterForeground(_ scene: UIScene) {
@ -104,8 +108,33 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
Preferences.save()
DraftsManager.save()
try! scene.session.mastodonController?.persistentContainer.viewContext.save()
}
private func handlePendingCrashReport(_ report: PLCrashReport, session: UISceneSession) {
#if !DEBUG
guard MFMailComposeViewController.canSendMail() else {
print("Cannot send email")
showAppOrOnboardingUI(session: session)
return
}
window!.rootViewController = CrashReporterViewController.create(report: report)
#endif
}
func showAppOrOnboardingUI(session: UISceneSession? = nil) {
let session = session ?? window!.windowScene!.session
if LocalData.shared.onboardingComplete {
if session.mastodonController == nil {
let account = LocalData.shared.getMostRecentAccount()!
session.mastodonController = MastodonController.getForAccount(account)
}
showAppUI()
} else {
showOnboardingUI()
}
}
func activateAccount(_ account: LocalData.UserAccountInfo) {
@ -128,8 +157,17 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
mastodonController.getOwnAccount()
mastodonController.getOwnInstance()
let tabBarController = MainTabBarViewController(mastodonController: mastodonController)
window!.rootViewController = tabBarController
let rootController: UIViewController
#if SDK_IOS_14
if #available(iOS 14.0, *) {
rootController = MainSplitViewController(mastodonController: mastodonController)
} else {
rootController = MainTabBarViewController(mastodonController: mastodonController)
}
#else
rootController = MainTabBarViewController(mastodonController: mastodonController)
#endif
window!.rootViewController = rootController
}
func showOnboardingUI() {
@ -149,3 +187,11 @@ extension SceneDelegate: OnboardingViewControllerDelegate {
activateAccount(account)
}
}
extension SceneDelegate: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true) {
self.showAppOrOnboardingUI()
}
}
}

View File

@ -32,7 +32,8 @@ class AccountListTableViewController: EnhancedTableViewController {
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.rowHeight = 66
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 66
tableView.alwaysBounceVertical = true

View File

@ -0,0 +1,215 @@
//
// AssetCollectionViewController.swift
// Tusker
//
// Created by Shadowfacts on 1/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Photos
private let reuseIdentifier = "assetCell"
private let cameraReuseIdentifier = "showCameraCell"
protocol AssetCollectionViewControllerDelegate: class {
func shouldSelectAsset(_ asset: PHAsset) -> Bool
func didSelectAssets(_ assets: [PHAsset])
func captureFromCamera()
}
class AssetCollectionViewController: UICollectionViewController {
weak var delegate: AssetCollectionViewControllerDelegate?
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var flowLayout: UICollectionViewFlowLayout {
return collectionViewLayout as! UICollectionViewFlowLayout
}
private var availableWidth: CGFloat!
private var thumbnailSize: CGSize!
private let imageManager = PHCachingImageManager()
private var fetchResult: PHFetchResult<PHAsset>!
var selectedAssets: [PHAsset] {
return collectionView.indexPathsForSelectedItems?.compactMap { (indexPath) in
guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil }
return asset
} ?? []
}
init() {
super.init(collectionViewLayout: UICollectionViewFlowLayout())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
// use the safe area layout guide instead of letting it automatically use the safe area insets
// because otherwise, when presented in a popover with the arrow on the left or right side,
// the collection view content will be cut off by the width of the arrow because the popover
// doesn't respect safe area insets
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor),
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
// top ignores safe area because when presented in the sheet container, it simplifies the top content offset
view.topAnchor.constraint(equalTo: collectionView.topAnchor),
// bottom ignores safe area because we want cells to underflow bottom of the screen on notched iPhones
view.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor),
])
view.backgroundColor = .systemBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))
collectionView.alwaysBounceVertical = true
collectionView.register(UINib(nibName: "AssetCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: reuseIdentifier)
collectionView.register(UINib(nibName: "ShowCameraCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: cameraReuseIdentifier)
let scale = UIScreen.main.scale
let cellSize = flowLayout.itemSize
thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in
switch item {
case .showCamera:
return collectionView.dequeueReusableCell(withReuseIdentifier: cameraReuseIdentifier, for: indexPath)
case let .asset(asset):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! AssetCollectionViewCell
cell.updateUI(asset: asset)
self.imageManager.requestImage(for: asset, targetSize: self.thumbnailSize, contentMode: .aspectFill, options: nil) { (image, _) in
guard let image = image else { return }
DispatchQueue.main.async {
guard cell.assetIdentifier == asset.localIdentifier else { return }
cell.thumbnailImage = image
}
}
return cell
}
})
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
fetchResult = fetchAssets(with: options)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.assets])
var items: [Item] = [.showCamera]
fetchResult.enumerateObjects { (asset, _, _) in
items.append(.asset(asset))
}
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: false)
collectionView.allowsMultipleSelection = true
setEditing(true, animated: false)
updateItemsSelectedCount()
if let singleFingerPanGesture = collectionView.gestureRecognizers?.first(where: {
$0.name == "multi-select.singleFingerPanGesture"
}),
let interactivePopGesture = navigationController?.interactivePopGestureRecognizer {
singleFingerPanGesture.require(toFail: interactivePopGesture)
}
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
let availableWidth = view.bounds.inset(by: view.safeAreaInsets).width
if self.availableWidth != availableWidth {
self.availableWidth = availableWidth
let size = (availableWidth - 8) / 3
flowLayout.itemSize = CGSize(width: size, height: size)
flowLayout.minimumInteritemSpacing = 4
flowLayout.minimumLineSpacing = 4
}
}
open func fetchAssets(with options: PHFetchOptions) -> PHFetchResult<PHAsset> {
return PHAsset.fetchAssets(with: options)
}
func updateItemsSelectedCount() {
let selected = collectionView.indexPathsForSelectedItems?.count ?? 0
navigationItem.title = "\(selected) selected"
}
// MARK: UICollectionViewDelegate
override func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool {
return true
}
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return false }
if let delegate = delegate,
case let .asset(asset) = item {
return delegate.shouldSelectAsset(asset)
}
return true
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .showCamera:
collectionView.deselectItem(at: indexPath, animated: false)
delegate?.captureFromCamera()
case .asset(_):
updateItemsSelectedCount()
}
}
override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
updateItemsSelectedCount()
}
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil }
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in
return AssetPreviewViewController(asset: asset)
}, actionProvider: nil)
}
override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?,
let cell = collectionView.cellForItem(at: indexPath) as? AssetCollectionViewCell {
let parameters = UIPreviewParameters()
parameters.backgroundColor = .black
return UITargetedPreview(view: cell.imageView, parameters: parameters)
} else {
return nil
}
}
// MARK: - Interaction
@objc func donePressed() {
delegate?.didSelectAssets(selectedAssets)
dismiss(animated: true)
}
}
extension AssetCollectionViewController {
enum Section: Hashable {
case assets
}
enum Item: Hashable {
case showCamera
case asset(PHAsset)
}
}

View File

@ -33,6 +33,8 @@ class AssetPickerSheetContainerViewController: SheetContainerViewController {
override func viewDidLoad() {
assetPicker.view.layer.cornerRadius = view.bounds.width * 0.02
// don't round bottom corners, since they'll always be cut off by the device
assetPicker.view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
super.viewDidLoad()
}
@ -40,13 +42,6 @@ class AssetPickerSheetContainerViewController: SheetContainerViewController {
}
extension AssetPickerSheetContainerViewController: SheetContainerViewControllerDelegate {
func sheetContainer(_ sheetContainer: SheetContainerViewController, willSnapToDetent detent: Detent) -> Bool {
if detent == .bottom {
dismiss(animated: true)
return false
}
return true
}
func sheetContainerContentScrollView(_ sheetContainer: SheetContainerViewController) -> UIScrollView? {
if let vc = assetPicker.visibleViewController as? UITableViewController {
return vc.tableView

View File

@ -9,16 +9,16 @@
import UIKit
import Photos
protocol AssetPickerViewControllerDelegate {
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachment.AttachmentType) -> Bool
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachment])
protocol AssetPickerViewControllerDelegate: class {
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData])
}
class AssetPickerViewController: UINavigationController {
var assetPickerDelegate: AssetPickerViewControllerDelegate?
weak var assetPickerDelegate: AssetPickerViewControllerDelegate?
var currentCollectionSelectedAssets: [CompositionAttachment] {
var currentCollectionSelectedAssets: [CompositionAttachmentData] {
if let vc = visibleViewController as? AssetCollectionViewController {
return vc.selectedAssets.map { .asset($0) }
} else {
@ -70,7 +70,7 @@ extension AssetPickerViewController: AssetCollectionViewControllerDelegate {
extension AssetPickerViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
let attachment: CompositionAttachment
let attachment: CompositionAttachmentData
if let image = info[.originalImage] as? UIImage {
attachment = .image(image)
} else if let url = info[.mediaURL] as? URL {

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