Compare commits
37 Commits
85ced7ff5f
...
bce0f8ef18
Author | SHA1 | Date |
---|---|---|
Shadowfacts | bce0f8ef18 | |
Shadowfacts | d661870401 | |
Shadowfacts | afa1a733f4 | |
Shadowfacts | 1b186725ce | |
Shadowfacts | 164a8e26c4 | |
Shadowfacts | cadcc1a92a | |
Shadowfacts | bcb3c24027 | |
Shadowfacts | fd6a4ba41c | |
Shadowfacts | 3ab82b2dbb | |
Shadowfacts | 1ed218d5e3 | |
Shadowfacts | 0fee770411 | |
Shadowfacts | 5b116c0d4e | |
Shadowfacts | b7a4f7e30f | |
Shadowfacts | ba1300b1b7 | |
Shadowfacts | 817ef0c2cc | |
Shadowfacts | 18ee621489 | |
Shadowfacts | ddf5094acf | |
Shadowfacts | 133921848d | |
Shadowfacts | 46db70d58b | |
Shadowfacts | 21958eb77f | |
Shadowfacts | b30f149dc9 | |
Shadowfacts | 9b83566482 | |
Shadowfacts | b688631937 | |
Shadowfacts | 4d654358d7 | |
Shadowfacts | 24e90de672 | |
Shadowfacts | 780e8b09b7 | |
Shadowfacts | 2196663d94 | |
Shadowfacts | 7085ac01cb | |
Shadowfacts | 81671d73c7 | |
Shadowfacts | a38c89a17f | |
Shadowfacts | 253fb8d27d | |
Shadowfacts | a682c8f5cc | |
Shadowfacts | d18a4b3c42 | |
Shadowfacts | 426b31d46c | |
Shadowfacts | 5c09b1910f | |
Shadowfacts | fe72d8faec | |
Shadowfacts | b560bcd8dc |
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -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.
|
||||
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
|
|
|
@ -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" */;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -30,7 +30,7 @@ struct InstanceFeatures {
|
|||
}
|
||||
|
||||
var boostToOriginalAudience: Bool {
|
||||
instanceType == .pleroma
|
||||
instanceType == .pleroma || instanceType == .mastodon
|
||||
}
|
||||
|
||||
var profilePinnedStatuses: Bool {
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {
|
||||
}
|
|
@ -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's posts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Post photos from the photo library.</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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: @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()
|
||||
}
|
||||
func withLock<R>(_ body: (inout State) throws -> R) rethrows -> R where R: Sendable {
|
||||
os_unfair_lock_lock(lock)
|
||||
defer { os_unfair_lock_unlock(lock) }
|
||||
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)
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -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,6 +119,7 @@ class IssueReporterViewController: UIViewController {
|
|||
@IBAction func sendReportTouchUpInside(_ sender: Any) {
|
||||
updateSendReportButtonColor(lightened: false, animate: true)
|
||||
|
||||
Task {
|
||||
let composeVC = MFMailComposeViewController()
|
||||
composeVC.mailComposeDelegate = self
|
||||
composeVC.setToRecipients(["me@shadowfacts.net"])
|
||||
|
@ -115,8 +128,14 @@ class IssueReporterViewController: UIViewController {
|
|||
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() {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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: [
|
||||
|
|
|
@ -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()
|
||||
var changed = false
|
||||
if !snapshot.sectionIdentifiers.contains(.loadingIndicator) {
|
||||
snapshot.appendSections([.loadingIndicator])
|
||||
snapshot.appendItems([.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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -35,14 +35,12 @@ 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)
|
||||
// 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
|
||||
if let retryAction = retryAction {
|
||||
self.actionTitle = "Retry"
|
||||
self.action = retryAction
|
||||
}
|
||||
self.longPressAction = { [unowned viewController] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
let text = """
|
||||
|
@ -56,9 +54,17 @@ extension ToastConfiguration {
|
|||
})
|
||||
viewController.present(reporter, animated: true)
|
||||
}
|
||||
} else {
|
||||
self.subtitle = error.localizedDescription
|
||||
self.systemImageName = "exclamationmark.triangle"
|
||||
}
|
||||
if let retryAction = retryAction {
|
||||
self.actionTitle = "Retry"
|
||||
self.action = retryAction
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
@ -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)"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -31,4 +31,44 @@ class TuskerTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -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] ?? []
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -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
|
||||
// })
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -14,7 +14,7 @@ class ComposeTests: TuskerUITests {
|
|||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
router.allRoutes()
|
||||
// router.allRoutes()
|
||||
|
||||
app.launchEnvironment["UI_TESTING_LOGIN"] = "true"
|
||||
app.launch()
|
||||
|
|
|
@ -13,7 +13,7 @@ class MyProfileTests: TuskerUITests {
|
|||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
router.allRoutes()
|
||||
// router.allRoutes()
|
||||
|
||||
app.launchEnvironment["UI_TESTING_LOGIN"] = "true"
|
||||
app.launch()
|
||||
|
|
|
@ -7,14 +7,14 @@
|
|||
//
|
||||
|
||||
import XCTest
|
||||
import Ambassador
|
||||
//import Ambassador
|
||||
|
||||
class OnboardingTests: TuskerUITests {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
router.instanceRoutes()
|
||||
// router.instanceRoutes()
|
||||
|
||||
app.launch()
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue