Compare commits

..

No commits in common. "d661870401bad6ca91cfb8a34ff82f0de79e1f44" and "b30f149dc93195a5d5744d031c30180ba768f00f" have entirely different histories.

28 changed files with 504 additions and 2488 deletions

View File

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

View File

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

View File

@ -33,19 +33,13 @@
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 */; };
@ -147,7 +141,6 @@
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 */; };
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
@ -170,7 +163,6 @@
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 */; };
@ -217,11 +209,6 @@
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 */; };
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 */; };
@ -386,18 +373,12 @@
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>"; };
@ -501,7 +482,6 @@
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>"; };
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>"; };
@ -524,7 +504,6 @@
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>"; };
@ -570,11 +549,6 @@
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>"; };
@ -906,12 +880,10 @@
D641C781213DD7DD004B4513 /* Timeline */ = {
isa = PBXGroup;
children = (
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
);
path = Timeline;
sourceTree = "<group>";
@ -1034,9 +1006,6 @@
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */,
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */,
D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */,
D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */,
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */,
D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */,
);
path = Status;
sourceTree = "<group>";
@ -1138,7 +1107,6 @@
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */,
D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */,
D62E9984279CA23900C26176 /* URLSession+Development.swift */,
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1262,7 +1230,6 @@
children = (
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
@ -1272,7 +1239,6 @@
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
D620483323D3801D008A63EF /* LinkTextView.swift */,
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
@ -1320,7 +1286,6 @@
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
@ -1380,7 +1345,6 @@
D6B81F432560390300F6E31D /* MenuController.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
@ -1433,7 +1397,6 @@
children = (
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */,
D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */,
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */,
);
path = "Confirm Load More Cell";
sourceTree = "<group>";
@ -1479,8 +1442,6 @@
D62E9988279DB2D100C26176 /* InstanceFeatures.swift */,
D6F953EF21251A2900CF0F2B /* MastodonController.swift */,
D6E9CDA7281A427800BBC98E /* PostService.swift */,
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
);
path = API;
sourceTree = "<group>";
@ -1762,12 +1723,10 @@
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 */,
@ -1830,7 +1789,6 @@
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 */,
@ -1845,12 +1803,10 @@
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 */,
@ -1880,7 +1836,6 @@
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 */,
@ -1902,7 +1857,6 @@
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 */,
@ -1914,10 +1868,8 @@
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 */,
@ -1929,10 +1881,8 @@
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 */,
@ -1949,12 +1899,10 @@
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 */,
@ -2224,6 +2172,7 @@
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 = "";
@ -2252,6 +2201,7 @@
);
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 = "";

View File

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

View File

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

View File

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

View File

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

View File

@ -2,22 +2,6 @@
<!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>
@ -32,6 +16,19 @@
<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>
@ -59,7 +56,7 @@
<key>NSMicrophoneUsageDescription</key>
<string>Post videos from the camera.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos directly from other people&apos;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>

View File

@ -9,7 +9,6 @@
import UIKit
import CrashReporter
import MessageUI
import OSLog
class IssueReporterViewController: UIViewController {
@ -35,8 +34,6 @@ class IssueReporterViewController: UIViewController {
"Tusker Error Report"
}
private let logDataTask: Task<Data?, Never>
@IBOutlet weak var crashReportTextView: UITextView!
@IBOutlet weak var sendReportButton: UIButton!
@ -44,15 +41,6 @@ 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)
}
@ -119,7 +107,6 @@ 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"])
@ -128,14 +115,8 @@ 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
@ -158,29 +139,3 @@ extension IssueReporterViewController: MFMailComposeViewControllerDelegate {
}
}
}
fileprivate func getLogData() -> Data? {
do {
let store = try OSLogStore(scope: .currentProcessIdentifier)
// past hour
let position = store.position(date: Date().addingTimeInterval(-60 * 60))
let entries = try store.getEntries(at: position, matching: NSPredicate(format: "subsystem = %@", Bundle.main.bundleIdentifier!))
var data = Data()
for entry in entries {
guard let entry = entry as? OSLogEntryLog else {
continue
}
data.append(contentsOf: entry.date.formatted(.iso8601).utf8)
data.append(32) // ' '
data.append(91) // '['
data.append(contentsOf: entry.category.utf8)
data.append(93) // ']'
data.append(32) // ' '
data.append(contentsOf: entry.composedMessage.utf8)
data.append(10) // '\n'
}
return data
} catch {
return nil
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -152,7 +152,7 @@ class BaseStatusTableViewCell: UITableViewCell {
updateGrayscaleableUI(account: account, status: status)
updateUIForPreferences(account: account, status: status)
cardView.updateUI(status: status)
cardView.card = status.card
cardView.isHidden = status.card == nil
cardView.navigationDelegate = delegate
cardView.actionProvider = delegate
@ -384,16 +384,93 @@ class BaseStatusTableViewCell: UITableViewCell {
@IBAction func favoritePressed() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
Task {
await FavoriteService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleFavorite()
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
}
}
}
}
@IBAction func reblogPressed() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
Task {
await ReblogService(status: status, mastodonController: mastodonController, presenter: delegate!).toggleReblog()
// 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)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,12 +35,14 @@ struct ToastConfiguration {
}
extension ToastConfiguration {
init(from error: Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) {
init(from error: Client.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 = """
@ -54,17 +56,9 @@ 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: Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) {
init(from error: Client.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)