Compare commits

...

37 Commits

Author SHA1 Message Date
Shadowfacts bce0f8ef18 Bump build number and update changelog 2022-10-09 14:46:48 -04:00
Shadowfacts d661870401 Include log data in issue/crash reports 2022-10-09 14:26:44 -04:00
Shadowfacts afa1a733f4 Remove old XCB docs 2022-10-09 13:53:14 -04:00
Shadowfacts 1b186725ce Re-add timeline context menus 2022-10-08 23:47:42 -04:00
Shadowfacts 164a8e26c4 Fix not being able to press attachments in new status cells 2022-10-08 19:10:21 -04:00
Shadowfacts cadcc1a92a Don't navigate to profile when tapping name stack in timeline status
Otherwise it's too difficult to open short posts
2022-10-08 16:53:48 -04:00
Shadowfacts bcb3c24027 Fix context menu presentation animation getting clipped in new status cells 2022-10-08 16:53:48 -04:00
Shadowfacts fd6a4ba41c Fix update timestamp work item firing too frequently
A reconfiguration would schedule a new work item without cancelling the
old one, resulting in the timestamp updating multiple times in quick
succession (noticeable for statuses <60s old).
2022-10-08 16:53:48 -04:00
Shadowfacts 3ab82b2dbb Fix attachments/cards flickering in new cells on reconfiguration 2022-10-08 16:53:48 -04:00
Shadowfacts 1ed218d5e3 Fix new status cells not showing meta indicators or reblog button visibility 2022-10-08 16:53:48 -04:00
Shadowfacts 0fee770411 Fix crash when displaying new status cells with polls 2022-10-08 15:12:17 -04:00
Shadowfacts 5b116c0d4e More logging! 2022-10-08 15:12:10 -04:00
Shadowfacts b7a4f7e30f Make tapping content warning label toggle expand/collapse 2022-10-08 15:03:50 -04:00
Shadowfacts ba1300b1b7 Re-add status cell dragging 2022-10-08 15:01:23 -04:00
Shadowfacts 817ef0c2cc New timeline key commands 2022-10-08 14:53:21 -04:00
Shadowfacts 18ee621489 Status cell swipe actions 2022-10-08 14:33:07 -04:00
Shadowfacts ddf5094acf Only show collapse button on collapsible statuses 2022-10-08 13:21:01 -04:00
Shadowfacts 133921848d Extract favoriting/reblogging to separate services
Allows displaying error popups and retrying
2022-10-08 13:19:32 -04:00
Shadowfacts 46db70d58b Fix building in release mode
When handleEvent dispatches to the other methods, it crashes the compiler
during an optimization pass. Seems to be related to:
https://github.com/apple/swift/issues/61350
2022-10-08 11:45:02 -04:00
Shadowfacts 21958eb77f Merge branch 'develop' into collection-timelines 2022-10-08 11:01:19 -04:00
Shadowfacts b30f149dc9 Use mutex on iOS 15 instead of os_unfair_lock
See #178
2022-10-08 10:57:59 -04:00
Shadowfacts 9b83566482 Fix TuskerTests not compiling 2022-10-08 10:55:55 -04:00
Shadowfacts b688631937 Update status cells on status changes 2022-10-06 22:36:55 -04:00
Shadowfacts 4d654358d7 Extract a bunch of common stuff to StatusCollectionViewCell protocol 2022-10-05 23:19:30 -04:00
Shadowfacts 24e90de672 Status cell interaction 2022-10-05 22:28:10 -04:00
Shadowfacts 780e8b09b7 Status cell UI 2022-10-05 21:39:58 -04:00
Shadowfacts 2196663d94 Make StatusContentContainer play nice with hiding subviews 2022-10-04 22:48:42 -04:00
Shadowfacts 7085ac01cb Timeline status collection view cell collapsing 2022-10-04 00:02:41 -04:00
Shadowfacts 81671d73c7 Start converting timeline status to UICollectionViewCell 2022-10-04 00:01:16 -04:00
Shadowfacts a38c89a17f Re-add public timeline descriptions 2022-10-01 15:32:06 -04:00
Shadowfacts 253fb8d27d Extract more things to TimelineLikeCollectionViewController 2022-10-01 15:08:51 -04:00
Shadowfacts a682c8f5cc Extract a bunch of timeline view controller stuff to separate protocol 2022-09-24 11:39:12 -04:00
Shadowfacts d18a4b3c42 Fixing loadInitial happening multiple times 2022-09-24 11:31:52 -04:00
Shadowfacts 426b31d46c Initial TimelineLikeController + TimelineViewController implementation 2022-09-24 10:49:06 -04:00
Shadowfacts 5c09b1910f Cleanup/reorganize some things 2022-09-19 22:52:52 -04:00
Shadowfacts fe72d8faec Remove x-callback-url support
Closes #1
2022-09-19 22:44:27 -04:00
Shadowfacts b560bcd8dc Prevent loading indicator from potentially being added multiple times
Not sure how this could happen, but it's caused 1 crash in the wild so w/e
2022-09-19 22:35:27 -04:00
49 changed files with 2754 additions and 1336 deletions

View File

@ -1,5 +1,22 @@
# Changelog
## 2022.1 (37)
This is the first build with the rewritten/rearchitected timeline screen. In future builds, this will roll out to the notifications and profile screens as well, but for now it's only used in the home tab. If you encounter crashes or errors, please report them. If you see a blue error bubble pop up, you can long-press it to send an error report.
Features/Improvements:
- Display error messages when favoriting/reblogging fails
Bugfixes:
- iOS 15: (hopefully) fix lock-related crash
- Fix crash when loading indicator is shown multiple times
Known Issues:
- Videos played from the timeline do not enter picture-in-picture mode when backgrounding the app
- Status expand/collapse animations on other screens do not match timelines
Other:
- X-Callback-URL support has been removed
## 2022.1 (36)
This build is a hotfix for a crash when refreshing on Pixelfed.

View File

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

View File

@ -420,6 +420,10 @@ extension Client {
public let requestEndpoint: Endpoint
public let type: ErrorType
#if DEBUG
public static let debug = Error(request: Client.getStatuses(timeline: .home), type: .invalidResponse)
#endif
init<ResultType: Decodable>(request: Request<ResultType>, type: ErrorType) {
self.requestMethod = request.method
self.requestEndpoint = request.endpoint

View File

@ -33,12 +33,19 @@
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; };
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */; };
D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; };
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; };
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */; };
D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757724EE133700B82A16 /* ComposeAssetPicker.swift */; };
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */; };
@ -98,7 +105,6 @@
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */; };
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6420AEC26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift */; };
D6420AEF26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6420AED26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib */; };
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6434EB2215B1856001A919A /* XCBRequest.swift */; };
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */; };
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; };
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; };
@ -115,7 +121,6 @@
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.swift */; };
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */; };
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */; };
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
@ -142,17 +147,14 @@
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */; };
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; };
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; };
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; };
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; };
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* Draft.swift */; };
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; };
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; };
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; };
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */; };
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; };
@ -168,6 +170,7 @@
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; };
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DE828D962C2006341DA /* TimelineLikeController.swift */; };
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
@ -214,8 +217,11 @@
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F613023AE99E000F3CFD3 /* Ambassador.framework */; };
D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F612D23AE990C00F3CFD3 /* Embassy.framework */; };
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; };
D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */; };
D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */; };
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */; };
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */; };
D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */; };
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */; };
@ -380,12 +386,18 @@
D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = "<group>"; };
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = "<group>"; };
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = "<group>"; };
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = "<group>"; };
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = "<group>"; };
D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = "<group>"; };
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = "<group>"; };
D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = "<group>"; };
@ -445,7 +457,6 @@
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = "<group>"; };
D6420AEC26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionTableViewCell.swift; sourceTree = "<group>"; };
D6420AED26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PublicTimelineDescriptionTableViewCell.xib; sourceTree = "<group>"; };
D6434EB2215B1856001A919A /* XCBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequest.swift; sourceTree = "<group>"; };
D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = "<group>"; };
D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; };
D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; };
@ -462,7 +473,6 @@
D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D64F80E1215875CC00BEF393 /* XCBActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActionType.swift; sourceTree = "<group>"; };
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
@ -491,17 +501,14 @@
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = "<group>"; };
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; };
D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = "<group>"; };
D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.swift; sourceTree = "<group>"; };
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = "<group>"; };
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = "<group>"; };
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = "<group>"; };
D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = "<group>"; };
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherIndicatorView.swift; sourceTree = "<group>"; };
D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; };
@ -517,6 +524,7 @@
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
D6895DE828D962C2006341DA /* TimelineLikeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeController.swift; sourceTree = "<group>"; };
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
@ -562,6 +570,11 @@
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentContainer.swift; sourceTree = "<group>"; };
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Configure.swift"; sourceTree = "<group>"; };
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedImageView.swift; sourceTree = "<group>"; };
D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = "<group>"; };
D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMesasgeActivity.swift; sourceTree = "<group>"; };
D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = "<group>"; };
@ -685,8 +698,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */,
D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */,
D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -894,10 +906,12 @@
D641C781213DD7DD004B4513 /* Timeline */ = {
isa = PBXGroup;
children = (
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
);
path = Timeline;
sourceTree = "<group>";
@ -1020,6 +1034,9 @@
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */,
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */,
D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */,
D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */,
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */,
D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */,
);
path = Status;
sourceTree = "<group>";
@ -1121,23 +1138,11 @@
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */,
D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */,
D62E9984279CA23900C26176 /* URLSession+Development.swift */,
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
D6757A7A2157E00100721E32 /* XCallbackURL */ = {
isa = PBXGroup;
children = (
D6757A7B2157E01900721E32 /* XCBManager.swift */,
D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */,
D6434EB2215B1856001A919A /* XCBRequest.swift */,
D6757A812157E8FA00721E32 /* XCBSession.swift */,
D64F80E1215875CC00BEF393 /* XCBActionType.swift */,
D679C09E215850EF00DA27FE /* XCBActions.swift */,
);
path = XCallbackURL;
sourceTree = "<group>";
};
D67B506B250B28FF00FAECFB /* Vendor */ = {
isa = PBXGroup;
children = (
@ -1257,6 +1262,7 @@
children = (
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
@ -1266,6 +1272,7 @@
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
D620483323D3801D008A63EF /* LinkTextView.swift */,
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
@ -1313,6 +1320,7 @@
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
@ -1369,16 +1377,17 @@
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D62E9988279DB2D100C26176 /* InstanceFeatures.swift */,
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
D6AEBB3F2321640F00E5038B /* Activities */,
D6F953F121251A2F00CF0F2B /* API */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6F953F121251A2F00CF0F2B /* Controllers */,
D6370B9924421FE00092A7FF /* CoreData */,
D667E5F62135C2ED0057A976 /* Extensions */,
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
@ -1386,11 +1395,9 @@
D61959D2241E846D00A37B8E /* Models */,
D663626021360A9600C9CBA2 /* Preferences */,
D641C780213DD7C4004B4513 /* Screens */,
D6E9CDA6281A426700BBC98E /* Services */,
D62D241E217AA46B005076CC /* Shortcuts */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6BED1722126661300F02DA0 /* Views */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
);
path = Tusker;
sourceTree = "<group>";
@ -1426,6 +1433,7 @@
children = (
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */,
D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */,
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */,
);
path = "Confirm Load More Cell";
sourceTree = "<group>";
@ -1443,14 +1451,6 @@
path = OpenInTusker;
sourceTree = "<group>";
};
D6E9CDA6281A426700BBC98E /* Services */ = {
isa = PBXGroup;
children = (
D6E9CDA7281A427800BBC98E /* PostService.swift */,
);
path = Services;
sourceTree = "<group>";
};
D6F1F84E2193B9BE00F5FE67 /* Caching */ = {
isa = PBXGroup;
children = (
@ -1473,13 +1473,16 @@
path = "Crash Reporter";
sourceTree = "<group>";
};
D6F953F121251A2F00CF0F2B /* Controllers */ = {
D6F953F121251A2F00CF0F2B /* API */ = {
isa = PBXGroup;
children = (
D62E9988279DB2D100C26176 /* InstanceFeatures.swift */,
D6F953EF21251A2900CF0F2B /* MastodonController.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */,
D6E9CDA7281A427800BBC98E /* PostService.swift */,
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
);
path = Controllers;
path = API;
sourceTree = "<group>";
};
/* End PBXGroup section */
@ -1547,6 +1550,7 @@
);
name = TuskerUITests;
packageProductDependencies = (
D61ABEFB28F105DE00B29151 /* Pachyderm */,
);
productName = TuskerUITests;
productReference = D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */;
@ -1751,7 +1755,6 @@
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */,
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
D6DEA0DE268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
@ -1759,10 +1762,12 @@
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */,
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */,
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */,
D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */,
D62E9989279DB2D100C26176 /* InstanceFeatures.swift in Sources */,
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
@ -1825,6 +1830,7 @@
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
@ -1839,17 +1845,18 @@
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */,
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */,
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
@ -1873,6 +1880,7 @@
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
@ -1890,11 +1898,11 @@
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */,
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */,
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */,
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
@ -1906,8 +1914,10 @@
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */,
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */,
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
@ -1919,12 +1929,13 @@
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
@ -1938,10 +1949,12 @@
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */,
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */,
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */,
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */,
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
@ -1958,7 +1971,6 @@
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
@ -1972,7 +1984,6 @@
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
@ -2202,7 +2213,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36;
CURRENT_PROJECT_VERSION = 37;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2213,7 +2224,6 @@
MARKETING_VERSION = 2022.1;
OTHER_CODE_SIGN_FLAGS = "";
OTHER_LDFLAGS = "";
"OTHER_SWIFT_FLAGS[sdk=iphone*14*]" = "$(inherited) -D SDK_IOS_14";
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -2232,7 +2242,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36;
CURRENT_PROJECT_VERSION = 37;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2242,7 +2252,6 @@
);
MARKETING_VERSION = 2022.1;
OTHER_CODE_SIGN_FLAGS = "";
"OTHER_SWIFT_FLAGS[sdk=iphone*14*]" = "$(inherited) -D SDK_IOS_14";
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -2258,14 +2267,15 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = HGYVAQA9FW;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = TuskerTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.TuskerTests;
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.TuskerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -2279,14 +2289,15 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = HGYVAQA9FW;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = TuskerTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.TuskerTests;
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.TuskerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -2299,14 +2310,14 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = HGYVAQA9FW;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = TuskerUITests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.TuskerUITests;
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.TuskerUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -2319,14 +2330,14 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = HGYVAQA9FW;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = TuskerUITests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.TuskerUITests;
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.TuskerUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -2341,7 +2352,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36;
CURRENT_PROJECT_VERSION = 37;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2368,7 +2379,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36;
CURRENT_PROJECT_VERSION = 37;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2479,6 +2490,10 @@
package = D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
D61ABEFB28F105DE00B29151 /* Pachyderm */ = {
isa = XCSwiftPackageProductDependency;
productName = Pachyderm;
};
D6552366289870790048A653 /* ScreenCorners */ = {
isa = XCSwiftPackageProductDependency;
package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */;

View File

@ -0,0 +1,58 @@
//
// FavoriteService.swift
// Tusker
//
// Created by Shadowfacts on 10/8/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class FavoriteService {
private let mastodonController: MastodonController
private let presenter: any TuskerNavigationDelegate
private let status: StatusMO
var hapticFeedback = true
init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) {
self.status = status
self.mastodonController = mastodonController
self.presenter = presenter
}
func toggleFavorite() async {
let oldValue = status.favourited
status.favourited.toggle()
mastodonController.persistentContainer.statusSubject.send(status.id)
if hapticFeedback {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
let request = (status.favourited ? Status.favourite : Status.unfavourite)(status.id)
do {
let (newStatus, _) = try await mastodonController.run(request)
mastodonController.persistentContainer.addOrUpdate(status: newStatus)
} catch {
status.favourited = oldValue
mastodonController.persistentContainer.statusSubject.send(status.id)
let title = oldValue ? "Error Unfavoriting" : "Error Favoriting"
let config = ToastConfiguration(from: error, with: title, in: presenter) { toast in
// deliberately retain a strong reference to self
toast.dismissToast(animated: true)
await self.toggleFavorite()
}
presenter.showToast(configuration: config, animated: true)
if hapticFeedback {
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}
}
}

View File

@ -30,7 +30,7 @@ struct InstanceFeatures {
}
var boostToOriginalAudience: Bool {
instanceType == .pleroma
instanceType == .pleroma || instanceType == .mastodon
}
var profilePinnedStatuses: Bool {

View File

@ -0,0 +1,110 @@
//
// ReblogService.swift
// Tusker
//
// Created by Shadowfacts on 10/8/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class ReblogService {
private let mastodonController: MastodonController
private let presenter: any TuskerNavigationDelegate
private let status: StatusMO
var hapticFeedback = true
var visibility: Status.Visibility? = nil
var requireConfirmation = Preferences.shared.confirmBeforeReblog
init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) {
self.status = status
self.mastodonController = mastodonController
self.presenter = presenter
}
func toggleReblog() async {
if !status.reblogged,
requireConfirmation {
presentConfirmationAlert()
} else {
await doToggleReblog()
}
}
private func presentConfirmationAlert() {
let image: UIImage?
let reblogVisibilityActions: [CustomAlertController.MenuAction]?
if mastodonController.instanceFeatures.reblogVisibility {
image = UIImage(systemName: Status.Visibility.public.unfilledImageName)
reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in
CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) {
// deliberately retain a strong reference to self
Task {
await self.doToggleReblog()
}
}
}
} else {
image = nil
reblogVisibilityActions = []
}
let preview = ConfirmReblogStatusPreviewView(status: status)
var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [
CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil),
CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: {
// deliberately retain a strong reference to self
Task {
await self.doToggleReblog()
}
})
])
if let reblogVisibilityActions {
var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil)
menuAction.isSecondaryMenu = true
config.actions.append(menuAction)
}
let alert = CustomAlertController(config: config)
presenter.present(alert, animated: true)
}
private func doToggleReblog() async {
let oldValue = status.reblogged
status.reblogged.toggle()
mastodonController.persistentContainer.statusSubject.send(status.id)
if hapticFeedback {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
let request: Request<Status>
if status.reblogged {
request = Status.reblog(status.id, visibility: visibility)
} else {
request = Status.unreblog(status.id)
}
do {
let (newStatus, _) = try await mastodonController.run(request)
mastodonController.persistentContainer.addOrUpdate(status: newStatus)
} catch {
status.favourited = oldValue
mastodonController.persistentContainer.statusSubject.send(status.id)
let title = oldValue ? "Error Unfavoriting" : "Error Favoriting"
let config = ToastConfiguration(from: error, with: title, in: presenter) { toast in
toast.dismissToast(animated: true)
await self.toggleReblog()
}
presenter.showToast(configuration: config, animated: true)
if hapticFeedback {
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}
}
}

View File

@ -0,0 +1,22 @@
//
// UIView+Configure.swift
// Tusker
//
// Created by Shadowfacts on 10/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
protocol Configurable {
associatedtype T = Self
func configure(_ closure: (T) -> Void) -> T
}
extension Configurable where Self: UIView {
func configure(_ closure: (Self) -> Void) -> Self {
closure(self)
return self
}
}
extension UIView: Configurable {
}

View File

@ -2,6 +2,22 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>OSLogPreferences</key>
<dict>
<key>$(PRODUCT_BUNDLE_IDENTIFIER)</key>
<dict>
<key>DEFAULT-OPTIONS</key>
<dict>
<key>Level</key>
<dict>
<key>Persist</key>
<string>Debug</string>
<key>Enable</key>
<string>Debug</string>
</dict>
</dict>
</dict>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
@ -16,19 +32,6 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>net.shadowfacts.Tusker</string>
<key>CFBundleURLSchemes</key>
<array>
<string>tusker</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
@ -56,7 +59,7 @@
<key>NSMicrophoneUsageDescription</key>
<string>Post videos from the camera.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos directly from other people's posts.</string>
<string>Save photos directly from other people&apos;s posts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Post photos from the photo library.</string>
<key>NSUserActivityTypes</key>

View File

@ -59,9 +59,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
let url = URLContexts.first!.url
if url.host == "x-callback-url" {
_ = XCBManager.handle(url: url)
} else if var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let rootViewController = rootViewController {
components.scheme = "https"
let query = components.string!

View File

@ -95,17 +95,3 @@ struct MenuController {
}
}
extension MenuController {
class SidebarItem: NSObject, NSCopying {
let item: MainSidebarViewController.Item
init(item: MainSidebarViewController.Item) {
self.item = item
}
func copy(with zone: NSZone? = nil) -> Any {
return SidebarItem(item: self.item)
}
}
}

View File

@ -19,7 +19,7 @@ class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
if #available(iOS 16.0, *) {
self.lock = OSAllocatedUnfairLock(initialState: [:])
} else {
self.lock = UnfairLock(initialState: [:])
self.lock = MutexLock(initialState: [:])
}
}
@ -65,21 +65,41 @@ fileprivate protocol Lock<State> {
extension OSAllocatedUnfairLock: Lock {
}
// from http://www.russbishop.net/the-law
fileprivate class UnfairLock<State>: Lock {
private var lock: UnsafeMutablePointer<os_unfair_lock>
// something is wrong with the UnfairLock impl and it results in segv_accerrs
fileprivate class MutexLock<State>: Lock {
private var state: State
private var lock = NSLock()
init(initialState: State) {
self.state = initialState
self.lock = .allocate(capacity: 1)
self.lock.initialize(to: os_unfair_lock())
}
deinit {
self.lock.deallocate()
}
func withLock<R>(_ body: (inout State) throws -> R) rethrows -> R where R: Sendable {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
func withLock<R>(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R : Sendable {
if !lock.lock(before: Date(timeIntervalSinceNow: 1)) {
// if we can't acquire the lock after 1 second, something has gone catastrophically wrong
fatalError()
}
defer { lock.unlock() }
return try body(&state)
}
}
//// from http://www.russbishop.net/the-law
//fileprivate class UnfairLock<State>: Lock {
// private var lock: UnsafeMutablePointer<os_unfair_lock>
// private var state: State
// init(initialState: State) {
// self.state = initialState
// self.lock = .allocate(capacity: 1)
// self.lock.initialize(to: os_unfair_lock())
// }
// deinit {
// self.lock.deinitialize(count: 1)
// self.lock.deallocate()
// }
// func withLock<R>(_ body: (inout State) throws -> R) rethrows -> R where R: Sendable {
// os_unfair_lock_lock(lock)
// defer { os_unfair_lock_unlock(lock) }
// return try body(&state)
// }
//}

View File

@ -9,6 +9,7 @@
import UIKit
import CrashReporter
import MessageUI
import OSLog
class IssueReporterViewController: UIViewController {
@ -34,6 +35,8 @@ class IssueReporterViewController: UIViewController {
"Tusker Error Report"
}
private let logDataTask: Task<Data?, Never>
@IBOutlet weak var crashReportTextView: UITextView!
@IBOutlet weak var sendReportButton: UIButton!
@ -41,6 +44,15 @@ class IssueReporterViewController: UIViewController {
self.reportText = reportText
self.reportFilename = reportFilename
self.dismiss = dismiss
self.logDataTask = Task(priority: .userInitiated) {
return await withCheckedContinuation({ continuation in
DispatchQueue.global().async {
continuation.resume(returning: getLogData())
}
})
}
super.init(nibName: "IssueReporterViewController", bundle: .main)
}
@ -107,15 +119,22 @@ class IssueReporterViewController: UIViewController {
@IBAction func sendReportTouchUpInside(_ sender: Any) {
updateSendReportButtonColor(lightened: false, animate: true)
let composeVC = MFMailComposeViewController()
composeVC.mailComposeDelegate = self
composeVC.setToRecipients(["me@shadowfacts.net"])
composeVC.setSubject(subject)
let data = reportText.data(using: .utf8)!
composeVC.addAttachmentData(data, mimeType: "text/plain", fileName: reportFilename)
self.present(composeVC, animated: true)
Task {
let composeVC = MFMailComposeViewController()
composeVC.mailComposeDelegate = self
composeVC.setToRecipients(["me@shadowfacts.net"])
composeVC.setSubject(subject)
let data = reportText.data(using: .utf8)!
composeVC.addAttachmentData(data, mimeType: "text/plain", fileName: reportFilename)
if let logData = await logDataTask.value {
let timestamp = ISO8601DateFormatter().string(from: Date())
composeVC.addAttachmentData(logData, mimeType: "text/plain", fileName: "Tusker-\(timestamp).log")
}
self.present(composeVC, animated: true)
}
}
@objc func sendReportButtonLongPressed() {
@ -139,3 +158,29 @@ extension IssueReporterViewController: MFMailComposeViewControllerDelegate {
}
}
}
fileprivate func getLogData() -> Data? {
do {
let store = try OSLogStore(scope: .currentProcessIdentifier)
// past hour
let position = store.position(date: Date().addingTimeInterval(-60 * 60))
let entries = try store.getEntries(at: position, matching: NSPredicate(format: "subsystem = %@", Bundle.main.bundleIdentifier!))
var data = Data()
for entry in entries {
guard let entry = entry as? OSLogEntryLog else {
continue
}
data.append(contentsOf: entry.date.formatted(.iso8601).utf8)
data.append(32) // ' '
data.append(91) // '['
data.append(contentsOf: entry.category.utf8)
data.append(93) // ']'
data.append(32) // ' '
data.append(contentsOf: entry.composedMessage.utf8)
data.append(10) // '\n'
}
return data
} catch {
return nil
}
}

View File

@ -0,0 +1,62 @@
//
// PublicTimelineDescriptionCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/1/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class PublicTimelineDescriptionCollectionViewCell: UICollectionViewCell {
weak var mastodonController: MastodonController!
var local = false {
didSet {
updateLabel()
}
}
var didDismiss: (() -> Void)?
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .tintColor
label.font = .preferredFont(forTextStyle: .body)
label.numberOfLines = 0
label.textColor = .white
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.leadingAnchor, multiplier: 1),
contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: label.trailingAnchor, multiplier: 1),
label.topAnchor.constraint(equalToSystemSpacingBelow: contentView.topAnchor, multiplier: 1),
contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: label.bottomAnchor, multiplier: 1),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateLabel() {
let str = NSMutableAttributedString()
let instanceStr = NSAttributedString(string: mastodonController.instanceURL.host!, attributes: [
.font: UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
])
if local {
str.append(NSAttributedString(string: "The local timeline shows public posts from only "))
str.append(instanceStr)
str.append(NSAttributedString(string: "."))
} else {
str.append(NSAttributedString(string: "The federated timeline shows public posts from all users that "))
str.append(instanceStr)
str.append(NSAttributedString(string: " knows about."))
}
label.attributedText = str
}
}

View File

@ -0,0 +1,375 @@
//
// TimelineViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/20/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
// TODO: gonna need a thing to replicate all of EnhancedTableViewController
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, RefreshableViewController {
let timeline: Timeline
weak var mastodonController: MastodonController!
private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
private var newer: RequestRange?
private var older: RequestRange?
// stored separately because i don't want to query the snapshot every time the user scrolls
private var isShowingTimelineDescription = false
var collectionView: UICollectionView {
view as! UICollectionView
}
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
self.controller = TimelineLikeController(delegate: self)
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
}
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
return sectionSeparatorConfiguration
}
var config = sectionSeparatorConfiguration
if item.hideSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
}
if case .status(_, _) = item {
config.topSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
config.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
}
return config
}
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
registerTimelineLikeCells()
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
dataSource = createDataSource()
applyInitialSnapshot()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
}
override func viewDidLoad() {
super.viewDidLoad()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
guard case .status(id: let id, state: let state) = item,
let status = mastodonController.persistentContainer.status(for: id) else {
fatalError()
}
cell.mastodonController = mastodonController
cell.delegate = self
cell.updateUI(statusID: id, state: state)
}
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
guard case .public(let local) = timeline else {
fatalError()
}
cell.mastodonController = self.mastodonController
cell.local = local
cell.didDismiss = { [unowned self] in
self.removeTimelineDescriptionCell()
}
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .status(_, _):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: itemIdentifier)
case .loadingIndicator:
return loadingIndicatorCell(for: indexPath)
case .confirmLoadMore:
return confirmLoadMoreCell(for: indexPath)
case .publicTimelineDescription:
self.isShowingTimelineDescription = true
return collectionView.dequeueConfiguredReusableCell(using: timelineDescriptionCell, for: indexPath, item: itemIdentifier)
}
}
}
private func applyInitialSnapshot() {
if case .public(let local) = timeline,
(local && !Preferences.shared.hasShownLocalTimelineDescription) ||
(!local && Preferences.shared.hasShownFederatedTimelineDescription) {
var snapshot = dataSource.snapshot()
snapshot.appendSections([.header])
snapshot.appendItems([.publicTimelineDescription], toSection: .header)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
collectionView.indexPathsForSelectedItems?.forEach {
collectionView.deselectItem(at: $0, animated: true)
}
Task {
await controller.loadInitial()
}
}
private func removeTimelineDescriptionCell() {
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.header])
dataSource.apply(snapshot, animatingDifferences: true)
isShowingTimelineDescription = false
}
@objc func refresh() {
Task {
await controller.loadNewer()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
}
}
}
extension TimelineViewController {
enum Section: TimelineLikeCollectionViewSection {
case header
case statuses
case footer
static var entries: Self { .statuses }
}
enum Item: TimelineLikeCollectionViewItem {
typealias TimelineItem = String // status ID
case status(id: String, state: StatusState)
case loadingIndicator
case confirmLoadMore
case publicTimelineDescription
static func fromTimelineItem(_ id: String) -> Self {
return .status(id: id, state: .unknown)
}
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.status(id: a, state: _), .status(id: b, state: _)):
return a == b
case (.loadingIndicator, .loadingIndicator):
return true
case (.confirmLoadMore, .confirmLoadMore):
return true
case (.publicTimelineDescription, .publicTimelineDescription):
return true
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .status(id: let id, state: _):
hasher.combine(0)
hasher.combine(id)
case .loadingIndicator:
hasher.combine(1)
case .confirmLoadMore:
hasher.combine(2)
case .publicTimelineDescription:
hasher.combine(3)
}
}
var hideSeparators: Bool {
switch self {
case .loadingIndicator, .publicTimelineDescription:
return true
default:
return false
}
}
var isSelectable: Bool {
switch self {
case .publicTimelineDescription, .status(id: _, state: _):
return true
default:
return false
}
}
}
}
// MARK: TimelineLikeControllerDelegate
extension TimelineViewController {
typealias TimelineItem = String // status ID
func loadInitial() async throws -> [TimelineItem] {
guard let mastodonController else {
throw Error.noClient
}
let request = Client.getStatuses(timeline: timeline)
let (statuses, _) = try await mastodonController.run(request)
if !statuses.isEmpty {
newer = .after(id: statuses.first!.id, count: nil)
older = .before(id: statuses.last!.id, count: nil)
}
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id))
}
}
}
func loadNewer() async throws -> [TimelineItem] {
guard let newer else {
throw Error.noNewer
}
let request = Client.getStatuses(timeline: timeline, range: newer)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
throw Error.allCaughtUp
}
self.newer = .after(id: statuses.first!.id, count: nil)
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id))
}
}
}
func loadOlder() async throws -> [TimelineItem] {
guard let older else {
throw Error.noOlder
}
let request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request)
if !statuses.isEmpty {
self.older = .before(id: statuses.last!.id, count: nil)
}
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id))
}
}
}
enum Error: TimelineLikeCollectionViewError {
case noClient
case noNewer
case noOlder
case allCaughtUp
}
}
extension TimelineViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section),
case .status(_, _) = dataSource.itemIdentifier(for: indexPath) else {
return
}
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
if indexPath.row == itemsInSection - 1 {
Task {
await controller.loadOlder()
}
}
}
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return
}
switch item {
case .publicTimelineDescription:
removeTimelineDescriptionCell()
case .status(id: let id, state: let state):
selected(status: id, state: state.copy())
case .loadingIndicator, .confirmLoadMore:
fatalError("unreachable")
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
if isShowingTimelineDescription {
removeTimelineDescriptionCell()
}
}
}
extension TimelineViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
(collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? []
}
}
extension TimelineViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension TimelineViewController: MenuActionProvider {
}
extension TimelineViewController: StatusCollectionViewCellDelegate {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
}

View File

@ -19,13 +19,13 @@ class TimelinesPageViewController: SegmentedPageViewController {
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
let home = TimelineTableViewController(for: .home, mastodonController: mastodonController)
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
home.title = homeTitle
let federated = TimelineTableViewController(for: .public(local: false), mastodonController: mastodonController)
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
federated.title = federatedTitle
let local = TimelineTableViewController(for: .public(local: true), mastodonController: mastodonController)
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
local.title = localTitle
super.init(titles: [

View File

@ -25,6 +25,7 @@ class DiffableTimelineLikeTableViewController<Section: DiffableTimelineLikeSecti
private(set) var state = State.unloaded
private var lastLastVisibleRow: IndexPath?
private var currentLoadingIndicatorWorkItem: DispatchWorkItem?
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
@ -113,13 +114,24 @@ class DiffableTimelineLikeTableViewController<Section: DiffableTimelineLikeSecti
}
private func showLoadingIndicatorDelayed() -> DispatchWorkItem {
currentLoadingIndicatorWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
var snapshot = self.dataSource.snapshot()
snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator])
self.dataSource.apply(snapshot, animatingDifferences: false)
var changed = false
if !snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.appendSections([.loadingIndicator])
changed = true
}
if changed || !snapshot.itemIdentifiers(inSection: .loadingIndicator).contains(.loadingIndicator) {
snapshot.appendItems([.loadingIndicator], toSection: .loadingIndicator)
changed = true
}
if changed {
self.dataSource.apply(snapshot, animatingDifferences: false)
}
}
currentLoadingIndicatorWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: workItem)
return workItem
}

View File

@ -409,6 +409,21 @@ extension MenuActionProvider {
}
struct MenuPreviewHelper {
static func willPerformPreviewAction(animator: UIContextMenuInteractionCommitAnimating, presenter: UIViewController) {
if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop
animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting {
customPresenting.presentFromPreview(presenter: presenter)
} else {
presenter.show(viewController, sender: nil)
}
}
}
}
}
extension LargeImageViewController: CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController) {
presenter.present(self, animated: true)

View File

@ -0,0 +1,202 @@
//
// TimelineLikeCollectionViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/24/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Combine
@MainActor
protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeControllerDelegate, ToastableViewController {
associatedtype Section: TimelineLikeCollectionViewSection
associatedtype Item: TimelineLikeCollectionViewItem where Item.TimelineItem == Self.TimelineItem
associatedtype Error: TimelineLikeCollectionViewError
// this needs to be an IUO because it can't be set until after the super init is called, so that self can be passed as the delegate param
var controller: TimelineLikeController<TimelineItem>! { get }
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
var collectionView: UICollectionView { get }
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
}
protocol TimelineLikeCollectionViewSection: Hashable {
static var entries: Self { get }
static var footer: Self { get }
}
protocol TimelineLikeCollectionViewItem: Hashable {
associatedtype TimelineItem
static var loadingIndicator: Self { get }
static var confirmLoadMore: Self { get }
static func fromTimelineItem(_ item: TimelineItem) -> Self
}
// TODO: equatable might not be the best for this?
protocol TimelineLikeCollectionViewError: Error, Equatable {
static var allCaughtUp: Self { get }
}
// MARK: TimelineLikeControllerDelegate
extension TimelineLikeCollectionViewController {
func canLoadOlder() async -> Bool {
if Preferences.shared.disableInfiniteScrolling {
var snapshot = dataSource.snapshot()
if !snapshot.itemIdentifiers.contains(.confirmLoadMore) {
if !snapshot.sectionIdentifiers.contains(.footer) {
snapshot.appendSections([.footer])
}
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
await apply(snapshot, animatingDifferences: false)
}
for await _ in confirmLoadMore.values {
return true
}
fatalError("unreachable")
} else {
return true
}
}
func handleAddLoadingIndicator() async {
var snapshot = dataSource.snapshot()
if !snapshot.sectionIdentifiers.contains(.footer) {
snapshot.appendSections([.footer])
}
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
snapshot.reconfigureItems([.confirmLoadMore])
} else {
snapshot.appendItems([.loadingIndicator], toSection: .footer)
}
await apply(snapshot, animatingDifferences: false)
}
func handleRemoveLoadingIndicator() async {
let oldContentOffset = collectionView.contentOffset
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.footer])
await apply(snapshot, animatingDifferences: false)
// prevent the collection view from scrolling as we remove the loading indicator and add the timeline items
collectionView.contentOffset = oldContentOffset
}
func handleLoadAllError(_ error: Swift.Error) async {
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadInitial()
}
}
self.showToast(configuration: config, animated: true)
}
func handleReplaceAllItems(_ timelineItems: [TimelineItem]) async {
var snapshot = dataSource.snapshot()
if snapshot.sectionIdentifiers.contains(.entries) {
snapshot.deleteSections([.entries])
}
snapshot.appendSections([.entries])
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
await apply(snapshot, animatingDifferences: false)
}
func handleLoadNewerError(_ error: Swift.Error) async {
var config: ToastConfiguration
if let error = error as? Self.Error,
error == .allCaughtUp {
config = ToastConfiguration(title: "You're all caught up")
config.edge = .top
config.dismissAutomaticallyAfter = 2
config.action = { toast in
toast.dismissToast(animated: true)
}
} else {
config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadNewer()
}
}
}
self.showToast(configuration: config, animated: true)
}
func handlePrependItems(_ timelineItems: [TimelineItem]) async {
let items = timelineItems.map { Item.fromTimelineItem($0) }
var snapshot = dataSource.snapshot()
let first = snapshot.itemIdentifiers(inSection: .entries).first
if let first {
snapshot.insertItems(items, beforeItem: first)
} else {
snapshot.appendItems(items, toSection: .entries)
}
await apply(snapshot, animatingDifferences: false)
if let first,
let indexPath = dataSource.indexPath(for: first) {
// TODO: i can't tell if this actually works or not
// maintain the current scroll position in the list (don't scroll to top)
collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
}
}
func handleLoadOlderError(_ error: Swift.Error) async {
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadOlder()
}
}
self.showToast(configuration: config, animated: true)
}
func handleAppendItems(_ timelineItems: [TimelineItem]) async {
var snapshot = dataSource.snapshot()
// TODO: this might not be necessary, isn't the confirm item removed separately?
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
snapshot.deleteItems([.confirmLoadMore])
}
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
await apply(snapshot, animatingDifferences: false)
}
}
extension TimelineLikeCollectionViewController {
// apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods
// but we always want to update the data source on the main thread for consistency, so this method does that
func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async {
let task = Task { @MainActor in
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
await task.value
}
func registerTimelineLikeCells() {
collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator")
collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore")
}
func loadingIndicatorCell(for indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "loadingIndicator", for: indexPath) as! LoadingCollectionViewCell
cell.indicator.startAnimating()
return cell
}
func confirmLoadMoreCell(for indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
cell.confirmLoadMore = self.confirmLoadMore
Task {
if case .loadingOlder(_, _) = await controller.state {
cell.isLoading = true
} else {
cell.isLoading = false
}
}
return cell
}
}

View File

@ -0,0 +1,302 @@
//
// TimelineLikeController.swift
// Tusker
//
// Created by Shadowfacts on 9/19/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import OSLog
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
associatedtype TimelineItem
func loadInitial() async throws -> [TimelineItem]
func loadNewer() async throws -> [TimelineItem]
func loadOlder() async throws -> [TimelineItem]
func canLoadOlder() async -> Bool
func handleAddLoadingIndicator() async
func handleRemoveLoadingIndicator() async
func handleLoadAllError(_ error: Swift.Error) async
func handleReplaceAllItems(_ timelineItems: [TimelineItem]) async
func handleLoadNewerError(_ error: Swift.Error) async
func handlePrependItems(_ timelineItems: [TimelineItem]) async
func handleLoadOlderError(_ error: Swift.Error) async
func handleAppendItems(_ timelineItems: [TimelineItem]) async
}
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
actor TimelineLikeController<Item> {
unowned var delegate: any TimelineLikeControllerDelegate<Item>
private(set) var state = State.notLoadedInitial {
willSet {
guard state.canTransition(to: newValue) else {
logger.error("State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
preconditionFailure("cannot transition to state")
}
logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
}
}
init(delegate: any TimelineLikeControllerDelegate<Item>) {
self.delegate = delegate
}
func loadInitial() async {
guard state == .notLoadedInitial else {
return
}
let token = LoadAttemptToken()
state = .loadingInitial(token, hasAddedLoadingIndicator: false)
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingInitial(token, hasAddedLoadingIndicator: true))
do {
let items = try await delegate.loadInitial()
guard case .loadingInitial(token, _) = state else {
return
}
await loadingIndicator.end()
await emit(event: .replaceAllItems(items, token))
state = .idle
} catch {
await loadingIndicator.end()
await emit(event: .loadAllError(error, token))
state = .idle
}
}
func loadNewer() async {
guard state == .idle else {
return
}
let token = LoadAttemptToken()
state = .loadingNewer(token)
do {
let items = try await delegate.loadNewer()
guard case .loadingNewer(token) = state else {
return
}
await emit(event: .prependItems(items, token))
state = .idle
} catch {
await emit(event: .loadNewerError(error, token))
state = .idle
}
}
func loadOlder() async {
guard state == .idle else {
return
}
let token = LoadAttemptToken()
guard await delegate.canLoadOlder() else {
return
}
state = .loadingOlder(token, hasAddedLoadingIndicator: false)
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingOlder(token, hasAddedLoadingIndicator: true))
do {
let items = try await delegate.loadOlder()
guard case .loadingOlder(token, _) = state else {
return
}
await loadingIndicator.end()
await emit(event: .appendItems(items, token))
state = .idle
} catch {
await loadingIndicator.end()
await emit(event: .loadOlderError(error, token))
state = .idle
}
}
private func transition(to newState: State) {
self.state = newState
}
private func emit(event: Event) async {
guard state.canEmit(event: event) else {
logger.error("State \(self.state.debugDescription, privacy: .public) cannot emit event: \(event.debugDescription, privacy: .public)")
preconditionFailure("state cannot emit event")
}
switch event {
case .addLoadingIndicator:
await delegate.handleAddLoadingIndicator()
case .removeLoadingIndicator:
await delegate.handleRemoveLoadingIndicator()
case .loadAllError(let error, _):
await delegate.handleLoadAllError(error)
case .replaceAllItems(let items, _):
await delegate.handleReplaceAllItems(items)
case .loadNewerError(let error, _):
await delegate.handleLoadNewerError(error)
case .prependItems(let items, _):
await delegate.handlePrependItems(items)
case .loadOlderError(let error, _):
await delegate.handleLoadOlderError(error)
case .appendItems(let items, _):
await delegate.handleAppendItems(items)
}
}
enum State: Equatable, CustomDebugStringConvertible {
case notLoadedInitial
case idle
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
case loadingNewer(LoadAttemptToken)
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
var debugDescription: String {
switch self {
case .notLoadedInitial:
return "notLoadedInitial"
case .idle:
return "idle"
case .loadingInitial(let token, let hasAddedLoadingIndicator):
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
case .loadingNewer(let token):
return "loadingNewer(\(ObjectIdentifier(token)))"
case .loadingOlder(let token, let hasAddedLoadingIndicator):
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
}
}
func canTransition(to: State) -> Bool {
switch self {
case .notLoadedInitial:
switch to {
case .loadingInitial(_, hasAddedLoadingIndicator: _):
return true
default:
return false
}
case .idle:
switch to {
case .loadingNewer(_), .loadingOlder(_, _):
return true
default:
return false
}
case .loadingInitial(let token, let hasAddedLoadingIndicator):
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
case .loadingNewer(_):
return to == .idle
case .loadingOlder(let token, let hasAddedLoadingIndicator):
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
}
}
func canEmit(event: Event) -> Bool {
switch event {
case .addLoadingIndicator:
switch self {
case .loadingInitial(_, _), .loadingOlder(_, _):
return true
default:
return false
}
case .removeLoadingIndicator:
switch self {
case .loadingInitial(_, hasAddedLoadingIndicator: true), .loadingOlder(_, hasAddedLoadingIndicator: true):
return true
default:
return false
}
case .loadAllError(_, let token), .replaceAllItems(_, let token):
switch self {
case .loadingInitial(token, _):
return true
default:
return false
}
case .loadNewerError(_, let token), .prependItems(_, let token):
switch self {
case .loadingNewer(token):
return true
default:
return false
}
case .loadOlderError(_, let token), .appendItems(_, let token):
switch self {
case .loadingOlder(token, _):
return true
default:
return false
}
}
}
}
enum Event: CustomDebugStringConvertible {
case addLoadingIndicator
case removeLoadingIndicator
case loadAllError(Error, LoadAttemptToken)
case replaceAllItems([Item], LoadAttemptToken)
case loadNewerError(Error, LoadAttemptToken)
case prependItems([Item], LoadAttemptToken)
case loadOlderError(Error, LoadAttemptToken)
case appendItems([Item], LoadAttemptToken)
var debugDescription: String {
switch self {
case .addLoadingIndicator:
return "addLoadingIndicator"
case .removeLoadingIndicator:
return "removeLoadingIndicator"
case .loadAllError(let error, let token):
return "loadAllError(\(error), \(token))"
case .replaceAllItems(_, let token):
return "replcaeAllItems(<omitted>, \(token))"
case .loadNewerError(let error, let token):
return "loadNewerError(\(error), \(token))"
case .prependItems(_, let token):
return "prependItems(<omitted>, \(token))"
case .loadOlderError(let error, let token):
return "loadOlderError(\(error), \(token))"
case .appendItems(_, let token):
return "appendItems(<omitted>, \(token))"
}
}
}
class LoadAttemptToken: Equatable {
static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool {
return lhs === rhs
}
}
class DeferredLoadingIndicator {
private let owner: TimelineLikeController<Item>
private let addedIndicatorState: State
private let task: Task<Void, Error>
init(owner: TimelineLikeController<Item>, state: State, addedIndicatorState: State) {
self.owner = owner
self.addedIndicatorState = addedIndicatorState
self.task = Task {
try await Task.sleep(nanoseconds: 150 * NSEC_PER_MSEC)
guard await state == owner.state else {
return
}
await owner.emit(event: .addLoadingIndicator)
await owner.transition(to: addedIndicatorState)
}
}
func end() async {
let state = await owner.state
if state == addedIndicatorState {
await owner.emit(event: .removeLoadingIndicator)
} else {
task.cancel()
}
}
}
}

View File

@ -10,7 +10,7 @@ import UIKit
import SafariServices
import Pachyderm
protocol TuskerNavigationDelegate: UIViewController {
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
var apiController: MastodonController { get }
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController

View File

@ -51,6 +51,10 @@ class AttachmentsContainerView: UIView {
// MARK: - User Interaface
func updateUI(status: StatusMO) {
guard self.statusID != status.id else {
return
}
self.statusID = status.id
attachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) }

View File

@ -0,0 +1,63 @@
//
// CachedImageView.swift
// Tusker
//
// Created by Shadowfacts on 10/4/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class CachedImageView: UIImageView {
private let cache: ImageCache
private var url: URL?
private var isGrayscale = false
private var fetchTask: Task<Void, Error>?
init(cache: ImageCache) {
self.cache = cache
super.init(frame: .zero)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(for url: URL?) {
if url != self.url {
self.url = url
updateImage()
}
}
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
updateImage()
}
}
private func updateImage() {
fetchTask?.cancel()
fetchTask = Task(priority: .high) {
self.image = nil
guard let url else {
return
}
let (_, image) = await cache.get(url)
guard let image else {
return
}
try Task.checkCancellation()
// TODO: check that this isn't on the main thread
guard let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
return
}
self.image = transformedImage
}
}
}

View File

@ -0,0 +1,67 @@
//
// ConfirmLoadMoreCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 9/21/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Combine
class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell {
var confirmLoadMore: PassthroughSubject<Void, Never>?
var isLoading: Bool {
get {
button.configuration?.showsActivityIndicator ?? false
}
set {
var config = button.configuration!
config.showsActivityIndicator = newValue
button.configuration = config
}
}
private var button: UIButton!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .secondarySystemBackground
let label = UILabel()
label.text = "Infinite scrolling is off. Do you want to keep going?"
label.textColor = .secondaryLabel
label.textAlignment = .natural
label.numberOfLines = 0
var config = UIButton.Configuration.tinted()
config.title = "Load More"
config.imagePadding = 4
button = UIButton(configuration: config, primaryAction: UIAction(handler: { [unowned self] _ in
self.confirmLoadMore?.send()
}))
let stack = UIStackView(arrangedSubviews: [
label,
button,
])
stack.axis = .vertical
stack.distribution = .fill
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,29 @@
//
// LoadingCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 9/24/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class LoadingCollectionViewCell: UICollectionViewCell {
let indicator = UIActivityIndicatorView(style: .medium)
override init(frame: CGRect) {
super.init(frame: frame)
indicator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(indicator)
NSLayoutConstraint.activate([
indicator.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
indicator.topAnchor.constraint(equalToSystemSpacingBelow: contentView.topAnchor, multiplier: 1),
contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: indicator.bottomAnchor, multiplier: 1),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -38,9 +38,17 @@ class StatusPollView: UIView {
optionsView.isTracking
}
override func awakeFromNib() {
super.awakeFromNib()
override init(frame: CGRect) {
super.init(frame: .zero)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
backgroundColor = .clear
optionsView = PollOptionsView(frame: .zero)
@ -55,12 +63,6 @@ class StatusPollView: UIView {
infoLabel.adjustsFontSizeToFitWidth = true
addSubview(infoLabel)
// voteButton = UIButton(type: .system)
// voteButton.translatesAutoresizingMaskIntoConstraints = false
// voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
// voteButton.setTitle("Vote", for: .normal)
// voteButton.setTitleColor(.secondaryLabel, for: .disabled)
// voteButton.titleLabel!.font = infoLabel.font
voteButton = PollVoteButton()
voteButton.translatesAutoresizingMaskIntoConstraints = false
voteButton.addTarget(self, action: #selector(votePressed))

View File

@ -152,7 +152,7 @@ class BaseStatusTableViewCell: UITableViewCell {
updateGrayscaleableUI(account: account, status: status)
updateUIForPreferences(account: account, status: status)
cardView.card = status.card
cardView.updateUI(status: status)
cardView.isHidden = status.card == nil
cardView.navigationDelegate = delegate
cardView.actionProvider = delegate
@ -384,93 +384,16 @@ class BaseStatusTableViewCell: UITableViewCell {
@IBAction func favoritePressed() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
let oldValue = favorited
favorited = !favorited
let realStatus = status.reblog ?? status
let request = (favorited ? Status.favourite : Status.unfavourite)(realStatus.id)
mastodonController.run(request) { response in
DispatchQueue.main.async {
if case let .success(newStatus, _) = response {
self.favorited = newStatus.favourited ?? false
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else {
self.favorited = oldValue
print("Couldn't favorite status \(realStatus.id)")
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
return
}
}
Task {
await FavoriteService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleFavorite()
}
}
@IBAction func reblogPressed() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
// if we are about to reblog and the user has confirmation enabled
if !reblogged,
Preferences.shared.confirmBeforeReblog {
let image: UIImage?
let reblogVisibilityActions: [CustomAlertController.MenuAction]?
if mastodonController.instanceFeatures.reblogVisibility {
image = UIImage(systemName: Status.Visibility.public.unfilledImageName)
reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in
CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { [unowned self] in
self.toggleReblogInternal(visibility: visibility)
}
}
} else {
image = nil
reblogVisibilityActions = nil
}
let preview = ConfirmReblogStatusPreviewView(status: status)
var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [
CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil),
CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { [unowned self] in
self.toggleReblogInternal(visibility: nil)
}),
])
if let reblogVisibilityActions {
var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil)
menuAction.isSecondaryMenu = true
config.actions.append(menuAction)
}
let alert = CustomAlertController(config: config)
delegate?.present(alert, animated: true)
} else {
toggleReblogInternal(visibility: nil)
}
}
private func toggleReblogInternal(visibility: Status.Visibility?) {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
let oldValue = reblogged
reblogged = !reblogged
let realStatus = status.reblog ?? status
let request: Request<Status>
if reblogged {
request = Status.reblog(realStatus.id, visibility: visibility)
} else {
request = Status.unreblog(realStatus.id)
}
mastodonController.run(request) { response in
DispatchQueue.main.async {
if case let .success(newStatus, _) = response {
self.reblogged = newStatus.reblogged ?? false
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else {
self.reblogged = oldValue
print("Couldn't reblog status \(realStatus.id)")
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}
Task {
await ReblogService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleReblog()
}
}

View File

@ -16,13 +16,8 @@ class StatusCardView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate?
weak var actionProvider: MenuActionProvider?
var card: Card? {
didSet {
if let card = card {
self.updateUI(card: card)
}
}
}
private var statusID: String?
private(set) var card: Card?
private let activeBackgroundColor = UIColor.secondarySystemFill
private let inactiveBackgroundColor = UIColor.secondarySystemBackground
@ -115,7 +110,17 @@ class StatusCardView: UIView {
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
private func updateUI(card: Card) {
func updateUI(status: StatusMO) {
guard status.id != statusID else {
return
}
self.card = status.card
self.statusID = status.id
guard let card = status.card else {
return
}
self.imageView.image = nil
updateGrayscaleableUI(card: card)

View File

@ -0,0 +1,266 @@
//
// StatusCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/5/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
@MainActor
protocol StatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?)
}
@MainActor
protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate {
// MARK: Subviews
var avatarImageView: CachedImageView { get }
var displayNameLabel: EmojiLabel { get }
var usernameLabel: UILabel { get }
var contentWarningLabel: EmojiLabel { get }
var collapseButton: StatusCollapseButton { get }
var contentContainer: StatusContentContainer { get }
var replyButton: UIButton { get }
var favoriteButton: UIButton { get }
var reblogButton: UIButton { get }
var moreButton: UIButton { get }
// TODO: why is one of these ! and the other ?
var mastodonController: MastodonController! { get }
var delegate: StatusCollectionViewCellDelegate? { get }
var showStatusAutomatically: Bool { get }
var showReplyIndicator: Bool { get }
var statusID: String! { get set }
var statusState: StatusState! { get set }
var accountID: String! { get set }
var isGrayscale: Bool { get set }
var cancellables: Set<AnyCancellable> { get set }
func updateUIForPreferences(status: StatusMO)
}
// MARK: UI Configuration
extension StatusCollectionViewCell {
static var avatarImageViewSize: CGFloat { 50 }
func baseCreateObservers() {
mastodonController.persistentContainer.statusSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] in
if let mastodonController = self.mastodonController,
let status = mastodonController.persistentContainer.status(for: $0) {
self.updateStatusState(status: status)
}
}
.store(in: &cancellables)
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.accountID }
.sink { [unowned self] in
if let mastodonController = self.mastodonController,
let account = mastodonController.persistentContainer.account(for: $0) {
self.updateAccountUI(account: account)
}
}
.store(in: &cancellables)
}
func doUpdateUI(status: StatusMO) {
precondition(delegate != nil, "StatusCollectionViewCell must have delegate")
statusID = status.id
accountID = status.account.id
updateAccountUI(account: status.account)
updateUIForPreferences(status: status)
contentContainer.contentTextView.setTextFrom(status: status)
contentContainer.attachmentsView.delegate = self
contentContainer.cardView.updateUI(status: status)
contentContainer.cardView.isHidden = status.card == nil
contentContainer.cardView.navigationDelegate = delegate
contentContainer.cardView.actionProvider = delegate
contentContainer.attachmentsView.updateUI(status: status)
updateStatusState(status: status)
contentWarningLabel.text = status.spoilerText
contentWarningLabel.isHidden = status.spoilerText.isEmpty
if !contentWarningLabel.isHidden {
contentWarningLabel.setEmojis(status.emojis, identifier: statusID)
}
reblogButton.isEnabled = reblogEnabled(status: status)
replyButton.isEnabled = mastodonController.loggedIn
favoriteButton.isEnabled = mastodonController.loggedIn
if statusState.unknown {
statusState.resolveFor(status: status, text: contentContainer.contentTextView.text)
if statusState.collapsible! && showStatusAutomatically {
statusState.collapsed = false
}
}
collapseButton.isHidden = !statusState.collapsible!
contentContainer.setCollapsed(statusState.collapsed!)
if statusState.collapsed! {
contentContainer.alpha = 0
// TODO: is this accessing the image view before the button's been laid out?
collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: 0)
collapseButton.accessibilityLabel = NSLocalizedString("Expand Status", comment: "expand status button accessibility label")
} else {
contentContainer.alpha = 1
collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: .pi)
collapseButton.accessibilityLabel = NSLocalizedString("Collapse Status", comment: "collapse status button accessibility label")
}
}
private func reblogEnabled(status: StatusMO) -> Bool {
guard mastodonController.loggedIn else {
return false
}
if status.visibility == .direct || status.visibility == .private {
if mastodonController.instanceFeatures.boostToOriginalAudience,
status.account.id == mastodonController.account.id {
return true
}
return false
}
return true
}
func updateAccountUI(account: AccountMO) {
avatarImageView.update(for: account.avatar)
displayNameLabel.updateForAccountDisplayName(account: account)
usernameLabel.text = "@\(account.acct)"
}
func baseUpdateUIForPreferences(status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
contentContainer.attachmentsView.contentHidden = Preferences.shared.blurAllMedia || status.sensitive
let reblogButtonImage: UIImage
if Preferences.shared.alwaysShowStatusVisibilityIcon || reblogEnabled(status: status) {
reblogButtonImage = UIImage(systemName: "repeat")!
} else {
reblogButtonImage = UIImage(systemName: status.visibility.imageName)!
}
reblogButton.setImage(reblogButtonImage, for: .normal)
}
// only called when isGrayscale does not match the pref
func updateGrayscaleableUI(status: StatusMO) {
isGrayscale = Preferences.shared.grayscaleImages
if contentContainer.contentTextView.hasEmojis {
contentContainer.contentTextView.setTextFrom(status: status)
}
displayNameLabel.updateForAccountDisplayName(account: status.account)
}
func updateStatusState(status: StatusMO) {
if status.favourited {
favoriteButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
} else {
favoriteButton.tintColor = nil
favoriteButton.accessibilityLabel = NSLocalizedString("Favorite", comment: "favorite button accessibility label")
}
if status.reblogged {
reblogButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)
reblogButton.accessibilityLabel = NSLocalizedString("Undo Reblog", comment: "undo reblog button accessibility label")
} else {
reblogButton.tintColor = nil
reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label")
}
// keep menu in sync with changed states e.g. bookmarked, muted
// do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeReply: false) ?? [])
contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController
contentContainer.pollView.toastableViewController = delegate?.toastableViewController
contentContainer.pollView.updateUI(status: status, poll: status.poll)
}
}
// MARK: Interaction
extension StatusCollectionViewCell {
func toggleCollapse() {
statusState.collapsed!.toggle()
// mask so that the content appears to expand with the container during the animation
// but only while the animation is taking place, otherwise the mask interferes with context menu presentation animation
contentContainer.layer.masksToBounds = true
// this delegate call causes the collection view to reconfigure this cell, at which point (and inside of the collection view's animation handling) we'll update the contentContainer
delegate?.statusCellNeedsReconfigure(self, animated: true) {
self.contentContainer.layer.masksToBounds = false
}
}
func toggleFavorite() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError()
}
Task {
await FavoriteService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleFavorite()
}
}
func toggleReblog() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError()
}
Task {
await ReblogService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleReblog()
}
}
}
extension StatusCollectionViewCell {
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
guard let delegate = delegate,
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
let sourceViews = status.attachments.map(contentContainer.attachmentsView.getAttachmentView(for:))
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
// TODO: PiP
// gallery.avPlayerViewControllerDelegate = self
return gallery
}
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
delegate?.present(vc, animated: animated)
}
}
extension StatusCollectionViewCell {
func contextMenuConfigurationForAccount(sourceView: UIView) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration() {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { _ in
return UIMenu(children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: sourceView) ?? [])
}
}
}
extension StatusCollectionViewCell {
func dragItemsForAccount() -> [UIDragItem] {
guard let currentAccountID = mastodonController.accountInfo?.id,
let account = mastodonController.persistentContainer.account(for: accountID) else {
return []
}
let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
}

View File

@ -0,0 +1,113 @@
//
// StatusContentContainer.swift
// Tusker
//
// Created by Shadowfacts on 10/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class StatusContentContainer: UIView {
let contentTextView = StatusContentTextView().configure {
$0.defaultFont = .systemFont(ofSize: 16)
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = false
}
let cardView = StatusCardView().configure {
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: 65),
])
}
let attachmentsView = AttachmentsContainerView().configure {
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalTo: $0.widthAnchor, multiplier: 9/16),
])
}
let pollView = StatusPollView()
private var arrangedSubviews: [UIView] {
[contentTextView, cardView, attachmentsView, pollView]
}
private var isHiddenObservations: [NSKeyValueObservation] = []
private var verticalConstraints: [NSLayoutConstraint] = []
private var lastSubviewBottomConstraint: NSLayoutConstraint?
private var zeroHeightConstraint: NSLayoutConstraint!
override init(frame: CGRect) {
super.init(frame: frame)
for subview in arrangedSubviews {
subview.translatesAutoresizingMaskIntoConstraints = false
addSubview(subview)
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
// this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands
zeroHeightConstraint = heightAnchor.constraint(equalToConstant: 0)
zeroHeightConstraint.priority = .defaultLow
setNeedsUpdateConstraints()
isHiddenObservations = arrangedSubviews.map {
$0.observe(\.isHidden) { [unowned self] _, _ in
self.setNeedsUpdateConstraints()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateConstraints() {
NSLayoutConstraint.deactivate(verticalConstraints)
verticalConstraints = []
var lastVisibleSubview: UIView?
for subview in arrangedSubviews {
guard !subview.isHidden else {
continue
}
if let lastVisibleSubview {
verticalConstraints.append(subview.topAnchor.constraint(equalTo: lastVisibleSubview.bottomAnchor, constant: 4))
} else {
verticalConstraints.append(subview.topAnchor.constraint(equalTo: topAnchor))
}
lastVisibleSubview = subview
}
NSLayoutConstraint.activate(verticalConstraints)
lastSubviewBottomConstraint?.isActive = false
// this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands
lastSubviewBottomConstraint = subviews.last(where: { !$0.isHidden })!.bottomAnchor.constraint(equalTo: bottomAnchor)
lastSubviewBottomConstraint!.isActive = true
lastSubviewBottomConstraint!.priority = .defaultLow
super.updateConstraints()
}
func setCollapsed(_ collapsed: Bool) {
// ensure that we have a lastSubviewBottomConstraint
updateConstraintsIfNeeded()
// force unwrap because the content container should always have at least one view
lastSubviewBottomConstraint!.isActive = !collapsed
zeroHeightConstraint.isActive = collapsed
}
}

View File

@ -41,7 +41,6 @@ class StatusMetaIndicatorsView: UIView {
v.preferredSymbolConfiguration = .init(weight: .thin)
addSubview(v)
v.heightAnchor.constraint(equalToConstant: 22).isActive = true
if index % 2 == 0 {
if index == images.count - 1 {
v.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true

View File

@ -0,0 +1,614 @@
//
// TimelineStatusCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/1/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell {
// MARK: Subviews
private lazy var reblogLabel = EmojiLabel().configure {
$0.textColor = .secondaryLabel
// this needs to have a higher priorty than the content container's zero height constraint
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed)))
}
private lazy var mainContainer = UIView().configure {
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
$0.addSubview(avatarImageView)
contentVStack.translatesAutoresizingMaskIntoConstraints = false
$0.addSubview(contentVStack)
metaIndicatorsView.translatesAutoresizingMaskIntoConstraints = false
$0.addSubview(metaIndicatorsView)
NSLayoutConstraint.activate([
avatarImageView.leadingAnchor.constraint(equalTo: $0.leadingAnchor),
avatarImageView.topAnchor.constraint(equalTo: $0.topAnchor),
contentVStack.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 8),
contentVStack.trailingAnchor.constraint(equalTo: $0.trailingAnchor),
contentVStack.topAnchor.constraint(equalTo: $0.topAnchor),
contentVStack.bottomAnchor.constraint(equalTo: $0.bottomAnchor),
metaIndicatorsView.leadingAnchor.constraint(greaterThanOrEqualTo: $0.leadingAnchor),
metaIndicatorsView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
metaIndicatorsView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 4),
])
}
private static let avatarImageViewSize: CGFloat = 50
private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure {
$0.layer.masksToBounds = true
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.avatarImageViewSize),
$0.widthAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.avatarImageViewSize),
])
$0.isUserInteractionEnabled = true
$0.addInteraction(UIContextMenuInteraction(delegate: self))
$0.addInteraction(UIDragInteraction(delegate: self))
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
}
private let metaIndicatorsView = StatusMetaIndicatorsView()
private lazy var contentVStack = UIStackView(arrangedSubviews: [
nameHStack,
contentWarningLabel,
collapseButton,
contentContainer,
]).configure {
$0.axis = .vertical
$0.spacing = 4
$0.alignment = .fill
}
private lazy var nameHStack = UIStackView(arrangedSubviews: [
displayNameLabel,
usernameLabel,
pinImageView,
timestampLabel,
]).configure {
$0.axis = .horizontal
$0.spacing = 4
}
let displayNameLabel = EmojiLabel().configure {
$0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
]
]), size: 0)
$0.setContentHuggingPriority(.init(251), for: .horizontal)
$0.setContentCompressionResistancePriority(.init(749), for: .horizontal)
}
let usernameLabel = UILabel().configure {
$0.textColor = .secondaryLabel
$0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
$0.setContentHuggingPriority(.init(249), for: .horizontal)
$0.setContentCompressionResistancePriority(.init(748), for: .horizontal)
}
private let pinImageView = UIImageView(image: UIImage(systemName: "pin.fill")).configure {
$0.tintColor = .secondaryLabel
$0.setContentHuggingPriority(.init(251), for: .horizontal)
}
private let timestampLabel = UILabel().configure {
$0.textColor = .secondaryLabel
$0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
}
private(set) lazy var contentWarningLabel = EmojiLabel().configure {
$0.textColor = .secondaryLabel
$0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold.rawValue,
]
]), size: 0)
// this needs to have a higher priorty than the content container's zero height constraint
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
$0.isUserInteractionEnabled = true
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed)))
}
private(set) lazy var collapseButton = StatusCollapseButton(configuration: {
var config = UIButton.Configuration.filled()
config.image = UIImage(systemName: "chevron.down")
return config
}()).configure {
// this button is so big that dimming its background color is visually distracting
$0.tintAdjustmentMode = .normal
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
}
let contentContainer = StatusContentContainer().configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
}
private var contentTextView: StatusContentTextView {
contentContainer.contentTextView
}
private var cardView: StatusCardView {
contentContainer.cardView
}
private var attachmentsView: AttachmentsContainerView {
contentContainer.attachmentsView
}
private var pollView: StatusPollView {
contentContainer.pollView
}
private var placeholderReplyButtonLeadingConstraint: NSLayoutConstraint!
private lazy var actionsContainer = UIView().configure {
replyButton.translatesAutoresizingMaskIntoConstraints = false
$0.addSubview(replyButton)
favoriteButton.translatesAutoresizingMaskIntoConstraints = false
$0.addSubview(favoriteButton)
reblogButton.translatesAutoresizingMaskIntoConstraints = false
$0.addSubview(reblogButton)
moreButton.translatesAutoresizingMaskIntoConstraints = false
$0.addSubview(moreButton)
placeholderReplyButtonLeadingConstraint = replyButton.leadingAnchor.constraint(equalTo: $0.leadingAnchor)
NSLayoutConstraint.activate([
favoriteButton.widthAnchor.constraint(equalTo: replyButton.widthAnchor),
reblogButton.widthAnchor.constraint(equalTo: replyButton.widthAnchor),
moreButton.widthAnchor.constraint(equalTo: replyButton.widthAnchor),
placeholderReplyButtonLeadingConstraint,
replyButton.topAnchor.constraint(equalTo: $0.topAnchor),
replyButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor),
favoriteButton.leadingAnchor.constraint(equalTo: replyButton.trailingAnchor),
favoriteButton.topAnchor.constraint(equalTo: $0.topAnchor),
favoriteButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor),
reblogButton.leadingAnchor.constraint(equalTo: favoriteButton.trailingAnchor),
reblogButton.topAnchor.constraint(equalTo: $0.topAnchor),
reblogButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor),
moreButton.leadingAnchor.constraint(equalTo: reblogButton.trailingAnchor),
moreButton.trailingAnchor.constraint(equalTo: $0.trailingAnchor),
moreButton.topAnchor.constraint(equalTo: $0.topAnchor),
moreButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor),
])
}
private(set) lazy var replyButton = UIButton().configure {
$0.setImage(UIImage(systemName: "arrowshape.turn.up.left.fill"), for: .normal)
$0.addTarget(self, action: #selector(replyPressed), for: .touchUpInside)
}
private(set) lazy var favoriteButton = UIButton().configure {
$0.setImage(UIImage(systemName: "star.fill"), for: .normal)
$0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside)
}
private(set) lazy var reblogButton = UIButton().configure {
$0.setImage(UIImage(systemName: "repeat"), for: .normal)
$0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside)
}
let moreButton = UIButton().configure {
$0.setImage(UIImage(systemName: "ellipsis"), for: .normal)
$0.showsMenuAsPrimaryAction = true
}
// MARK: Cell state
private var mainContainerTopToReblogLabelConstraint: NSLayoutConstraint!
private var mainContainerTopToSelfConstraint: NSLayoutConstraint!
private var mainContainerBottomToActionsConstraint: NSLayoutConstraint!
private var mainContainerBottomToSelfConstraint: NSLayoutConstraint!
weak var mastodonController: MastodonController!
weak var delegate: StatusCollectionViewCellDelegate?
var showStatusAutomatically: Bool {
// TODO: needed once conversation controller refactored
false
}
var showReplyIndicator: Bool {
// TODO: needed once conversation controller refactored
true
}
var showPinned: Bool {
// TODO: needed once profile controller refactored
false
}
// alas these need to be internal so they're accessible from the protocol extensions
var statusID: String!
var statusState: StatusState!
var accountID: String!
private var reblogStatusID: String?
private var rebloggerID: String?
private var firstLayout = true
var isGrayscale = false
private var updateTimestampWorkItem: DispatchWorkItem?
private var hasCreatedObservers = false
var cancellables = Set<AnyCancellable>()
override init(frame: CGRect) {
super.init(frame: frame)
for subview in [reblogLabel, mainContainer, actionsContainer] {
subview.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(subview)
}
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogLabel.bottomAnchor, constant: 4)
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor)
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6)
// sometimes during intermediate layouts, there are conflicting constraints, so let this one get broken temporarily, to avoid a bunch of printing
metaIndicatorsBottomConstraint.priority = .init(999)
NSLayoutConstraint.activate([
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
reblogLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
reblogLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
reblogLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
actionsContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
actionsContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
// yes, this is deliberately 6. 4 looks to cramped, 8 looks uneven
actionsContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6),
metaIndicatorsBottomConstraint,
])
updateActionsVisibility()
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
if firstLayout {
firstLayout = false
// the button's image view doesn't exist until after the first layout
// accessing it before that cause the button to layoutIfNeeded which generates a broken, intermediate layout and prints a bunch of unhelpful autolayout warnings
// so we wait until after the first layout pass to setup the reply button's real constraint
placeholderReplyButtonLeadingConstraint.isActive = false
replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true
}
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
var str = "\(status.account.displayOrUserName), \(contentTextView.text ?? "")"
if status.attachments.count > 0 {
// TODO: localize me
str += ", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")"
}
if status.poll != nil {
str += ", poll"
}
str += ", \(status.createdAt.formatted(.relative(presentation: .numeric)))"
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += ", reblogged by \(reblogger.displayOrUserName)"
}
return str
}
set {}
}
override func accessibilityActivate() -> Bool {
delegate?.selected(status: statusID, state: statusState.copy())
return true
}
// MARK: Configure UI
func updateUI(statusID: String, state: StatusState) {
guard var status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError()
}
createObservers()
self.statusState = state
if let rebloggedStatus = status.reblog {
reblogStatusID = statusID
rebloggerID = status.account.id
reblogLabel.isHidden = false
mainContainerTopToReblogLabelConstraint.isActive = true
mainContainerTopToSelfConstraint.isActive = false
updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus
} else {
reblogStatusID = nil
rebloggerID = nil
reblogLabel.isHidden = true
mainContainerTopToReblogLabelConstraint.isActive = false
mainContainerTopToSelfConstraint.isActive = true
}
doUpdateUI(status: status)
doUpdateTimestamp(status: status)
timestampLabel.isHidden = showPinned
pinImageView.isHidden = !showPinned
}
func createObservers() {
guard !hasCreatedObservers else {
return
}
hasCreatedObservers = true
baseCreateObservers()
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.rebloggerID }
.sink { [unowned self] in
if let mastodonController = self.mastodonController,
let reblogger = mastodonController.persistentContainer.account(for: $0) {
self.updateRebloggerLabel(reblogger: reblogger)
}
}
.store(in: &cancellables)
}
func updateUIForPreferences(status: StatusMO) {
baseUpdateUIForPreferences(status: status)
if showReplyIndicator {
metaIndicatorsView.allowedIndicators = .all
} else {
metaIndicatorsView.allowedIndicators = .all.subtracting(.reply)
}
metaIndicatorsView.updateUI(status: status)
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
updateRebloggerLabel(reblogger: reblogger)
}
}
private func updateTimestamp() {
guard let mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return
}
doUpdateTimestamp(status: status)
}
private func doUpdateTimestamp(status: StatusMO) {
// if there's a pending update timestamp work item, cancel it
updateTimestampWorkItem?.cancel()
timestampLabel.text = status.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch status.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
} else {
updateTimestampWorkItem = nil
}
}
private func updateRebloggerLabel(reblogger: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames {
reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)"
reblogLabel.removeEmojis()
} else {
reblogLabel.text = "Reblogged by \(reblogger.displayOrUserName)"
reblogLabel.setEmojis(reblogger.emojis, identifier: reblogger.id)
}
}
private func updateActionsVisibility() {
if Preferences.shared.hideActionsInTimeline {
actionsContainer.isHidden = true
mainContainerBottomToSelfConstraint.isActive = true
mainContainerBottomToActionsConstraint.isActive = false
} else {
actionsContainer.isHidden = false
mainContainerBottomToSelfConstraint.isActive = false
mainContainerBottomToActionsConstraint.isActive = true
}
}
@objc private func preferencesChanged() {
guard let mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return
}
updateUIForPreferences(status: status)
if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI(status: status)
}
// only needs to happen when prefs change, rather than in updateUIForPrefs b/c this is setup correctly during init
let oldState = actionsContainer.isHidden
if oldState != Preferences.shared.hideActionsInTimeline {
updateActionsVisibility()
delegate?.statusCellNeedsReconfigure(self, animated: true, completion: nil)
}
}
// MARK: Interaction
@objc private func reblogLabelPressed() {
guard let rebloggerID else {
return
}
delegate?.selected(account: rebloggerID)
}
@objc private func accountPressed() {
delegate?.selected(account: accountID)
}
@objc private func collapseButtonPressed() {
toggleCollapse()
}
@objc private func replyPressed() {
if Preferences.shared.mentionReblogger,
let rebloggerID = rebloggerID,
let rebloggerAccount = mastodonController.persistentContainer.account(for: rebloggerID) {
delegate?.compose(inReplyToID: statusID, mentioningAcct: rebloggerAccount.acct)
} else {
delegate?.compose(inReplyToID: statusID)
}
}
@objc private func favoritePressed() {
toggleFavorite()
}
@objc private func reblogPressed() {
toggleReblog()
}
func leadingSwipeActions() -> UISwipeActionsConfiguration? {
guard mastodonController.loggedIn,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
let favoriteTitle = status.favourited ? "Unfavorite" : "Favorite"
let favorite = UIContextualAction(style: .normal, title: favoriteTitle) { [unowned self] _, _, completion in
Task {
await FavoriteService(status: status, mastodonController: self.mastodonController, presenter: self.delegate!).toggleFavorite()
completion(true)
}
}
favorite.image = UIImage(systemName: "star.fill")
favorite.backgroundColor = status.favourited ? UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) : UIColor(displayP3Red: 1, green: 204/255, blue: 0, alpha: 1)
let reblogTitle = status.reblogged ? "Unreblog" : "Reblog"
let reblog = UIContextualAction(style: .normal, title: reblogTitle) { _, _, completion in
Task {
await ReblogService(status: status, mastodonController: self.mastodonController, presenter: self.delegate!).toggleReblog()
completion(true)
}
}
reblog.image = UIImage(systemName: "repeat")
reblog.backgroundColor = status.reblogged ? UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) : tintColor
return UISwipeActionsConfiguration(actions: [favorite, reblog])
}
func trailingSwipeActions() -> UISwipeActionsConfiguration? {
var actions = [UIContextualAction]()
let share = UIContextualAction(style: .normal, title: "Share") { [unowned self] _, _, completion in
self.delegate?.showMoreOptions(forStatus: statusID, sourceView: self)
completion(true)
}
// bold to more closesly match other action symbols
let config = UIImage.SymbolConfiguration(weight: .bold)
share.image = UIImage(systemName: "square.and.arrow.up")!.applyingSymbolConfiguration(config)!
share.backgroundColor = .lightGray
actions.append(share)
if mastodonController.loggedIn {
let reply = UIContextualAction(style: .normal, title: "Reply") { [unowned self] _, _, completion in
self.replyPressed()
completion(true)
}
reply.image = UIImage(systemName: "arrowshape.turn.up.left.fill")
reply.backgroundColor = tintColor
actions.insert(reply, at: 0)
}
return UISwipeActionsConfiguration(actions: actions)
}
func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] {
// the poll options view is tracking while the user is dragging between options
// while that's happening, don't initiate a drag
guard !pollView.isTracking,
let status = mastodonController.persistentContainer.status(for: statusID),
let accountID = mastodonController.accountInfo?.id else {
return []
}
let provider = NSItemProvider(object: status.url! as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
func contextMenuConfiguration() -> UIContextMenuConfiguration? {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
return UIContextMenuConfiguration {
ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.delegate!.actionsForStatus(status, sourceView: self))
}
}
}
extension TimelineStatusCollectionViewCell: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return contextMenuConfigurationForAccount(sourceView: interaction.view!)
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let delegate {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: delegate)
}
}
}
extension TimelineStatusCollectionViewCell: UIDragInteractionDelegate {
func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] {
return dragItemsForAccount()
}
}

View File

@ -35,30 +35,36 @@ struct ToastConfiguration {
}
extension ToastConfiguration {
init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) {
init(from error: Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) {
self.init(title: title)
self.subtitle = error.localizedDescription
self.systemImageName = error.systemImageName
// localizedDescription is statically dispatched, so we need to call it after the downcast
if let error = error as? Client.Error {
self.subtitle = error.localizedDescription
self.systemImageName = error.systemImageName
self.longPressAction = { [unowned viewController] toast in
toast.dismissToast(animated: true)
let text = """
\(title):
\(error.requestMethod.name) \(error.requestEndpoint)
\(error.type)
"""
let reporter = IssueReporterViewController.create(reportText: text, dismiss: { [unowned viewController] in
viewController.dismiss(animated: true)
})
viewController.present(reporter, animated: true)
}
} else {
self.subtitle = error.localizedDescription
self.systemImageName = "exclamationmark.triangle"
}
if let retryAction = retryAction {
self.actionTitle = "Retry"
self.action = retryAction
}
self.longPressAction = { [unowned viewController] toast in
toast.dismissToast(animated: true)
let text = """
\(title):
\(error.requestMethod.name) \(error.requestEndpoint)
\(error.type)
"""
let reporter = IssueReporterViewController.create(reportText: text, dismiss: { [unowned viewController] in
viewController.dismiss(animated: true)
})
viewController.present(reporter, animated: true)
}
}
init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) {
init(from error: Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) {
self.init(from: error, with: title, in: viewController) { toast in
Task {
await retryAction(toast)

View File

@ -1,29 +0,0 @@
//
// XCBActionType.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
enum XCBActionType: String {
// Statuses
case showStatus
case postStatus
case getStatus
case favoriteStatus
case reblogStatus
// Accounts
case showAccount
case getAccount
case getCurrentUser
case followUser
// Search
case search
var path: String {
return "/\(rawValue)"
}
}

View File

@ -1,354 +0,0 @@
//
// XCBActions.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
struct XCBActions {
// MARK: - Utils
private static var mastodonController: MastodonController {
let scene = UIApplication.shared.activeOrBackgroundScene!
return scene.session.mastodonController!
}
private static func getMainTabBarController() -> MainTabBarViewController {
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
let window = scene.windows.first { $0.isKeyWindow }!
return window.rootViewController as! MainTabBarViewController
}
private static func show(_ vc: UIViewController) {
let tabBarController = getMainTabBarController()
if tabBarController.presentedViewController != nil {
tabBarController.presentedViewController?.dismiss(animated: false)
}
tabBarController.selectedViewController!.show(vc, sender: nil)
}
private static func present(_ vc: UIViewController, animated: Bool = true) {
getMainTabBarController().present(vc, animated: animated)
}
private static func getStatus(from request: XCBRequest, session: XCBSession, completion: @escaping (Status) -> Void) {
if let id = request.arguments["statusID"] {
let request = Client.getStatus(id: id)
mastodonController.run(request) { (response) in
guard case let .success(status, _) = response else {
session.complete(with: .error, additionalData: [
"error": "Could not get status with ID \(id)"
])
return
}
completion(status)
}
} else if let searchQuery = request.arguments["statusURL"] {
let request = Client.search(query: searchQuery)
mastodonController.run(request) { (response) in
if case let .success(results, _) = response,
let status = results.statuses.first {
completion(status)
} else {
session.complete(with: .error, additionalData: [
"error": "Could not find status by searching '\(searchQuery)'"
])
}
}
} else {
session.complete(with: .error, additionalData: [
"error": "No status provided. Specify either instance-local statusID or remote statusURL."
])
}
}
private static func getAccount(from request: XCBRequest, session: XCBSession, completion: @escaping (Account) -> Void) {
if let id = request.arguments["accountID"] {
let request = Client.getAccount(id: id)
mastodonController.run(request) { (response) in
guard case let .success(account, _) = response else {
session.complete(with: .error, additionalData: [
"error": "Could not get account with ID \(id)"
])
return
}
completion(account)
}
} else if let searchQuery = request.arguments["accountURL"] {
let request = Client.search(query: searchQuery)
mastodonController.run(request) { (response) in
if case let .success(results, _) = response {
if let account = results.accounts.first {
completion(account)
} else {
session.complete(with: .error, additionalData: [
"error": "Could not find account by searching '\(searchQuery)'"
])
}
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
} else if let acct = request.arguments["acct"] {
let request = Client.searchForAccount(query: acct)
mastodonController.run(request) { (response) in
if case let .success(accounts, _) = response {
if let account = accounts.first {
completion(account)
} else {
session.complete(with: .error, additionalData: [
"error": "Could not find account \(acct)"
])
}
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
} else {
session.complete(with: .error, additionalData: [
"error": "No status provided. Specify either instance-local ID, account URL, or qualified username."
])
}
}
// MARK: - Statuses
static func showStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
getStatus(from: request, session: session) { (status) in
DispatchQueue.main.async {
let vc = ConversationTableViewController(for: status.id, mastodonController: mastodonController)
show(vc)
}
}
}
static func postStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
let mentioning = request.arguments["mentioning"]
let text = request.arguments["text"]
if silent ?? false {
var status = ""
if let mentioning = mentioning { status += mentioning }
if let text = text { status += text }
guard CharacterCounter.count(text: status) <= mastodonController.instance.maxStatusCharacters ?? 500 else {
session.complete(with: .error, additionalData: [
"error": "Too many characters. Instance maximum is \(mastodonController.instance.maxStatusCharacters ?? 500)"
])
return
}
let request = Client.createStatus(text: status, visibility: Preferences.shared.defaultPostVisibility)
mastodonController.run(request) { response in
if case let .success(status, _) = response {
session.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,
"statusURI": status.uri
])
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
} else {
// todo: use text param
let draft = mastodonController.createDraft(mentioningAcct: mentioning)
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
// compose.xcbSession = session
let vc = UINavigationController(rootViewController: compose)
present(vc)
}
}
static func getStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
getStatus(from: request, session: session) { (status) in
let html = Bool(request.arguments["html"] ?? "false") ?? false
let content: String
if html {
content = status.content
} else {
do {
let doc = try SwiftSoup.parse(status.content)
content = try doc.body()!.text()
} catch {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
return
}
}
session.complete(with: .success, additionalData: [
"url": status.url?.absoluteString,
"uri": status.uri,
"id": status.id,
"account": status.account.acct,
"inReplyTo": status.inReplyToID,
"posted": status.createdAt.timeIntervalSince1970.description,
"content": content,
"reblog": status.reblog?.id
])
}
}
static func favoriteStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
statusAction(request: Status.favourite, alertTitle: "Favorite status?", request, session, silent)
}
static func reblogStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
statusAction(request: { Status.reblog($0) }, alertTitle: "Reblog status?", request, session, silent)
}
static func statusAction(request: @escaping (String) -> Request<Status>, alertTitle: String, _ url: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
func performAction(status: Status, completion: ((Status) -> Void)?) {
mastodonController.run(request(status.id)) { (response) in
if case let .success(status, _) = response {
completion?(status)
session.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,
"statusURI": status.uri
])
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
}
func favorite(_ status: Status) {
if silent ?? false {
performAction(status: status, completion: nil)
} else {
let vc = ConversationTableViewController(for: status.id, mastodonController: mastodonController)
DispatchQueue.main.async {
show(vc)
}
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in
performAction(status: status, completion: { (status) in
DispatchQueue.main.async {
vc.tableView.reloadData()
}
})
}))
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
session.complete(with: .cancel)
}))
DispatchQueue.main.async {
present(alertController)
}
}
}
getStatus(from: url, session: session, completion: favorite)
}
// MARK: - Accounts
static func showAccount(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
getAccount(from: request, session: session) { (account) in
DispatchQueue.main.async {
let vc = ProfileViewController(accountID: account.id, mastodonController: mastodonController)
show(vc)
}
}
}
static func getAccount(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
getAccount(from: request, session: session) { (account) in
session.complete(with: .success, additionalData: [
"username": account.acct,
"displayName": account.displayName,
"locked": account.locked.description,
"followers": account.followersCount.description,
"following": account.followingCount.description,
"url": account.url.absoluteString,
"avatarURL": account.avatar?.absoluteString,
"headerURL": account.header?.absoluteString
])
}
}
static func getCurrentUser(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
let account = mastodonController.account!
session.complete(with: .success, additionalData: [
"username": account.acct,
"displayName": account.displayName,
"locked": account.locked.description,
"followers": account.followersCount.description,
"following": account.followingCount.description,
"url": account.url.absoluteString,
"avatarURL": account.avatar?.absoluteString,
"headerURL": account.header?.absoluteString
])
}
static func followUser(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
func performAction(_ account: Account) {
let request = Account.follow(account.id)
mastodonController.run(request) { (response) in
if case .success(_, _) = response {
session.complete(with: .success, additionalData: [
"url": account.url.absoluteString
])
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
}
func follow(_ account: Account) {
if silent ?? false {
performAction(account)
} else {
let vc = ProfileViewController(accountID: account.id, mastodonController: mastodonController)
DispatchQueue.main.async {
show(vc)
}
// todo: update to use managed objects
let alertController = UIAlertController(title: "Follow \(account.displayName)?", message: nil, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in
performAction(account)
}))
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
session.complete(with: .cancel)
}))
DispatchQueue.main.async {
present(alertController)
}
}
}
getAccount(from: request, session: session, completion: follow)
}
// MARK: - Search
static func search(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
let query = request.arguments["query"]!
let tabBarController = getMainTabBarController()
if let navigationController = tabBarController.getTabController(tab: .explore) as? UINavigationController,
let exploreController = navigationController.viewControllers.first as? ExploreViewController {
tabBarController.select(tab: .explore)
navigationController.popToRootViewController(animated: false)
exploreController.loadViewIfNeeded()
exploreController.searchController.isActive = true
exploreController.searchController.searchBar.text = query
exploreController.resultsController.performSearch(query: query)
} else {
session.complete(with: .error)
}
}
}

View File

@ -1,46 +0,0 @@
//
// XCBManager.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
class XCBManager {
static var specs: [XCBRequestSpec] = [
// Statuses
XCBRequestSpec(type: .showStatus, arguments: ["statusID": true, "statusURL": true], canRunSilently: false, action: XCBActions.showStatus),
XCBRequestSpec(type: .getStatus, arguments: ["statusID": true, "statusURL": true, "html": true], canRunSilently: false, action: XCBActions.getStatus),
XCBRequestSpec(type: .postStatus, arguments: ["mentioning": true, "text": true], canRunSilently: true, action: XCBActions.postStatus),
XCBRequestSpec(type: .favoriteStatus, arguments: ["statusID": true, "statusURL": true], canRunSilently: true, action: XCBActions.favoriteStatus),
XCBRequestSpec(type: .reblogStatus, arguments: ["statusID": true, "statusURL": true], canRunSilently: true, action: XCBActions.reblogStatus),
// Accounts
XCBRequestSpec(type: .showAccount, arguments: ["accountID": true, "accountURL": true, "acct": true], canRunSilently: false, action: XCBActions.showAccount),
XCBRequestSpec(type: .getAccount, arguments: ["accountID": true, "accountURL": true, "acct": true], canRunSilently: false, action: XCBActions.getAccount),
XCBRequestSpec(type: .getCurrentUser, arguments: [:], canRunSilently: false, action: XCBActions.getCurrentUser),
XCBRequestSpec(type: .followUser, arguments: ["accountID": true, "accountURL": true, "acct": true], canRunSilently: true, action: XCBActions.followUser),
// Search
XCBRequestSpec(type: .search, arguments: ["query": false], canRunSilently: false, action: XCBActions.search),
]
static var currentSession: XCBSession?
static func handle(url: URL) -> Bool {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return false }
if let spec = specs.first(where: { $0.matches(components) }) {
let request = XCBRequest(spec: spec, components: components)
return spec.handle(request: request)
}
return false
}
static func createSession(type: XCBActionType, request: XCBRequest) -> XCBSession {
let session = XCBSession(type: type, request: request)
currentSession = session
return session
}
}

View File

@ -1,51 +0,0 @@
//
// XCBRequest.swift
// Tusker
//
// Created by Shadowfacts on 9/25/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
struct XCBRequest {
let path: String
let arguments: [String: String]
let json: Bool
let silent: Bool
let source: String?
let success: URL?
let error: URL?
let cancel: URL?
init(spec: XCBRequestSpec, components: URLComponents) {
self.path = spec.path
if let queryItems = components.queryItems {
self.arguments = spec.arguments.reduce(into: [String: String](), { (result, el) in
if let value = queryItems.first(where: { $0.name == el.key })?.value {
result[el.key] = value
}
})
source = queryItems.first(where: { $0.name == "x-source" }).flatMap { $0.value }
success = queryItems.first(where: { $0.name == "x-success" }).flatMap { $0.value }.flatMap { URL(string: $0) }
error = queryItems.first(where: { $0.name == "x-error" }).flatMap { $0.value }.flatMap { URL(string: $0) }
cancel = queryItems.first(where: { $0.name == "x-cancel" }).flatMap { $0.value }.flatMap { URL(string: $0) }
} else {
self.arguments = [:]
source = nil
success = nil
error = nil
cancel = nil
}
if let arg = arguments["json"] {
json = Bool(arg) ?? false
} else {
json = false
}
if spec.canRunSilently, let arg = arguments["silent"] {
silent = Bool(arg) ?? false
} else {
silent = false
}
}
}

View File

@ -1,79 +0,0 @@
//
// XCallbackURL.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
typealias XCBAction = (_ url: XCBRequest, _ session: XCBSession, _ silent: Bool?) -> Void
struct XCBRequestSpec {
let path: String
let type: XCBActionType
let arguments: [String: Bool]
let canRunSilently: Bool
let action: XCBAction
init(type: XCBActionType, arguments: [String: Bool], canRunSilently: Bool, action: @escaping XCBAction) {
self.path = type.path
self.type = type
self.canRunSilently = canRunSilently
self.action = action
var arguments = arguments
if canRunSilently {
arguments["silent"] = true
}
arguments["json"] = true
self.arguments = arguments
}
func handle(request: XCBRequest) -> Bool {
let session = XCBManager.createSession(type: type, request: request)
if canRunSilently && request.silent {
if let source = request.source {
let permission = Preferences.shared.silentActions[source] ?? .undecided
switch permission {
case .accepted:
action(request, session, true)
case .rejected:
action(request, session, false)
case .undecided:
let alert = UIAlertController(title: "\(source) wants to perform actions silently", message: "Accepting will allow \(source) to perform actions without your confirmation, rejecting will always prompt for confirmation.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Accept", style: .default, handler: { (_) in
Preferences.shared.silentActions[source] = .accepted
self.action(request, session, true)
}))
alert.addAction(UIAlertAction(title: "Reject", style: .default, handler: { (_) in
Preferences.shared.silentActions[source] = .rejected
self.action(request, session, false)
}))
UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true)
}
} else {
session.complete(with: .error, additionalData: [
"error": "Cannot perform silent action without source app, x-source parameter must be specified."
])
}
} else {
action(request, session, nil)
}
return true
}
}
extension XCBRequestSpec {
func matches(_ components: URLComponents) -> Bool {
guard path == components.path else { return false }
for (name, optional) in arguments {
if (!optional && components.queryItems?.first(where: { $0.name == name }) == nil) {
return false
}
}
return true
}
}

View File

@ -1,45 +0,0 @@
//
// XCBSession.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
class XCBSession {
static let encoder = JSONEncoder()
let type: XCBActionType
let request: XCBRequest
init(type: XCBActionType, request: XCBRequest) {
self.type = type
self.request = request
}
func complete(with result: XCBSessionResult, additionalData: [String: String?]? = nil) {
guard var url = result == .success ? request.success : result == .error ? request.error : request.cancel else { return }
XCBManager.currentSession = nil
if let additionalData = additionalData {
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
components.queryItems = components.queryItems ?? []
if request.json {
let data = try! XCBSession.encoder.encode(additionalData)
let response = String(data: data, encoding: .utf8)
components.queryItems!.append(URLQueryItem(name: "response", value: response))
} else {
components.queryItems!.append(contentsOf: additionalData.map(URLQueryItem.init))
}
url = components.url!
}
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:])
}
}
}
enum XCBSessionResult {
case success, error, cancel
}

View File

@ -30,5 +30,45 @@ class TuskerTests: XCTestCase {
// Put the code you want to measure the time of here.
}
}
func testFuckingLock() {
let lock = MutexLock<[Int: Bool]>(initialState: [:])
for i in 0..<100 {
Thread.detachNewThread {
for j in 0..<50_000 {
lock.withLock {
$0[i * 50_000 + j] = true
}
}
}
}
while true {
if lock.withLock({ $0.count }) == 5_000_000 {
break
}
}
lock.withLock({ _ in
print("WHAT THE FUUUUUUUUUUUUCK")
})
}
}
fileprivate class MutexLock<State> {
private var state: State
private var lock = NSLock()
init(initialState: State) {
self.state = initialState
}
func withLock<R>(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R : Sendable {
if !lock.lock(before: Date(timeIntervalSinceNow: 1)) {
// if we can't acquire the lock after 1 second, something has gone catastrophically wrong
fatalError()
}
defer { lock.unlock() }
return try body(&state)
}
}

View File

@ -7,70 +7,70 @@
//
import Foundation
import Ambassador
fileprivate let notFound = ["error": "Record not found"]
extension Router {
func allRoutes() {
instanceRoutes()
accountRoutes()
timelineRoutes()
notificationRoutes()
}
func instanceRoutes() {
self["/api/v1/instance"] = JSONResponse(handler: { (_) in
return [
"description": "An instance description",
"max_toot_chars": 500,
"thumbnail": "http://localhost:8080/thumbnail.png",
"title": "Localhost",
"uri": "http://localhost:8080",
"version": "2.7.2",
"urls": [:]
]
})
}
func accountRoutes() {
let selfAccount: [String: Any] = [
"id": "1",
"username": "admin",
"acct": "admin",
"display_name": "Admin Account",
"locked": false,
"created_at": "2019-12-31T11:13:42.0Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "My profile description.",
"url": "http://localhost:8080/users/admin",
"avatar": "http://localhost:8080/avatar/admin.jpg",
"avatar_static": "http://localhost:8080/avatar/admin.jpg",
"header": "http://localhost:8080/header/admin.jpg",
"header_static": "http://localhost:8080/header/admin.jpg",
"emojis": []
]
self["/api/v1/accounts/verify_credentials"] = JSONResponse(result: selfAccount)
self["/api/v1/accounts/\\d+/statuses"] = JSONResponse(result: [])
self["/api/v1/accounts/(\\d+)"] = DelegatingResponse { (ctx) in
if ctx.captures[0] == "1" {
return JSONResponse(result: selfAccount)
} else {
return JSONResponse(statusCode: 404, statusMessage: "Not Found", result: notFound)
}
}
}
func timelineRoutes() {
let emptyTimeline: [Any] = []
self["/api/v1/timelines/home"] = JSONResponse(result: emptyTimeline)
self["/api/v1/timelines/public"] = JSONResponse(result: emptyTimeline)
}
func notificationRoutes() {
let emptyTimeline: [Any] = []
self["/api/v1/notifications"] = JSONResponse(result: emptyTimeline)
}
}
//import Ambassador
//
//fileprivate let notFound = ["error": "Record not found"]
//
//extension Router {
// func allRoutes() {
// instanceRoutes()
// accountRoutes()
// timelineRoutes()
// notificationRoutes()
// }
//
// func instanceRoutes() {
// self["/api/v1/instance"] = JSONResponse(handler: { (_) in
// return [
// "description": "An instance description",
// "max_toot_chars": 500,
// "thumbnail": "http://localhost:8080/thumbnail.png",
// "title": "Localhost",
// "uri": "http://localhost:8080",
// "version": "2.7.2",
// "urls": [:]
// ]
// })
// }
//
// func accountRoutes() {
// let selfAccount: [String: Any] = [
// "id": "1",
// "username": "admin",
// "acct": "admin",
// "display_name": "Admin Account",
// "locked": false,
// "created_at": "2019-12-31T11:13:42.0Z",
// "followers_count": 0,
// "following_count": 0,
// "statuses_count": 0,
// "note": "My profile description.",
// "url": "http://localhost:8080/users/admin",
// "avatar": "http://localhost:8080/avatar/admin.jpg",
// "avatar_static": "http://localhost:8080/avatar/admin.jpg",
// "header": "http://localhost:8080/header/admin.jpg",
// "header_static": "http://localhost:8080/header/admin.jpg",
// "emojis": []
// ]
// self["/api/v1/accounts/verify_credentials"] = JSONResponse(result: selfAccount)
// self["/api/v1/accounts/\\d+/statuses"] = JSONResponse(result: [])
// self["/api/v1/accounts/(\\d+)"] = DelegatingResponse { (ctx) in
// if ctx.captures[0] == "1" {
// return JSONResponse(result: selfAccount)
// } else {
// return JSONResponse(statusCode: 404, statusMessage: "Not Found", result: notFound)
// }
// }
// }
//
// func timelineRoutes() {
// let emptyTimeline: [Any] = []
// self["/api/v1/timelines/home"] = JSONResponse(result: emptyTimeline)
// self["/api/v1/timelines/public"] = JSONResponse(result: emptyTimeline)
// }
//
// func notificationRoutes() {
// let emptyTimeline: [Any] = []
// self["/api/v1/notifications"] = JSONResponse(result: emptyTimeline)
// }
//}

View File

@ -7,23 +7,23 @@
//
import Foundation
import Ambassador
struct DelegatingResponse: WebApp {
let handler: (_ ctx: Context) -> WebApp
func app(_ environ: [String : Any], startResponse: @escaping ((String, [(String, String)]) -> Void), sendBody: @escaping ((Data) -> Void)) {
let ctx = Context(environ: environ)
handler(ctx).app(environ, startResponse: startResponse, sendBody: sendBody)
}
}
extension DelegatingResponse {
struct Context {
let environ: [String: Any]
var captures: [String] {
environ["ambassador.router_captures"] as? [String] ?? []
}
}
}
//import Ambassador
//
//struct DelegatingResponse: WebApp {
// let handler: (_ ctx: Context) -> WebApp
//
// func app(_ environ: [String : Any], startResponse: @escaping ((String, [(String, String)]) -> Void), sendBody: @escaping ((Data) -> Void)) {
// let ctx = Context(environ: environ)
// handler(ctx).app(environ, startResponse: startResponse, sendBody: sendBody)
// }
//}
//
//extension DelegatingResponse {
// struct Context {
// let environ: [String: Any]
//
// var captures: [String] {
// environ["ambassador.router_captures"] as? [String] ?? []
// }
// }
//}

View File

@ -7,12 +7,12 @@
//
import Foundation
import Ambassador
extension JSONResponse {
init(statusCode: Int = 200, statusMessage: String = "OK", result: Any) {
self.init(statusCode: statusCode, statusMessage: statusMessage, handler: { (_) in
return result
})
}
}
//import Ambassador
//
//extension JSONResponse {
// init(statusCode: Int = 200, statusMessage: String = "OK", result: Any) {
// self.init(statusCode: statusCode, statusMessage: statusMessage, handler: { (_) in
// return result
// })
// }
//}

View File

@ -14,7 +14,7 @@ class ComposeTests: TuskerUITests {
override func setUp() {
super.setUp()
router.allRoutes()
// router.allRoutes()
app.launchEnvironment["UI_TESTING_LOGIN"] = "true"
app.launch()

View File

@ -13,7 +13,7 @@ class MyProfileTests: TuskerUITests {
override func setUp() {
super.setUp()
router.allRoutes()
// router.allRoutes()
app.launchEnvironment["UI_TESTING_LOGIN"] = "true"
app.launch()

View File

@ -7,14 +7,14 @@
//
import XCTest
import Ambassador
//import Ambassador
class OnboardingTests: TuskerUITests {
override func setUp() {
super.setUp()
router.instanceRoutes()
// router.instanceRoutes()
app.launch()
}

View File

@ -7,40 +7,40 @@
//
import XCTest
import Embassy
import Ambassador
//import Embassy
//import Ambassador
class TuskerUITests: XCTestCase {
var eventLoop: EventLoop!
var router: Router!
var server: HTTPServer!
var eventLoopThreadCondition: NSCondition!
var eventLoopThread: Thread!
// var eventLoop: EventLoop!
// var router: Router!
// var server: HTTPServer!
// var eventLoopThreadCondition: NSCondition!
// var eventLoopThread: Thread!
var app: XCUIApplication!
private func setupWebServer() {
eventLoop = try! SelectorEventLoop(selector: try! KqueueSelector())
router = Router()
server = DefaultHTTPServer(eventLoop: eventLoop, port: 8080, app: router.app)
router["/hello"] = JSONResponse(handler: { (_) in
return ["Hello", "World"]
})
try! server.start()
eventLoopThreadCondition = NSCondition()
eventLoopThread = Thread(block: {
self.eventLoop.runForever()
self.eventLoopThreadCondition.lock()
self.eventLoopThreadCondition.signal()
self.eventLoopThreadCondition.unlock()
})
eventLoopThread.start()
}
// private func setupWebServer() {
// eventLoop = try! SelectorEventLoop(selector: try! KqueueSelector())
// router = Router()
// server = DefaultHTTPServer(eventLoop: eventLoop, port: 8080, app: router.app)
// router["/hello"] = JSONResponse(handler: { (_) in
// return ["Hello", "World"]
// })
// try! server.start()
//
// eventLoopThreadCondition = NSCondition()
// eventLoopThread = Thread(block: {
// self.eventLoop.runForever()
// self.eventLoopThreadCondition.lock()
// self.eventLoopThreadCondition.signal()
// self.eventLoopThreadCondition.unlock()
// })
// eventLoopThread.start()
// }
override func setUp() {
setupWebServer()
// setupWebServer()
continueAfterFailure = false
app = XCUIApplication()
@ -48,14 +48,14 @@ class TuskerUITests: XCTestCase {
}
override func tearDown() {
server.stopAndWait()
eventLoopThreadCondition.lock()
eventLoop.stop()
while eventLoop.running {
if !eventLoopThreadCondition.wait(until: Date(timeIntervalSinceNow: 10)) {
fatalError("Join eventLoopThread timeout")
}
}
// server.stopAndWait()
// eventLoopThreadCondition.lock()
// eventLoop.stop()
// while eventLoop.running {
// if !eventLoopThreadCondition.wait(until: Date(timeIntervalSinceNow: 10)) {
// fatalError("Join eventLoopThread timeout")
// }
// }
}
}