Compare commits

...

14 Commits

Author SHA1 Message Date
Shadowfacts 7449688bfe Bump build number and update changelog 2023-05-07 19:44:04 -04:00
Shadowfacts 63612b2fb0 Make notification cells subclasses of UICollectionViewListCell 2023-05-07 16:35:01 -04:00
Shadowfacts 8e010c7fa5 Remove unused notifications and status table view code 2023-05-07 15:11:35 -04:00
Shadowfacts 3181c47fde Convert rest of notifications screen to collection view 2023-05-07 15:11:35 -04:00
Shadowfacts a133955489 Fix using removed dismiss notification API endpoint 2023-05-07 15:11:35 -04:00
Shadowfacts 7551c79715 Convert status updated notification to collection view cell 2023-05-07 15:11:35 -04:00
Shadowfacts 5a4e387026 Convert poll finished notification to collection view cell 2023-05-07 15:11:35 -04:00
Shadowfacts 00945a0028 Convert follow request notification to collection view cell 2023-05-07 13:44:55 -04:00
Shadowfacts 2b9d384f8f Convert follow notification to collection view cell 2023-05-07 11:02:37 -04:00
Shadowfacts 90efee3f20 Convert action group notification to collection view cell 2023-05-07 11:02:06 -04:00
Shadowfacts 574d1f9134 Initial notifications collection view implementatioan 2023-05-06 20:32:48 -04:00
Shadowfacts 25e82d828f Fix presented VC getting dismissed after closing expanded attachment view 2023-05-06 14:33:05 -04:00
Shadowfacts 2eb9e63724 Make language picker sheet half-height, fix appearance in non-pure-black dark mode 2023-05-06 14:28:12 -04:00
Shadowfacts d85f74f365 Fix crash due to layout loop when laying out fields on certain profiles
Closes #378

Also make field layout more consistent, and tweak appearance
2023-05-06 14:16:43 -04:00
37 changed files with 2060 additions and 3923 deletions

View File

@ -1,6 +1,21 @@
# Changelog
## 2023.5 (83)
This build contains significant refactors to the notifications screen, please report any issues you encounter.
Features/Improvements:
- Tweak appearance of profile fields
- Make language picker sheet half-height
Bugfixes:
- Fix crash when laying out profile fields on certain accounts
- Fix other presented screens getting dismissed when opened after closing expanded attachment view
- Fix janky status collapse/expand animation on notifications screen
- Fix link previews not appearing in notifications
## 2023.5 (81)
Further improvements and fixes to the Compose screen, see below. Features are frozen for the upcoming release, please report any bugs you encounter!
Features/Improvements:
- Add expanded attachment view on Compose screen
- Add an attachment, select the description text field, and tap on the expand button on the attachment thumbnail

View File

@ -119,6 +119,7 @@ public final class ComposeController: ViewController {
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environmentObject(draft)
.environmentObject(mastodonController.instanceFeatures)
.environment(\.composeUIConfig, config)
}
public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
@ -450,3 +451,13 @@ private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
value = nextValue()
}
}
private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
static let defaultValue = ComposeUIConfig()
}
extension EnvironmentValues {
var composeUIConfig: ComposeUIConfig {
get { self[ComposeUIConfigEnvironmentKey.self] }
set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
}
}

View File

@ -69,6 +69,7 @@ struct LanguagePicker: View {
NavigationStack {
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
}
.presentationDetents([.large, .medium])
}
}
}
@ -78,6 +79,8 @@ private struct LanguagePickerList: View {
@Binding var languageCode: Locale.LanguageCode
@Binding var hasChangedSelection: Bool
@Binding var isPresented: Bool
@Environment(\.composeUIConfig.groupedBackgroundColor) private var groupedBackgroundColor
@Environment(\.composeUIConfig.groupedCellBackgroundColor) private var groupedCellBackgroundColor
@State private var recentLangs: [Lang] = []
@State private var langs: [Lang] = []
@State private var filteredLangs: [Lang]?
@ -102,6 +105,7 @@ private struct LanguagePickerList: View {
ForEach(recentLangs) { lang in
button(for: lang)
}
.listRowBackground(groupedCellBackgroundColor)
} header: {
Text("Recently Used")
}
@ -110,11 +114,16 @@ private struct LanguagePickerList: View {
ForEach(filteredLangs ?? langs) { lang in
button(for: lang)
}
.listRowBackground(groupedCellBackgroundColor)
} header: {
Text("All Languages")
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
.searchable(text: $query)
.scrollDismissesKeyboard(.interactively)
.navigationTitle("Post Language")
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View File

@ -56,6 +56,7 @@ private struct ViewControllerPresenter: UIViewControllerRepresentable {
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
isPresented = false
didPresent = false
}
}
}

View File

@ -70,12 +70,12 @@ public final class Account: AccountProtocol, Decodable, Sendable {
}
}
public static func authorizeFollowRequest(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
public static func authorizeFollowRequest(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize")
}
public static func rejectFollowRequest(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject")
public static func rejectFollowRequest(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject")
}
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {

View File

@ -30,9 +30,7 @@ public struct Notification: Decodable, Sendable {
}
public static func dismiss(id notificationID: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
"id" => notificationID
]))
return Request<Empty>(method: .post, path: "/api/v1/notifications/\(notificationID)/dismiss")
}
private enum CodingKeys: String, CodingKey {

View File

@ -8,14 +8,14 @@
import Foundation
public struct NotificationGroup: Identifiable, Hashable {
public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification]
public let id: String
public let kind: Notification.Kind
public let statusState: CollapseState?
@MainActor
init?(notifications: [Notification]) {
public init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil }
self.notifications = notifications
self.id = notifications.first!.id

View File

@ -32,14 +32,12 @@
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */; };
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F232442372B005F8713 /* StatusMO.swift */; };
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; };
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */; };
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */; };
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; };
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; };
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */; };
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 */; };
@ -55,8 +53,6 @@
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* ProfileViewController.swift */; };
D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75872932DB6000C0B37F /* StatusSwipeActions.swift */; };
D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */; };
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */; };
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.swift */; };
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; };
@ -118,20 +114,24 @@
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */; };
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; };
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; };
D646DCAC2A06C8840059ECEB /* ProfileFieldValueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */; };
D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */; };
D646DCD22A06F2510059ECEB /* NotificationsCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */; };
D646DCD42A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD32A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift */; };
D646DCD62A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD52A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift */; };
D646DCD82A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */; };
D646DCDA2A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */; };
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */; };
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */; };
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; };
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; };
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; };
D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18D23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift */; };
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; };
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */; };
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */; };
D6552367289870790048A653 /* ScreenCorners in Frameworks */ = {isa = PBXBuildFile; productRef = D6552366289870790048A653 /* ScreenCorners */; };
D659F35E2953A212002D944A /* TTTKit in Frameworks */ = {isa = PBXBuildFile; productRef = D659F35D2953A212002D944A /* TTTKit */; };
D659F36229541065002D944A /* TTTView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D659F36129541065002D944A /* TTTView.swift */; };
@ -149,12 +149,9 @@
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */; };
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */; };
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; };
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; };
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; };
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */; };
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F02134D5050057A976 /* UIViewController+Delegates.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 */; };
@ -214,10 +211,6 @@
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; };
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; };
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */; };
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */; };
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */; };
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; };
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; };
D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4531529EF64BA00032932 /* ShareViewController.swift */; };
@ -271,7 +264,6 @@
D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; };
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; };
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; };
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; };
@ -427,14 +419,12 @@
D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SuggestedProfileCardCollectionViewCell.xib; sourceTree = "<group>"; };
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = "<group>"; };
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = "<group>"; };
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
D60E2F232442372B005F8713 /* StatusMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMO.swift; sourceTree = "<group>"; };
D60E2F252442372B005F8713 /* AccountMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMO.swift; sourceTree = "<group>"; };
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazilyDecoding.swift; sourceTree = "<group>"; };
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonCachePersistentStore.swift; sourceTree = "<group>"; };
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = "<group>"; };
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; };
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; 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>"; };
@ -449,8 +439,6 @@
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D61F75872932DB6000C0B37F /* StatusSwipeActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSwipeActions.swift; sourceTree = "<group>"; };
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActionsPrefsView.swift; sourceTree = "<group>"; };
D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationTableViewCell.swift; sourceTree = "<group>"; };
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; };
D61F758F29353B4300C0B37F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = "<group>"; };
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
@ -511,20 +499,24 @@
D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = "<group>"; };
D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; };
D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; };
D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldValueView.swift; sourceTree = "<group>"; };
D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldVerificationView.swift; sourceTree = "<group>"; };
D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCollectionViewController.swift; sourceTree = "<group>"; };
D646DCD32A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupCollectionViewCell.swift; sourceTree = "<group>"; };
D646DCD52A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowNotificationGroupCollectionViewCell.swift; sourceTree = "<group>"; };
D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationCollectionViewCell.swift; sourceTree = "<group>"; };
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedNotificationCollectionViewCell.swift; sourceTree = "<group>"; };
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationCollectionViewCell.swift; sourceTree = "<group>"; };
D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = "<group>"; };
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = "<group>"; };
D64BC18D23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationTableViewCell.swift; sourceTree = "<group>"; };
D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FollowRequestNotificationTableViewCell.xib; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableTimelineLikeTableViewController.swift; sourceTree = "<group>"; };
D659F36129541065002D944A /* TTTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTTView.swift; sourceTree = "<group>"; };
D65B4B532971F71D00DABDFB /* EditedReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditedReport.swift; sourceTree = "<group>"; };
D65B4B552971F98300DABDFB /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; };
@ -544,11 +536,8 @@
D65F613523AFD65900F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D65F613723AFD65D00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = "<group>"; };
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = "<group>"; };
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = "<group>"; };
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = "<group>"; };
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = "<group>"; };
D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimelineStatusTableViewCell.xib; sourceTree = "<group>"; };
D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Delegates.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>"; };
@ -610,10 +599,6 @@
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; };
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupTableViewCell.swift; sourceTree = "<group>"; };
D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActionNotificationGroupTableViewCell.xib; sourceTree = "<group>"; };
D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowNotificationGroupTableViewCell.swift; sourceTree = "<group>"; };
D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FollowNotificationGroupTableViewCell.xib; sourceTree = "<group>"; };
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = "<group>"; };
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = "<group>"; };
D6A4531329EF64BA00032932 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@ -666,7 +651,6 @@
D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = "<group>"; };
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; };
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
@ -891,7 +875,6 @@
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */,
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */,
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */,
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */,
D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */,
D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */,
D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */,
@ -1064,7 +1047,12 @@
isa = PBXGroup;
children = (
D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */,
D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */,
D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */,
D646DCD32A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift */,
D646DCD52A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift */,
D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */,
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */,
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */,
);
path = Notifications;
sourceTree = "<group>";
@ -1115,9 +1103,6 @@
D641C78A213DD926004B4513 /* Status */ = {
isa = PBXGroup;
children = (
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */,
D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */,
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */,
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */,
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */,
D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */,
@ -1136,29 +1121,14 @@
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */,
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */,
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */,
D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */,
D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */,
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */,
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */,
);
path = "Profile Header";
sourceTree = "<group>";
};
D641C78C213DD937004B4513 /* Notifications */ = {
isa = PBXGroup;
children = (
D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */,
D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */,
D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */,
D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */,
D64BC18D23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift */,
D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */,
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */,
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */,
D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */,
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */,
);
path = Notifications;
sourceTree = "<group>";
};
D646C954213B364600269FB5 /* Transitions */ = {
isa = PBXGroup;
children = (
@ -1398,7 +1368,6 @@
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */,
D61AC1DA232EA43100C54D2D /* Instance Cell */,
D641C78C213DD937004B4513 /* Notifications */,
D623A53B2635F4E20095BD04 /* Poll */,
D641C78B213DD92F004B4513 /* Profile Header */,
D641C78A213DD926004B4513 /* Status */,
@ -1412,7 +1381,6 @@
children = (
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */,
D6895DC128D65274006341DA /* CustomAlertController.swift */,
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */,
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */,
@ -1835,7 +1803,6 @@
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */,
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */,
@ -1844,12 +1811,7 @@
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */,
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
D601FA84297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib in Resources */,
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */,
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */,
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1944,7 +1906,6 @@
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */,
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
@ -1962,7 +1923,6 @@
D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */,
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */,
D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */,
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
@ -1994,9 +1954,9 @@
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */,
D646DCD82A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift in Sources */,
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */,
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */,
D6D94955298963A900C59229 /* Colors.swift in Sources */,
@ -2005,7 +1965,6 @@
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */,
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */,
@ -2024,6 +1983,7 @@
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */,
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
D646DCD22A06F2510059ECEB /* NotificationsCollectionViewController.swift in Sources */,
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
@ -2032,10 +1992,12 @@
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */,
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */,
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
D646DCDA2A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift in Sources */,
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
@ -2059,6 +2021,7 @@
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
D646DCD62A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift in Sources */,
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
@ -2086,7 +2049,6 @@
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
D659F36229541065002D944A /* TTTView.swift in Sources */,
D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */,
@ -2106,7 +2068,6 @@
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */,
@ -2118,6 +2079,7 @@
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */,
D646DCAC2A06C8840059ECEB /* ProfileFieldValueView.swift in Sources */,
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
@ -2128,12 +2090,10 @@
D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */,
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */,
D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */,
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
@ -2146,6 +2106,7 @@
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D646DCD42A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift in Sources */,
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */,
@ -2153,7 +2114,6 @@
D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */,
D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */,
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
@ -2166,7 +2126,6 @@
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 */,
@ -2214,6 +2173,7 @@
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -2382,7 +2342,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81;
CURRENT_PROJECT_VERSION = 83;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2448,7 +2408,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81;
CURRENT_PROJECT_VERSION = 83;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2474,7 +2434,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81;
CURRENT_PROJECT_VERSION = 83;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2503,7 +2463,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81;
CURRENT_PROJECT_VERSION = 83;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2532,7 +2492,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81;
CURRENT_PROJECT_VERSION = 83;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2687,7 +2647,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81;
CURRENT_PROJECT_VERSION = 83;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2718,7 +2678,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81;
CURRENT_PROJECT_VERSION = 83;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2824,7 +2784,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81;
CURRENT_PROJECT_VERSION = 83;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2850,7 +2810,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81;
CURRENT_PROJECT_VERSION = 83;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -1,5 +1,5 @@
//
// UIViewController+StatusTableViewCellDelegate.swift
// UIViewController+Delegate.swift
// Tusker
//
// Created by Shadowfacts on 8/27/18.

View File

@ -1,177 +0,0 @@
//
// TrendingLinkTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 4/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import WebURLFoundationExtras
class TrendingLinkTableViewCell: UITableViewCell {
private var card: Card?
private var isGrayscale = false
private var thumbnailRequest: ImageCache.Request?
private let thumbnailView = UIImageView()
private let titleLabel = UILabel()
private let providerLabel = UILabel()
private let activityLabel = UILabel()
private let historyView = TrendHistoryView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
thumbnailView.contentMode = .scaleAspectFill
thumbnailView.clipsToBounds = true
titleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .headline).withSymbolicTraits(.traitBold)!, size: 0)
titleLabel.numberOfLines = 2
providerLabel.font = .preferredFont(forTextStyle: .subheadline)
activityLabel.font = .preferredFont(forTextStyle: .caption1)
let vStack = UIStackView(arrangedSubviews: [
titleLabel,
providerLabel,
activityLabel,
])
vStack.axis = .vertical
vStack.spacing = 4
let hStack = UIStackView(arrangedSubviews: [
thumbnailView,
vStack,
historyView,
])
hStack.axis = .horizontal
hStack.spacing = 4
hStack.alignment = .center
hStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(hStack)
NSLayoutConstraint.activate([
thumbnailView.heightAnchor.constraint(equalToConstant: 75),
thumbnailView.widthAnchor.constraint(equalTo: thumbnailView.heightAnchor),
historyView.widthAnchor.constraint(equalToConstant: 75),
historyView.heightAnchor.constraint(equalToConstant: 44),
hStack.leadingAnchor.constraint(equalToSystemSpacingAfter: safeAreaLayoutGuide.leadingAnchor, multiplier: 1),
safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: hStack.trailingAnchor, multiplier: 1),
hStack.topAnchor.constraint(equalToSystemSpacingBelow: topAnchor, multiplier: 1),
bottomAnchor.constraint(equalToSystemSpacingBelow: hStack.bottomAnchor, multiplier: 1),
])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
thumbnailView.layer.cornerRadius = 0.05 * thumbnailView.bounds.width
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListGroupedCell(for: state)
}
func updateUI(card: Card) {
self.card = card
self.thumbnailView.image = nil
updateGrayscaleableUI(card: card)
updateUIForPreferences()
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title
titleLabel.isHidden = title.isEmpty
let provider = card.providerName?.trimmingCharacters(in: .whitespacesAndNewlines)
providerLabel.text = provider
providerLabel.isHidden = provider?.isEmpty ?? true
if let history = card.history {
let sorted = history.sorted(by: { $0.day < $1.day })
let lastTwo = sorted[(sorted.count - 2)...]
let accounts = lastTwo.map(\.accounts).reduce(0, +)
let uses = lastTwo.map(\.uses).reduce(0, +)
let format = NSLocalizedString("trending hashtag info", comment: "trending hashtag posts and people")
activityLabel.text = String.localizedStringWithFormat(format, accounts, uses)
activityLabel.isHidden = false
} else {
activityLabel.isHidden = true
}
historyView.setHistory(card.history)
historyView.isHidden = card.history == nil || card.history!.count < 2
}
@objc private func updateUIForPreferences() {
if isGrayscale != Preferences.shared.grayscaleImages,
let card {
updateGrayscaleableUI(card: card)
}
}
private func updateGrayscaleableUI(card: Card) {
isGrayscale = Preferences.shared.grayscaleImages
if let imageURL = card.image,
let url = URL(imageURL) {
thumbnailRequest = ImageCache.attachments.get(url, completion: { _, image in
guard let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
return
}
DispatchQueue.main.async {
self.thumbnailView.image = transformedImage
}
})
if thumbnailRequest != nil {
loadBlurHash(card: card)
}
}
}
private func loadBlurHash(card: Card) {
guard let hash = card.blurhash else {
return
}
AttachmentView.queue.async { [weak self] in
let size: CGSize
if let width = card.width, let height = card.height {
let aspectRatio = CGFloat(width) / CGFloat(height)
if aspectRatio > 1 {
size = CGSize(width: 32, height: 32 / aspectRatio)
} else {
size = CGSize(width: 32 * aspectRatio, height: 32)
}
} else {
size = CGSize(width: 32, height: 32)
}
guard let preview = UIImage(blurHash: hash, size: size) else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self,
self.card?.url == card.url,
self.thumbnailView.image == nil else {
return
}
self.thumbnailView.image = preview
}
}
}
}

View File

@ -0,0 +1,263 @@
//
// ActionNotificationGroupCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 5/6/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
private let iconView = UIImageView().configure {
$0.tintColor = UIColor(red: 1, green: 204/255, blue: 0, alpha: 1)
$0.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([
$0.widthAnchor.constraint(equalToConstant: 30),
$0.heightAnchor.constraint(equalToConstant: 30),
])
}
private let avatarStack = UIStackView().configure {
$0.axis = .horizontal
$0.alignment = .fill
$0.spacing = 4
}
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)
$0.adjustsFontForContentSizeCategory = true
}
private lazy var hStack = UIStackView(arrangedSubviews: [
avatarStack,
UIView().configure {
$0.backgroundColor = .clear
$0.setContentHuggingPriority(.init(249), for: .horizontal)
},
timestampLabel,
]).configure {
$0.axis = .horizontal
$0.alignment = .fill
$0.spacing = 8
let heightConstraint = $0.heightAnchor.constraint(equalToConstant: 30)
// the collection view cell imposes a height constraint before it's calculated the actual height
// so let this constraint be broken temporarily to avoid unsatisfiable constraints log spam
heightConstraint.priority = .init(999)
heightConstraint.isActive = true
}
private lazy var actionLabel = MultiSourceEmojiLabel().configure {
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true
$0.numberOfLines = 2
$0.lineBreakMode = .byTruncatingTail
$0.combiner = { [unowned self] in self.updateActionLabel(names: $0) }
}
private let statusContentLabel = UILabel().configure {
$0.textColor = .secondaryLabel
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true
$0.numberOfLines = 3
$0.lineBreakMode = .byTruncatingTail
}
private lazy var vStack = UIStackView(arrangedSubviews: [
hStack,
actionLabel,
statusContentLabel,
]).configure {
$0.axis = .vertical
}
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
private var mastodonController: MastodonController { delegate!.apiController }
private var group: NotificationGroup!
private var statusID: String!
private var updateTimestampWorkItem: DispatchWorkItem?
deinit {
updateTimestampWorkItem?.cancel()
}
override init(frame: CGRect) {
super.init(frame: frame)
iconView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconView)
vStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(vStack)
NSLayoutConstraint.activate([
iconView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
func updateUI(group: NotificationGroup) {
guard group.kind == .favourite || group.kind == .reblog,
let firstNotification = group.notifications.first,
let status = firstNotification.status else {
fatalError()
}
self.group = group
self.statusID = status.id
switch group.kind {
case .favourite:
iconView.image = UIImage(systemName: "star.fill")
case .reblog:
iconView.image = UIImage(systemName: "repeat")
default:
fatalError()
}
updateTimestamp()
let people = group.notifications.compactMap {
mastodonController.persistentContainer.account(for: $0.account.id)
}
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) {
let imageView = CachedImageView(cache: .avatars)
imageView.contentMode = .scaleAspectFit
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
avatarStack.addArrangedSubview(imageView)
imageView.update(for: avatarURL)
}
NSLayoutConstraint.activate(avatarStack.arrangedSubviews.map {
$0.widthAnchor.constraint(equalTo: $0.heightAnchor)
})
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)
// todo: use htmlconverter
let doc = try! SwiftSoup.parseBodyFragment(status.content)
statusContentLabel.text = try! doc.text()
}
@objc private func updateUIForPreferences() {
for view in avatarStack.arrangedSubviews {
view.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: view)
}
}
private func updateTimestamp() {
guard let notification = group.notifications.first else {
fatalError()
}
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
} else {
updateTimestampWorkItem = nil
}
}
private func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
let verb: String
switch group.kind {
case .favourite:
verb = "Favorited"
case .reblog:
verb = "Reblogged"
default:
fatalError()
}
// todo: figure out how to localize this
let str = NSMutableAttributedString(string: "\(verb) by ")
switch names.count {
case 1:
str.append(names.first!)
case 2:
str.append(names.first!)
str.append(NSAttributedString(string: " and "))
str.append(names.last!)
default:
for (index, name) in names.enumerated() {
str.append(name)
if index < names.count - 2 {
str.append(NSAttributedString(string: ", "))
} else if index == names.count - 2 {
str.append(NSAttributedString(string: ", and "))
}
}
}
return str
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
let first = group.notifications.first!
var str = ""
switch group.kind {
case .favourite:
str += "Favorited by "
case .reblog:
str += "Reblogged by "
default:
return nil
}
str += first.account.displayNameWithoutCustomEmoji
if group.notifications.count > 1 {
str += " and \(group.notifications.count - 1) more"
}
str += ", \(first.createdAt.formatted(.relative(presentation: .numeric))), "
str += statusContentLabel.text ?? ""
return str
}
set {}
}
}

View File

@ -0,0 +1,212 @@
//
// FollowNotificationGroupCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 5/7/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class FollowNotificationGroupCollectionViewCell: UICollectionViewListCell {
private let iconView = UIImageView(image: UIImage(systemName: "person.fill.badge.plus")).configure {
$0.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: 30),
$0.widthAnchor.constraint(equalToConstant: 30),
])
}
private let avatarStack = UIStackView().configure {
$0.axis = .horizontal
$0.alignment = .fill
$0.spacing = 8
}
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)
$0.adjustsFontForContentSizeCategory = true
}
private lazy var hStack = UIStackView(arrangedSubviews: [
avatarStack,
UIView().configure {
$0.backgroundColor = .clear
$0.setContentHuggingPriority(.init(249), for: .horizontal)
},
timestampLabel,
]).configure {
$0.axis = .horizontal
$0.alignment = .fill
let heightConstraint = $0.heightAnchor.constraint(equalToConstant: 30)
heightConstraint.priority = .init(999)
heightConstraint.isActive = true
}
private lazy var actionLabel = MultiSourceEmojiLabel().configure {
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true
$0.numberOfLines = 2
$0.lineBreakMode = .byTruncatingTail
$0.combiner = { [unowned self] in self.updateActionLabel(names: $0) }
}
private lazy var vStack = UIStackView(arrangedSubviews: [
hStack,
actionLabel,
]).configure {
$0.axis = .vertical
$0.alignment = .fill
}
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
private var mastodonController: MastodonController { delegate!.apiController }
private var group: NotificationGroup!
private var updateTimestampWorkItem: DispatchWorkItem?
deinit {
updateTimestampWorkItem?.cancel()
}
override init(frame: CGRect) {
super.init(frame: frame)
iconView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconView)
vStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(vStack)
NSLayoutConstraint.activate([
iconView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
func updateUI(group: NotificationGroup) {
guard group.kind == .follow else {
fatalError()
}
self.group = group
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
actionLabel.setEmojis(pairs: people.map {
($0.displayOrUserName, $0.emojis)
}, identifier: group.id)
updateTimestamp()
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) {
let imageView = CachedImageView(cache: .avatars)
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.update(for: avatarURL)
avatarStack.addArrangedSubview(imageView)
}
NSLayoutConstraint.activate(avatarStack.arrangedSubviews.map {
$0.widthAnchor.constraint(equalTo: $0.heightAnchor)
})
}
private func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
// todo: figure out how to localize this
let str = NSMutableAttributedString(string: "Followed by ")
switch names.count {
case 1:
str.append(names.first!)
case 2:
str.append(names.first!)
str.append(NSAttributedString(string: " and "))
str.append(names.last!)
default:
for (index, name) in names.enumerated() {
str.append(name)
if index < names.count - 2 {
str.append(NSAttributedString(string: ", "))
} else if index == names.count - 2 {
str.append(NSAttributedString(string: ", and "))
}
}
}
return str
}
@objc private func updateUIForPreferences() {
for view in avatarStack.arrangedSubviews {
view.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: view)
}
}
private func updateTimestamp() {
guard let notification = group.notifications.first else {
fatalError("Missing cached notification")
}
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
} else {
updateTimestampWorkItem = nil
}
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
let first = group.notifications.first!
var str = "Followed by "
str += first.account.displayNameWithoutCustomEmoji
if group.notifications.count > 1 {
str += " and \(group.notifications.count - 1) more"
}
str += ", \(first.createdAt.formatted(.relative(presentation: .numeric)))"
return str
}
set {}
}
}

View File

@ -1,99 +1,162 @@
//
// FollowRequestNotificationTableViewCell.swift
// FollowRequestNotificationCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 1/4/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 5/7/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class FollowRequestNotificationTableViewCell: UITableViewCell {
class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
private let iconView = UIImageView(image: UIImage(systemName: "person.fill")).configure {
$0.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: 30),
$0.widthAnchor.constraint(equalToConstant: 30),
])
}
private let avatarImageView = CachedImageView(cache: .avatars).configure {
$0.layer.masksToBounds = true
NSLayoutConstraint.activate([
$0.widthAnchor.constraint(equalTo: $0.heightAnchor),
])
}
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)
$0.adjustsFontForContentSizeCategory = true
}
private lazy var hStack = UIStackView(arrangedSubviews: [
avatarImageView,
UIView().configure {
$0.backgroundColor = .clear
$0.setContentHuggingPriority(.init(249), for: .horizontal)
},
timestampLabel,
]).configure {
$0.axis = .horizontal
$0.alignment = .fill
let heightConstraint = $0.heightAnchor.constraint(equalToConstant: 30)
heightConstraint.priority = .init(999)
heightConstraint.isActive = true
}
private lazy var actionLabel = EmojiLabel().configure {
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true
$0.numberOfLines = 2
$0.lineBreakMode = .byTruncatingTail
}
private lazy var acceptButton = UIButton(configuration: {
var config = UIButton.Configuration.plain()
config.image = UIImage(systemName: "checkmark.circle.fill")
config.title = "Accept"
return config
}()).configure {
$0.addTarget(self, action: #selector(acceptButtonPressed), for: .touchUpInside)
}
private lazy var rejectButton = UIButton(configuration: {
var config = UIButton.Configuration.plain()
config.image = UIImage(systemName: "xmark.circle.fill")
config.title = "Reject"
return config
}()).configure {
$0.addTarget(self, action: #selector(rejectButtonPressed), for: .touchUpInside)
}
private lazy var actionButtonsStack = UIStackView(arrangedSubviews: [
acceptButton,
rejectButton,
]).configure {
$0.axis = .horizontal
$0.distribution = .fillEqually
}
private lazy var vStack = UIStackView(arrangedSubviews: [
hStack,
actionLabel,
actionButtonsStack,
]).configure {
$0.axis = .vertical
$0.alignment = .fill
}
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController! { delegate?.apiController }
private var mastodonController: MastodonController { delegate!.apiController }
@IBOutlet weak var stackView: UIStackView!
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var actionLabel: EmojiLabel!
@IBOutlet weak var actionButtonsStackView: UIStackView!
@IBOutlet weak var acceptButton: UIButton!
@IBOutlet weak var rejectButton: UIButton!
private var notification: Pachyderm.Notification!
var notification: Pachyderm.Notification?
var account: Account!
private var avatarRequest: ImageCache.Request?
private var updateTimestampWorkItem: DispatchWorkItem?
private var isGrayscale = false
deinit {
updateTimestampWorkItem?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
override init(frame: CGRect) {
super.init(frame: frame)
timestampLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
timestampLabel.adjustsFontForContentSizeCategory = true
iconView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconView)
vStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(vStack)
NSLayoutConstraint.activate([
iconView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
avatarImageView.layer.masksToBounds = true
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
updateUIForPreferences()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
@objc func updateUIForPreferences() {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
if isGrayscale != Preferences.shared.grayscaleImages,
let account = self.account {
updateUI(account: account)
}
}
func updateUI(notification: Pachyderm.Notification) {
guard notification.kind == .followRequest,
let account = mastodonController.persistentContainer.account(for: notification.account.id) else {
fatalError()
}
self.notification = notification
updateUI(account: notification.account)
updateActionLabel(account: account)
avatarImageView.update(for: account.avatar)
updateTimestamp()
}
func updateUI(account: Account) {
// todo: update to use managed objects
self.account = account
@objc private func updateUIForPreferences() {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
if let account = mastodonController.persistentContainer.account(for: notification.account.id) {
updateActionLabel(account: account)
}
}
private func updateActionLabel(account: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames {
actionLabel.text = "Request to follow from \(account.displayName)"
actionLabel.removeEmojis()
actionLabel.text = "Request to follow from \(account.displayNameWithoutCustomEmoji)"
} else {
actionLabel.text = "Request to follow from \(account.displayName)"
actionLabel.setEmojis(account.emojis, identifier: account.id)
}
if let avatarURL = account.avatar {
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self else { return }
self.avatarRequest = nil
guard self.account == account,
let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
return
}
DispatchQueue.main.async {
self.avatarImageView.image = transformedImage
}
}
actionLabel.text = "Request to follow from \(account.displayOrUserName)"
}
}
@ -125,18 +188,16 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
override func prepareForReuse() {
super.prepareForReuse()
avatarRequest?.cancel()
updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil
}
private func addLabel(_ text: String) {
let label = UILabel()
label.textAlignment = .center
label.font = .boldSystemFont(ofSize: 17)
label.adjustsFontForContentSizeCategory = true
label.text = text
self.stackView.addArrangedSubview(label)
self.vStack.addArrangedSubview(label)
}
// MARK: Accessibility
@ -164,17 +225,17 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
// MARK: - Interaction
@IBAction func rejectButtonPressed() {
@objc func rejectButtonPressed() {
acceptButton.isEnabled = false
rejectButton.isEnabled = false
Task {
let request = Account.rejectFollowRequest(account)
let request = Account.rejectFollowRequest(notification.account.id)
do {
_ = try await mastodonController.run(request)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
self.actionButtonsStackView.isHidden = true
self.actionButtonsStack.isHidden = true
self.addLabel(NSLocalizedString("Rejected", comment: "rejected follow request label"))
} catch let error as Client.Error {
acceptButton.isEnabled = true
@ -190,17 +251,17 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
}
}
@IBAction func acceptButtonPressed() {
@objc func acceptButtonPressed() {
acceptButton.isEnabled = false
rejectButton.isEnabled = false
Task {
let request = Account.authorizeFollowRequest(account)
let request = Account.authorizeFollowRequest(notification.account.id)
do {
_ = try await mastodonController.run(request)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
self.actionButtonsStackView.isHidden = true
self.actionButtonsStack.isHidden = true
self.addLabel(NSLocalizedString("Accepted", comment: "accepted follow request label"))
} catch let error as Client.Error {
acceptButton.isEnabled = true
@ -218,30 +279,3 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
}
}
extension FollowRequestNotificationTableViewCell: SelectableTableViewCell {
func didSelectCell() {
delegate?.selected(account: account.id)
}
}
extension FollowRequestNotificationTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let mastodonController = mastodonController else { return nil }
return (content: {
return ProfileViewController(accountID: self.account.id, mastodonController: mastodonController)
}, actions: {
return []
})
}
}
extension FollowRequestNotificationTableViewCell: DraggableTableViewCell {
func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] {
let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
}

View File

@ -0,0 +1,574 @@
//
// NotificationsCollectionViewController.swift
// Tusker
//
// Created by Shadowfacts on 5/6/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
import Sentry
class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
weak var mastodonController: MastodonController!
private let allowedTypes: [Pachyderm.Notification.Kind]
private let groupTypes = [Pachyderm.Notification.Kind.favourite, .reblog, .follow]
private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var newer: RequestRange?
private var older: RequestRange?
init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) {
self.allowedTypes = allowedTypes
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
self.controller = TimelineLikeController(delegate: self)
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications"))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
}
config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in
let dismissAction = UIContextualAction(style: .destructive, title: "Dismiss") { _, _, completion in
Task {
await self.dismissNotificationsInGroup(at: indexPath)
completion(true)
}
}
dismissAction.accessibilityLabel = "Dismiss Notification"
dismissAction.image = UIImage(systemName: "clear.fill")
let cellConfig = (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
let config = UISwipeActionsConfiguration(actions: (cellConfig?.actions ?? []) + [dismissAction])
config.performsFirstActionWithFullSwipe = cellConfig?.performsFirstActionWithFullSwipe ?? false
return config
}
config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
return sectionSeparatorConfiguration
}
var config = sectionSeparatorConfiguration
if item.hidesSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
return config
}
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
return section
}
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
registerTimelineLikeCells()
dataSource = createDataSource()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, NotificationGroup> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
let statusID = itemIdentifier.notifications.first!.status!.id
let statusState = itemIdentifier.statusState!
cell.updateUI(statusID: statusID, state: statusState, filterResult: .allow, precomputedContent: nil)
}
let actionGroupCell = UICollectionView.CellRegistration<ActionNotificationGroupCollectionViewCell, NotificationGroup> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(group: itemIdentifier)
}
let followCell = UICollectionView.CellRegistration<FollowNotificationGroupCollectionViewCell, NotificationGroup> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(group: itemIdentifier)
}
let followRequestCell = UICollectionView.CellRegistration<FollowRequestNotificationCollectionViewCell, Pachyderm.Notification> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(notification: itemIdentifier)
}
let pollCell = UICollectionView.CellRegistration<PollFinishedNotificationCollectionViewCell, Pachyderm.Notification> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(notification: itemIdentifier)
}
let updateCell = UICollectionView.CellRegistration<StatusUpdatedNotificationCollectionViewCell, Pachyderm.Notification> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(notification: itemIdentifier)
}
let unknownCell = UICollectionView.CellRegistration<UICollectionViewListCell, ()> { cell, indexPath, itemIdentifier in
var config = cell.defaultContentConfiguration()
config.text = "Unknown Notification"
cell.contentConfiguration = config
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .group(let group):
switch group.kind {
case .status, .mention:
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: group)
case .favourite, .reblog:
return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group)
case .follow:
return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group)
case .followRequest:
return collectionView.dequeueConfiguredReusableCell(using: followRequestCell, for: indexPath, item: group.notifications.first!)
case .poll:
return collectionView.dequeueConfiguredReusableCell(using: pollCell, for: indexPath, item: group.notifications.first!)
case .update:
return collectionView.dequeueConfiguredReusableCell(using: updateCell, for: indexPath, item: group.notifications.first!)
default:
return collectionView.dequeueConfiguredReusableCell(using: unknownCell, for: indexPath, item: ())
}
case .loadingIndicator:
return self.loadingIndicatorCell(for: indexPath)
case .confirmLoadMore:
return self.confirmLoadMoreCell(for: indexPath)
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
if case .notLoadedInitial = controller.state {
Task {
await controller.loadInitial()
}
}
}
@objc func refresh() {
Task { @MainActor in
if case .notLoadedInitial = controller.state {
await controller.loadInitial()
} else {
await controller.loadNewer()
}
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
}
}
private func dismissNotificationsInGroup(at indexPath: IndexPath) async {
guard case .group(let group) = dataSource.itemIdentifier(for: indexPath) else {
return
}
let notifications = group.notifications
let dismissFailedIndices = await withTaskGroup(of: (Int, Bool).self) { group -> [Int] in
for (index, notification) in notifications.enumerated() {
group.addTask {
do {
_ = try await self.mastodonController.run(Notification.dismiss(id: notification.id))
return (index, true)
} catch {
return (index, false)
}
}
}
return await group.reduce(into: [], { partialResult, value in
if !value.1 {
partialResult.append(value.0)
}
})
}
var snapshot = dataSource.snapshot()
if dismissFailedIndices.isEmpty {
snapshot.deleteItems([.group(group)])
} else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count {
let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] }
snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed)!)], afterItem: .group(group))
snapshot.deleteItems([.group(group)])
}
await apply(snapshot, animatingDifferences: true)
}
}
extension NotificationsCollectionViewController {
enum Section: TimelineLikeCollectionViewSection {
case notifications
case footer
static var entries: Self { .notifications }
}
enum Item: TimelineLikeCollectionViewItem {
case group(NotificationGroup)
case loadingIndicator
case confirmLoadMore
static func fromTimelineItem(_ item: NotificationGroup) -> Self {
return .group(item)
}
var group: NotificationGroup? {
if case .group(let group) = self {
return group
} else {
return nil
}
}
var isSelectable: Bool {
switch self {
case .group(_):
return true
default:
return false
}
}
var hidesSeparators: Bool {
switch self {
case .loadingIndicator, .confirmLoadMore:
return true
default:
return false
}
}
}
}
// MARK: TimelineLikeControllerDelegate
extension NotificationsCollectionViewController {
typealias TimelineItem = NotificationGroup
private static let pageSize = 40
private func request(range: RequestRange) -> Request<[Pachyderm.Notification]> {
if mastodonController.instanceFeatures.notificationsAllowedTypes {
return Client.getNotifications(allowedTypes: allowedTypes, range: range)
} else {
var types = Set(Notification.Kind.allCases)
types.remove(.unknown)
allowedTypes.forEach { types.remove($0) }
return Client.getNotifications(excludedTypes: Array(types), range: range)
}
}
private func validateNotifications(_ notifications: [Pachyderm.Notification]) -> [Pachyderm.Notification] {
return notifications.compactMap { notif in
if notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite) {
let crumb = Breadcrumb(level: .fatal, category: "notifications")
crumb.data = [
"id": notif.id,
"type": notif.kind.rawValue,
"created_at": notif.createdAt.formatted(.iso8601),
"account": notif.account.id,
]
SentrySDK.addBreadcrumb(crumb)
return nil
} else {
return notif
}
}
}
func loadInitial() async throws -> [NotificationGroup] {
let request = self.request(range: .count(NotificationsCollectionViewController.pageSize))
let (notifications, _) = try await mastodonController.run(request)
if !notifications.isEmpty {
self.newer = .after(id: notifications.first!.id, count: NotificationsCollectionViewController.pageSize)
self.older = .before(id: notifications.last!.id, count: NotificationsCollectionViewController.pageSize)
}
let validated = validateNotifications(notifications)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(notifications: validated) {
continuation.resume()
}
}
return NotificationGroup.createGroups(notifications: validated, only: self.groupTypes)
}
func loadNewer() async throws -> [NotificationGroup] {
guard let newer else {
throw Error.noNewer
}
let request = self.request(range: newer)
let (notifications, _) = try await mastodonController.run(request)
if !notifications.isEmpty {
self.newer = .after(id: notifications.first!.id, count: NotificationsCollectionViewController.pageSize)
}
let validated = validateNotifications(notifications)
guard !validated.isEmpty else {
throw Error.allCaughtUp
}
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(notifications: validated) {
continuation.resume()
}
}
let newerGroups = NotificationGroup.createGroups(notifications: validated, only: self.groupTypes)
let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group)
return NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes)
}
func handlePrependItems(_ timelineItems: [NotificationGroup]) async {
// we always replace all, because new items are merged with existing ones
await handleReplaceAllItems(timelineItems)
}
func loadOlder() async throws -> [NotificationGroup] {
guard let older else {
throw Error.noOlder
}
let request = self.request(range: older)
let (notifications, _) = try await mastodonController.run(request)
if !notifications.isEmpty {
self.older = .before(id: notifications.last!.id, count: NotificationsCollectionViewController.pageSize)
}
let validated = validateNotifications(notifications)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(notifications: validated) {
continuation.resume()
}
}
let olderGroups = NotificationGroup.createGroups(notifications: validated, only: self.groupTypes)
let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group)
return NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes)
}
func handleAppendItems(_ timelineItems: [NotificationGroup]) async {
await handleReplaceAllItems(timelineItems)
}
enum Error: TimelineLikeCollectionViewError {
case noNewer
case noOlder
case allCaughtUp
}
}
extension NotificationsCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) 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 case .group(let group) = dataSource.itemIdentifier(for: indexPath) else {
return
}
switch group.kind {
case .mention, .status, .poll, .update:
let statusID = group.notifications.first!.status!.id
let state = group.statusState?.copy() ?? .unknown
selected(status: statusID, state: state)
case .favourite, .reblog:
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id)
let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
show(vc)
case .follow:
let accountIDs = group.notifications.map(\.account.id)
switch accountIDs.count {
case 0:
collectionView.deselectItem(at: indexPath, animated: true)
case 1:
selected(account: accountIDs.first!)
default:
let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController)
vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
show(vc)
}
case .followRequest:
selected(account: group.notifications.first!.account.id)
case .unknown:
collectionView.deselectItem(at: indexPath, animated: true)
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard case .group(let group) = dataSource.itemIdentifier(for: indexPath),
let cell = collectionView.cellForItem(at: indexPath) else {
return nil
}
switch group.kind {
case .mention, .status, .poll, .update:
guard let statusID = group.notifications.first?.status?.id,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
let state = group.statusState?.copy() ?? .unknown
return UIContextMenuConfiguration {
ConversationViewController(for: statusID, state: state, mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.actionsForStatus(status, source: .view(cell), includeStatusButtonActions: group.kind == .poll || group.kind == .update))
}
case .favourite, .reblog:
return UIContextMenuConfiguration(previewProvider: {
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id)
return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
})
case .follow:
let accountIDs = group.notifications.map(\.account.id)
return UIContextMenuConfiguration {
if accountIDs.count == 1 {
return ProfileViewController(accountID: accountIDs.first!, mastodonController: self.mastodonController)
} else {
let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: self.mastodonController)
vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
return vc
}
} actionProvider: { _ in
if accountIDs.count == 1 {
return UIMenu(children: self.actionsForProfile(accountID: accountIDs.first!, source: .view(cell)))
} else {
return nil
}
}
case .followRequest:
let accountID = group.notifications.first!.account.id
return UIContextMenuConfiguration {
ProfileViewController(accountID: accountID, mastodonController: self.mastodonController)
} actionProvider: { _ in
let cell = cell as! FollowRequestNotificationCollectionViewCell
let acceptRejectChildren = [
UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }),
]
let acceptRejectMenu: UIMenu
if #available(iOS 16.0, *) {
acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren)
} else {
acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren)
}
return UIMenu(children: [
acceptRejectMenu,
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
])
}
case .unknown:
return nil
}
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
}
extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard case .group(let group) = dataSource.itemIdentifier(for: indexPath) else {
return []
}
switch group.kind {
case .mention, .status:
// not combiend with .poll and .update below, b/c TimelineStatusCollectionViewCell handles checking whether the poll view is tracking
let cell = collectionView.cellForItem(at: indexPath) as! TimelineStatusCollectionViewCell
return cell.dragItemsForBeginning(session: session)
case .poll, .update:
let status = group.notifications.first!.status!
let provider = NSItemProvider(object: URL(status.url!)! as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
case .favourite, .reblog:
return []
case .follow, .followRequest:
guard group.notifications.count == 1 else {
return []
}
let account = group.notifications.first!.account
let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
case .unknown:
return []
}
}
}
extension NotificationsCollectionViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension NotificationsCollectionViewController: MenuActionProvider {
}
extension NotificationsCollectionViewController: 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)
}
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
}
}

View File

@ -21,7 +21,7 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
self.mastodonController = mastodonController
super.init(pages: [.all, .mentions]) { page in
let vc = NotificationsTableViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
let vc = NotificationsCollectionViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
vc.title = page.title
vc.userActivity = page.userActivity(accountID: mastodonController.accountInfo!.id)
return vc

View File

@ -1,376 +0,0 @@
//
// NotificationsTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/2/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Sentry
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationsTableViewController.Item> {
private let statusCell = "statusCell"
private let actionGroupCell = "actionGroupCell"
private let followGroupCell = "followGroupCell"
private let followRequestCell = "followRequestCell"
private let pollCell = "pollCell"
private let updatedCell = "updatedCell"
private let unknownCell = "unknownCell"
weak var mastodonController: MastodonController!
private let allowedTypes: [Pachyderm.Notification.Kind]
private let groupTypes = [Pachyderm.Notification.Kind.favourite, .reblog, .follow]
private var newer: RequestRange?
private var older: RequestRange?
init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) {
self.allowedTypes = allowedTypes
self.mastodonController = mastodonController
super.init()
dragEnabled = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Notifications", comment: "refresh notifications command discoverability title")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
tableView.register(UINib(nibName: "ActionNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: actionGroupCell)
tableView.register(UINib(nibName: "FollowNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: followGroupCell)
tableView.register(UINib(nibName: "FollowRequestNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: followRequestCell)
tableView.register(UINib(nibName: "PollFinishedTableViewCell", bundle: .main), forCellReuseIdentifier: pollCell)
tableView.register(UINib(nibName: "StatusUpdatedNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: updatedCell)
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
tableView.cellLayoutMarginsFollowReadableWidth = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac
tableView.allowsFocus = true
tableView.backgroundColor = .appBackground
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
var snapshot = self.dataSource.snapshot()
// this is not efficient, since the number of notifications is almost certainly greater than the number of deleted statuses
// but we can't just check if the status is in the data source, since we don't have the corresponding notification/group
let toDelete = snapshot.itemIdentifiers
.filter { item in
guard case .notificationGroup(let group) = item else {
return false
}
return group.kind == .mention && statusIDs.contains(group.notifications.first!.status!.id)
}
if !toDelete.isEmpty {
snapshot.deleteItems(toDelete)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
private func request(range: RequestRange) -> Request<[Pachyderm.Notification]> {
if mastodonController.instanceFeatures.notificationsAllowedTypes {
return Client.getNotifications(allowedTypes: allowedTypes, range: range)
} else {
var types = Set(Notification.Kind.allCases)
allowedTypes.forEach { types.remove($0) }
return Client.getNotifications(excludedTypes: Array(types), range: range)
}
}
// MARK: - DiffableTimelineLikeTableViewController
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
if case .loadingIndicator = item {
return self.loadingIndicatorCell(indexPath: indexPath)
}
let group = item.group!
switch group.kind {
case .mention, .status:
guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as? TimelineStatusTableViewCell else {
fatalError()
}
cell.delegate = self
guard let status = notification.status else {
let crumb = Breadcrumb(level: .fatal, category: "notifications")
crumb.data = [
"id": notification.id,
"type": notification.kind.rawValue,
"created_at": notification.createdAt.formatted(.iso8601),
"account": notification.account.id,
]
SentrySDK.addBreadcrumb(crumb)
fatalError("missing status for \(group.kind) notification")
}
cell.updateUI(statusID: status.id, state: group.statusState!)
return cell
case .favourite, .reblog:
guard let cell = tableView.dequeueReusableCell(withIdentifier: actionGroupCell, for: indexPath) as? ActionNotificationGroupTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(group: group)
return cell
case .follow:
guard let cell = tableView.dequeueReusableCell(withIdentifier: followGroupCell, for: indexPath) as? FollowNotificationGroupTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(group: group)
return cell
case .followRequest:
guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: followRequestCell, for: indexPath) as? FollowRequestNotificationTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(notification: notification)
return cell
case .poll:
guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: pollCell, for: indexPath) as? PollFinishedTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(notification: notification)
return cell
case .update:
guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: updatedCell, for: indexPath) as? StatusUpdatedNotificationTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(notification: notification)
return cell
case .unknown:
let cell = tableView.dequeueReusableCell(withIdentifier: unknownCell, for: indexPath)
cell.textLabel!.text = NSLocalizedString("Unknown Notification", comment: "unknown notification fallback cell text")
return cell
}
}
private func validateNotifications(_ notifications: [Pachyderm.Notification]) -> [Pachyderm.Notification] {
return notifications.compactMap { notif in
if notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite) {
let crumb = Breadcrumb(level: .fatal, category: "notifications")
crumb.data = [
"id": notif.id,
"type": notif.kind.rawValue,
"created_at": notif.createdAt.formatted(.iso8601),
"account": notif.account.id,
]
SentrySDK.addBreadcrumb(crumb)
return nil
} else {
return notif
}
}
}
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
mastodonController.run(request(range: .default)) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(notifications, _):
let notifications = self.validateNotifications(notifications)
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
if !notifications.isEmpty {
self.newer = .after(id: notifications.first!.id, count: nil)
self.older = .before(id: notifications.last!.id, count: nil)
}
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
var snapshot = Snapshot()
snapshot.appendSections([.notifications])
snapshot.appendItems(groups.map { .notificationGroup($0) }, toSection: .notifications)
completion(.success(snapshot))
}
}
}
}
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let older = older else {
completion(.failure(.noOlder))
return
}
mastodonController.run(request(range: older)) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(newNotifications, _):
let newNotifications = self.validateNotifications(newNotifications)
if !newNotifications.isEmpty {
self.older = .before(id: newNotifications.last!.id, count: nil)
}
let olderGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
let existingGroups = currentSnapshot().itemIdentifiers.compactMap(\.group)
let merged = NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.notifications])
snapshot.appendItems(merged.map { .notificationGroup($0) }, toSection: .notifications)
completion(.success(snapshot))
}
}
}
}
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let newer = newer else {
completion(.failure(.noNewer))
return
}
mastodonController.run(request(range: newer)) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(newNotifications, _):
let newNotifications = self.validateNotifications(newNotifications)
guard !newNotifications.isEmpty else {
completion(.failure(.allCaughtUp))
return
}
self.newer = .after(id: newNotifications.first!.id, count: nil)
let newerGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
let existingGroups = currentSnapshot().itemIdentifiers.compactMap(\.group)
let merged = NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.notifications])
snapshot.appendItems(merged.map { .notificationGroup($0) }, toSection: .notifications)
completion(.success(snapshot))
}
}
}
}
private func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
guard let item = dataSource.itemIdentifier(for: indexPath),
let notifications = item.group?.notifications else {
return
}
let group = DispatchGroup()
notifications
.map { Pachyderm.Notification.dismiss(id: $0.id) }
.forEach { (request) in
group.enter()
mastodonController.run(request) { (_) in
group.leave()
}
}
group.notify(queue: .main) {
var snapshot = self.dataSource.snapshot()
snapshot.deleteItems([item])
self.dataSource.apply(snapshot, completion: completion)
}
}
// MARK: - UITableViewDelegate
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let dismissAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Dismiss", comment: "dismiss notification swipe action title")) { (action, view, completion) in
self.dismissNotificationsInGroup(at: indexPath) {
completion(true)
}
}
dismissAction.accessibilityLabel = "Dismiss Notification"
dismissAction.image = UIImage(systemName: "clear.fill")
let cellConfiguration = (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
let config: UISwipeActionsConfiguration
if let cellConfiguration = cellConfiguration {
config = UISwipeActionsConfiguration(actions: cellConfiguration.actions + [dismissAction])
config.performsFirstActionWithFullSwipe = cellConfiguration.performsFirstActionWithFullSwipe
} else {
config = UISwipeActionsConfiguration(actions: [dismissAction])
config.performsFirstActionWithFullSwipe = false
}
return config
}
override func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] {
return [
UIAction(title: "Dismiss Notification", image: UIImage(systemName: "clear.fill"), identifier: .init("dismissnotification"), handler: { (_) in
self.dismissNotificationsInGroup(at: indexPath)
})
]
}
}
extension NotificationsTableViewController {
enum Section: DiffableTimelineLikeSection {
case loadingIndicator
case notifications
}
enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case notificationGroup(NotificationGroup)
var group: NotificationGroup? {
switch self {
case .loadingIndicator:
return nil
case .notificationGroup(let group):
return group
}
}
}
}
extension NotificationsTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension NotificationsTableViewController: MenuActionProvider {
}
extension NotificationsTableViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
if #available(iOS 16.0, *) {
} else {
cellHeightChanged()
}
}
}
extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let group = dataSource.itemIdentifier(for: indexPath)?.group else { continue }
for notification in group.notifications {
guard let avatar = notification.account.avatar else { continue }
ImageCache.avatars.fetchIfNotCached(avatar)
}
}
}
}

View File

@ -0,0 +1,205 @@
//
// PollFinishedNotificationCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 5/7/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
private let iconView = UIImageView(image: UIImage(systemName: "checkmark.square.fill")).configure {
$0.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: 30),
$0.widthAnchor.constraint(equalToConstant: 30),
])
}
private let descriptionLabel = UILabel().configure {
$0.text = "A poll has finished"
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true
$0.setContentHuggingPriority(.init(249), 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)
$0.adjustsFontForContentSizeCategory = true
}
private lazy var hStack = UIStackView(arrangedSubviews: [
descriptionLabel,
timestampLabel,
]).configure {
$0.axis = .horizontal
$0.alignment = .fill
}
private let displayNameLabel = EmojiLabel().configure {
$0.textColor = .secondaryLabel
$0.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)!
$0.adjustsFontForContentSizeCategory = true
$0.numberOfLines = 2
$0.lineBreakMode = .byTruncatingTail
}
private let contentLabel = UILabel().configure {
$0.textColor = .secondaryLabel
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true
$0.numberOfLines = 2
$0.lineBreakMode = .byTruncatingTail
}
private let pollView = StatusPollView()
private lazy var vStack = UIStackView(arrangedSubviews: [
hStack,
displayNameLabel,
contentLabel,
pollView,
]).configure {
$0.axis = .vertical
$0.alignment = .fill
}
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
private var mastodonController: MastodonController { delegate!.apiController }
private var notification: Pachyderm.Notification!
private var updateTimestampWorkItem: DispatchWorkItem?
deinit {
updateTimestampWorkItem?.cancel()
}
override init(frame: CGRect) {
super.init(frame: frame)
iconView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconView)
vStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(vStack)
let vStackBottomConstraint = vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8)
// need something to break during intermediate layouts when the cell imposes a 44pt height :S
vStackBottomConstraint.priority = .init(999)
NSLayoutConstraint.activate([
iconView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
vStackBottomConstraint,
])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
func updateUI(notification: Pachyderm.Notification) {
guard let statusID = notification.status?.id,
let status = mastodonController.persistentContainer.status(for: statusID),
let account = mastodonController.persistentContainer.account(for: notification.account.id),
let poll = status.poll else {
return
}
self.notification = notification
updateTimestamp()
updateDisplayName(account: account)
// todo: use htmlconverter
let doc = try! SwiftSoup.parseBodyFragment(status.content)
contentLabel.text = try! doc.text()
pollView.mastodonController = mastodonController
pollView.toastableViewController = delegate
pollView.updateUI(status: status, poll: poll)
}
@objc private func updateUIForPreferences() {
if let account = mastodonController.persistentContainer.account(for: notification.account.id) {
self.updateDisplayName(account: account)
}
}
private func updateDisplayName(account: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames {
displayNameLabel.text = account.displayNameWithoutCustomEmoji
displayNameLabel.removeEmojis()
} else {
displayNameLabel.text = account.displayOrUserName
displayNameLabel.setEmojis(account.emojis, identifier: account.id)
}
}
private func updateTimestamp() {
guard let notification = notification else { return }
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
}
} else {
updateTimestampWorkItem = nil
}
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let notification else { return nil }
var str = "Poll from "
str += notification.account.displayNameWithoutCustomEmoji
str += " finished "
str += notification.createdAt.formatted(.relative(presentation: .numeric))
if let poll = notification.status?.poll,
poll.options.contains(where: { ($0.votesCount ?? 0) > 0 }) {
let winner = poll.options.max(by: { ($0.votesCount ?? 0) < ($1.votesCount ?? 0) })!
str += ", winning option: \(winner.title)"
}
return str
}
set {}
}
}

View File

@ -0,0 +1,191 @@
//
// StatusUpdatedNotificationCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 5/7/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
class StatusUpdatedNotificationCollectionViewCell: UICollectionViewListCell {
private let iconView = UIImageView(image: UIImage(systemName: "pencil")).configure {
$0.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: 30),
$0.widthAnchor.constraint(equalToConstant: 30),
])
}
private let descriptionLabel = UILabel().configure {
$0.text = "A post was edited"
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true
$0.setContentHuggingPriority(.init(249), 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)
$0.adjustsFontForContentSizeCategory = true
}
private lazy var hStack = UIStackView(arrangedSubviews: [
descriptionLabel,
timestampLabel,
]).configure {
$0.axis = .horizontal
$0.alignment = .fill
}
private let displayNameLabel = EmojiLabel().configure {
$0.textColor = .secondaryLabel
$0.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)!
$0.adjustsFontForContentSizeCategory = true
$0.numberOfLines = 2
$0.lineBreakMode = .byTruncatingTail
}
private let contentLabel = UILabel().configure {
$0.textColor = .secondaryLabel
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true
$0.numberOfLines = 2
$0.lineBreakMode = .byTruncatingTail
}
private lazy var vStack = UIStackView(arrangedSubviews: [
hStack,
displayNameLabel,
contentLabel,
]).configure {
$0.axis = .vertical
$0.alignment = .fill
}
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
private var mastodonController: MastodonController { delegate!.apiController }
private var notification: Pachyderm.Notification!
private var updateTimestampWorkItem: DispatchWorkItem?
deinit {
updateTimestampWorkItem?.cancel()
}
override init(frame: CGRect) {
super.init(frame: frame)
iconView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconView)
vStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(vStack)
NSLayoutConstraint.activate([
iconView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
func updateUI(notification: Pachyderm.Notification) {
guard notification.kind == .update,
let status = notification.status,
let account = mastodonController.persistentContainer.account(for: notification.account.id) else {
return
}
self.notification = notification
updateTimestamp()
updateDisplayName(account: account)
// todo: use htmlconverter
let doc = try! SwiftSoup.parseBodyFragment(status.content)
contentLabel.text = try! doc.text()
}
@objc private func updateUIForPreferences() {
if let account = mastodonController.persistentContainer.account(for: notification.account.id) {
updateDisplayName(account: account)
}
}
private func updateDisplayName(account: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames {
displayNameLabel.text = account.displayNameWithoutCustomEmoji
displayNameLabel.removeEmojis()
} else {
displayNameLabel.text = account.displayOrUserName
displayNameLabel.setEmojis(account.emojis, identifier: account.id)
}
}
private func updateTimestamp() {
guard let notification else { return }
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
}
} else {
updateTimestampWorkItem = nil
}
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let notification else { return nil }
var str = "Post from "
str += notification.account.displayNameWithoutCustomEmoji
str += " edited "
str += notification.createdAt.formatted(.relative(presentation: .numeric))
str += ", "
str += contentLabel.text ?? ""
return str
}
set {}
}
}

View File

@ -206,10 +206,11 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
headerView.layer.zPosition = 100
view.addSubview(headerView)
let oldHeaderCellTop = oldHeaderCell.convert(CGPoint.zero, to: view).y
// TODO: use safe area layout guide instead of manually adjusting this?
let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top
let headerBottomOffset = oldHeaderCell.convert(CGPoint(x: 0, y: oldHeaderCell.bounds.maxY), to: view).y// - view.safeAreaInsets.top
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset),
headerView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: headerBottomOffset),
headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])

View File

@ -1,370 +0,0 @@
//
// DiffableTimelineLikeTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/18/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
protocol DiffableTimelineLikeSection: Hashable, CaseIterable {
static var loadingIndicator: Self { get }
}
protocol DiffableTimelineLikeItem: Hashable {
static var loadingIndicator: Self { get }
}
class DiffableTimelineLikeTableViewController<Section: DiffableTimelineLikeSection, Item: DiffableTimelineLikeItem>: EnhancedTableViewController, RefreshableViewController {
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
typealias LoadResult = Result<Snapshot, LoadError>
private let pageSize = 20
private(set) var state = State.unloaded
private var lastLastVisibleRow: IndexPath?
private var currentLoadingIndicatorWorkItem: DispatchWorkItem?
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
init() {
super.init(style: .plain)
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: Self.refreshCommandTitle()))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
dataSource = UITableViewDiffableDataSource(tableView: tableView) { [unowned self] (tableView, indexPath, item) in
self.cellProvider(tableView, indexPath, item)
}
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
tableView.register(LoadingTableViewCell.self, forCellReuseIdentifier: "loadingCell")
#if !targetEnvironment(macCatalyst)
self.refreshControl = UIRefreshControl()
self.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
if let prefetchSource = self as? UITableViewDataSourcePrefetching {
tableView.prefetchDataSource = prefetchSource
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadInitial()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
pruneOffscreenRows()
currentToast?.dismissToast(animated: false)
}
class func refreshCommandTitle() -> String {
return "Refresh"
}
private func pruneOffscreenRows() {
guard let lastVisibleRow = tableView.indexPathsForVisibleRows?.last else {
return
}
var snapshot = dataSource.snapshot()
let lastVisibleRowSection = snapshot.sectionIdentifiers[lastVisibleRow.section]
let contentSections = snapshot.sectionIdentifiers.filter { timelineContentSections().contains($0) }
let contentSectionIndices = contentSections.compactMap(snapshot.indexOfSection(_:))
guard let maxContentSectionIndex = contentSectionIndices.max() else {
return
}
if lastVisibleRow.section < maxContentSectionIndex {
return
} else if lastVisibleRow.section == maxContentSectionIndex {
let items = snapshot.itemIdentifiers(inSection: lastVisibleRowSection)
let numberOfPagesToPrune = (items.count - lastVisibleRow.row - 1) / pageSize
if numberOfPagesToPrune > 0 {
let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize))
snapshot.deleteItems(itemsToRemove)
willRemoveItems(itemsToRemove)
} else {
return
}
} else {
// unreachable
return
}
dataSource.apply(snapshot, animatingDifferences: false)
}
private func showLoadingIndicatorDelayed() -> DispatchWorkItem {
currentLoadingIndicatorWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
var snapshot = self.dataSource.snapshot()
var changed = false
if !snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.appendSections([.loadingIndicator])
changed = true
}
if changed || !snapshot.itemIdentifiers(inSection: .loadingIndicator).contains(.loadingIndicator) {
snapshot.appendItems([.loadingIndicator], toSection: .loadingIndicator)
changed = true
}
if changed {
self.dataSource.apply(snapshot, animatingDifferences: false)
}
}
currentLoadingIndicatorWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: workItem)
return workItem
}
private func loadInitial() {
guard state == .unloaded else { return }
// set loaded immediately so we don't trigger another request while the current one is running
state = .loadingInitial
let showIndicator = showLoadingIndicatorDelayed()
loadInitialItems() { result in
DispatchQueue.main.async {
showIndicator.cancel()
switch result {
case .success(var snapshot):
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.deleteSections([.loadingIndicator])
}
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded
case let .failure(.client(error)):
self.state = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadInitial()
}
self.showToast(configuration: config, animated: true)
default:
self.state = .unloaded
}
}
}
}
func reloadInitial() {
state = .unloaded
loadInitial()
}
func loadOlder() {
guard state == .loaded else { return }
state = .loadingOlder
let showIndicator = showLoadingIndicatorDelayed()
loadOlderItems(currentSnapshot: dataSource.snapshot) { result in
DispatchQueue.main.async {
self.state = .loaded
showIndicator.cancel()
switch result {
case .success(var snapshot):
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.deleteSections([.loadingIndicator])
}
self.dataSource.apply(snapshot, animatingDifferences: false)
case let .failure(.client(error)):
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadOlder()
}
self.showToast(configuration: config, animated: true)
default:
break
}
}
}
}
@available(iOS, deprecated: 16.0)
func cellHeightChanged() {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
}
// MARK: - UITableViewDelegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// this assumes that indexPathsForVisibleRows is always in order
lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last
let orderedContentSections = dataSource.snapshot().sectionIdentifiers.enumerated().filter { timelineContentSections().contains($0.element) }
if let lastContentSection = orderedContentSections.last,
indexPath.section == lastContentSection.offset,
indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 {
loadOlder()
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
// MARK: - RefreshableViewController
func refresh() {
// if we're unloaded, there's nothing "newer" to load
// if we're performing some other operation, we don't want to step on its toes
guard state == .loaded else {
self.refreshControl?.endRefreshing()
return
}
state = .loadingNewer
var firstItem: Item? = nil
let currentSnapshot: () -> Snapshot = {
let snapshot = self.dataSource.snapshot()
for section in self.timelineContentSections() {
if snapshot.indexOfSection(section) != nil,
let first = snapshot.itemIdentifiers(inSection: section).first {
firstItem = first
break
}
}
return snapshot
}
loadNewerItems(currentSnapshot: currentSnapshot) { result in
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
self.state = .loaded
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false)
if let firstItem = firstItem,
let indexPath = self.dataSource.indexPath(for: firstItem) {
// maintain the current position in the list (don't scroll to top)
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
}
case let .failure(.client(error)):
let config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.refresh()
}
self.showToast(configuration: config, animated: true)
case .failure(.allCaughtUp):
var config = ToastConfiguration(title: "You're all caught up")
config.edge = .top
config.dismissAutomaticallyAfter = 2
config.action = { (toast) in
toast.dismissToast(animated: true)
}
self.showToast(configuration: config, animated: true)
default:
break
}
}
}
}
// MARK: - Subclass Methods
func loadingIndicatorCell(indexPath: IndexPath) -> UITableViewCell? {
let cell = tableView.dequeueReusableCell(withIdentifier: "loadingCell", for: indexPath) as! LoadingTableViewCell
cell.indicator.startAnimating()
return cell
}
func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
fatalError("cellProvider(_:_:_:) must be implemented by subclasses")
}
func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
fatalError("loadInitialItems(completion:) must be implemented by subclasses")
}
func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
fatalError("loadOlderItesm(completion:) must be implemented by subclasses")
}
func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
fatalError("loadNewerItems(completion:) must be implemented by subclasses")
}
func timelineContentSections() -> Section.AllCases {
return Section.allCases
}
func willRemoveItems(_ items: [Item]) {
}
}
extension DiffableTimelineLikeTableViewController {
enum State: Equatable {
case unloaded
case loadingInitial
case loaded
case loadingNewer
case loadingOlder
}
}
extension DiffableTimelineLikeTableViewController {
enum LoadError: LocalizedError {
case noClient
case noOlder
case noNewer
case allCaughtUp
case client(Client.Error)
}
}
extension DiffableTimelineLikeTableViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
pruneOffscreenRows()
currentToast?.dismissToast(animated: false)
}
}
extension DiffableTimelineLikeTableViewController: ToastableViewController {
}

View File

@ -21,7 +21,7 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
func setEmojis(_ emojis: [Emoji], identifier: String) {
guard emojis.count > 0, let attributedText = attributedText else { return }
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in
replaceEmojis(in: attributedText, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in
guard let self = self, self.emojiIdentifier == identifier else { return }
self.hasEmojis = didReplaceEmojis
self.attributedText = newAttributedText

View File

@ -1,297 +0,0 @@
//
// ActionNotificationGroupTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
import Sentry
class ActionNotificationGroupTableViewCell: UITableViewCell {
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var actionImageView: UIImageView!
@IBOutlet weak var verticalStackView: UIStackView!
@IBOutlet weak var actionAvatarStackView: UIStackView!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var actionLabel: MultiSourceEmojiLabel!
@IBOutlet weak var statusContentLabel: UILabel!
var group: NotificationGroup!
var statusID: String!
private var updateTimestampWorkItem: DispatchWorkItem?
private var isGrayscale = false
deinit {
updateTimestampWorkItem?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
timestampLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
timestampLabel.adjustsFontForContentSizeCategory = true
actionLabel.combiner = self.updateActionLabel
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
@objc func updateUIForPreferences() {
for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews {
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView)
}
if isGrayscale != Preferences.shared.grayscaleImages {
Task {
await updateGrayscaleableUI()
}
}
}
func updateUI(group: NotificationGroup) {
guard group.kind == .favourite || group.kind == .reblog else {
fatalError("Invalid notification type \(group.kind) for ActionNotificationGroupTableViewCell")
}
self.group = group
guard let firstNotification = group.notifications.first else { fatalError() }
guard let status = firstNotification.status else {
let crumb = Breadcrumb(level: .fatal, category: "notifications")
crumb.data = [
"id": firstNotification.id,
"type": firstNotification.kind.rawValue,
"created_at": firstNotification.createdAt.formatted(.iso8601),
"account": firstNotification.account.id,
]
SentrySDK.addBreadcrumb(crumb)
fatalError("missing status for favorite/reblog notification")
}
self.statusID = status.id
updateUIForPreferences()
switch group.kind {
case .favourite:
actionImageView.image = UIImage(systemName: "star.fill")
case .reblog:
actionImageView.image = UIImage(systemName: "repeat")
default:
fatalError()
}
isGrayscale = Preferences.shared.grayscaleImages
updateTimestamp()
let timestampLabelSize = timestampLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: timestampLabel.bounds.height))
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
actionAvatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
var imageViews = [UIImageView]()
for _ in people {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
actionAvatarStackView.addArrangedSubview(imageView)
imageViews.append(imageView)
// don't add more avatars if they would overflow or squeeze the timestamp label
let avatarViewsWidth = 30 * CGFloat(imageViews.count)
let avatarMarginsWidth = 4 * CGFloat(max(0, imageViews.count - 1))
// todo: when the cell is first created, verticalStackView.bounds.width is not correct
let maxAvatarStackWidth = verticalStackView.bounds.width - timestampLabelSize.width - 8
let remainingWidth = maxAvatarStackWidth - avatarViewsWidth - avatarMarginsWidth
if remainingWidth < 34 {
break
}
}
NSLayoutConstraint.activate(imageViews.map { $0.widthAnchor.constraint(equalTo: $0.heightAnchor) })
Task {
await updateGrayscaleableUI()
}
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)
let doc = try! SwiftSoup.parse(status.content)
statusContentLabel.text = try! doc.text()
}
@MainActor
private func updateGrayscaleableUI() async {
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
let groupID = group.id
for (index, account) in people.enumerated() {
guard actionAvatarStackView.arrangedSubviews.count > index,
let imageView = actionAvatarStackView.arrangedSubviews[index] as? UIImageView,
let avatarURL = account.avatar else {
continue
}
Task {
let (_, image) = await ImageCache.avatars.get(avatarURL)
guard let image = image,
self.group.id == groupID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
return
}
imageView.image = transformedImage
}
}
}
private func updateTimestamp() {
guard let notification = group.notifications.first else {
fatalError("Missing cached notification")
}
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
} else {
updateTimestampWorkItem = nil
}
}
func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
let verb: String
switch group.kind {
case .favourite:
verb = "Favorited"
case .reblog:
verb = "Reblogged"
default:
fatalError()
}
// todo: figure out how to localize this
let str = NSMutableAttributedString(string: "\(verb) by ")
switch names.count {
case 1:
str.append(names.first!)
case 2:
str.append(names.first!)
str.append(NSAttributedString(string: " and "))
str.append(names.last!)
default:
for (index, name) in names.enumerated() {
str.append(name)
if index < names.count - 2 {
str.append(NSAttributedString(string: ", "))
} else if index == names.count - 2 {
str.append(NSAttributedString(string: ", and "))
}
}
}
return str
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
let first = group.notifications.first!
var str = ""
switch group.kind {
case .favourite:
str += "Favorited by "
case .reblog:
str += "Reblogged by "
default:
return nil
}
str += first.account.displayNameWithoutCustomEmoji
if group.notifications.count > 1 {
str += " and \(group.notifications.count - 1) more"
}
str += ", \(first.createdAt.formatted(.relative(presentation: .numeric))), "
str += statusContentLabel.text ?? ""
return str
}
set {}
}
}
extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
func didSelectCell() {
guard let delegate = delegate else { return }
let notifications = group.notifications
let accountIDs = notifications.map { $0.account.id }
let action: StatusActionAccountListViewController.ActionType
switch notifications.first!.kind {
case .favourite:
action = .favorite
case .reblog:
action = .reblog
default:
fatalError()
}
let vc = StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
vc.showInaccurateCountWarning = false
delegate.show(vc)
}
}
extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
return (content: {
let notifications = self.group.notifications
let accountIDs = notifications.map { $0.account.id }
let action: StatusActionAccountListViewController.ActionType
switch notifications.first!.kind {
case .favourite:
action = .favorite
case .reblog:
action = .reblog
default:
fatalError()
}
let vc = StatusActionAccountListViewController(actionType: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
vc.showInaccurateCountWarning = false
return vc
}, actions: {
return []
})
}
}

View File

@ -1,93 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="175" id="KGk-i7-Jjw" customClass="ActionNotificationGroupTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="175"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="175"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="hld-yu-Rmi">
<rect key="frame" x="74" y="11" width="230" height="153"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="hTQ-P4-gOO">
<rect key="frame" x="0.0" y="0.0" width="230" height="30"/>
<subviews>
<stackView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" ambiguous="YES" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="b7l-YW-nQY">
<rect key="frame" x="0.0" y="0.0" width="189.5" height="30"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="9uh-oo-JSM"/>
</constraints>
</stackView>
<view contentMode="scaleToFill" horizontalHuggingPriority="249" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5Ef-5g-b23">
<rect key="frame" x="197.5" y="0.0" width="0.5" height="30"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="752" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JN0-Bf-3qx">
<rect key="frame" x="206" y="0.0" width="24" height="30"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Actioned by Person 1, Person 2, and Person 3" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="fkn-Gk-ngr" customClass="MultiSourceEmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="34" width="230" height="42.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lc7-zZ-HrZ">
<rect key="frame" x="0.0" y="80.5" width="230" height="72.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="wUd-R6-gkG">
<rect key="frame" x="36" y="11" width="30" height="30"/>
<color key="tintColor" red="1" green="0.80000000000000004" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="displayP3"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="Cx5-Jh-XEu"/>
<constraint firstAttribute="height" constant="30" id="lWD-P5-gDr"/>
</constraints>
</imageView>
</subviews>
<constraints>
<constraint firstItem="hld-yu-Rmi" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" constant="58" id="05d-IL-ZX0"/>
<constraint firstItem="wUd-R6-gkG" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="Cg0-cz-htM"/>
<constraint firstItem="hld-yu-Rmi" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="X0D-ZI-FXy"/>
<constraint firstItem="hld-yu-Rmi" firstAttribute="leading" secondItem="wUd-R6-gkG" secondAttribute="trailing" constant="8" id="bby-eV-FDb"/>
<constraint firstAttribute="trailingMargin" secondItem="hld-yu-Rmi" secondAttribute="trailing" id="nC6-7Q-m0V"/>
<constraint firstAttribute="bottomMargin" secondItem="hld-yu-Rmi" secondAttribute="bottom" id="sB7-UM-p0X"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="actionAvatarStackView" destination="b7l-YW-nQY" id="XW6-FM-tpc"/>
<outlet property="actionImageView" destination="wUd-R6-gkG" id="HBp-p8-f3b"/>
<outlet property="actionLabel" destination="fkn-Gk-ngr" id="bBG-a8-m5G"/>
<outlet property="statusContentLabel" destination="lc7-zZ-HrZ" id="jgT-LU-rXt"/>
<outlet property="timestampLabel" destination="JN0-Bf-3qx" id="Jlo-f6-DAi"/>
<outlet property="verticalStackView" destination="hld-yu-Rmi" id="jvu-1u-Ok3"/>
</connections>
<point key="canvasLocation" x="-394.20289855072468" y="56.584821428571423"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -1,259 +0,0 @@
//
// FollowNotificationGroupTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class FollowNotificationGroupTableViewCell: UITableViewCell {
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var avatarStackView: UIStackView!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var actionLabel: MultiSourceEmojiLabel!
var group: NotificationGroup!
private var avatarRequests = [String: ImageCache.Request]()
private var updateTimestampWorkItem: DispatchWorkItem?
private var isGrayscale = false
deinit {
updateTimestampWorkItem?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
timestampLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
timestampLabel.adjustsFontForContentSizeCategory = true
actionLabel.combiner = self.updateActionLabel
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
@objc func updateUIForPreferences() {
for case let imageView as UIImageView in avatarStackView.arrangedSubviews {
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView)
}
if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI()
}
}
func updateUI(group: NotificationGroup) {
self.group = group
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
actionLabel.setEmojis(pairs: people.map {
($0.displayOrUserName, $0.emojis)
}, identifier: group.id)
updateTimestamp()
isGrayscale = Preferences.shared.grayscaleImages
avatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for account in people {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
if let avatarURL = account.avatar {
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self,
let image = image,
self.group.id == group.id,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
return
}
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = transformedImage
}
}
}
avatarStackView.addArrangedSubview(imageView)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: 30),
imageView.heightAnchor.constraint(equalToConstant: 30),
])
}
}
private func updateGrayscaleableUI() {
isGrayscale = Preferences.shared.grayscaleImages
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
let groupID = group.id
for (index, account) in people.enumerated() {
guard avatarStackView.arrangedSubviews.count > index,
let imageView = avatarStackView.arrangedSubviews[index] as? UIImageView else {
continue
}
if let avatarURL = account.avatar {
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self else { return }
guard let image = image,
self.group.id == groupID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
}
return
}
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = transformedImage
}
}
}
}
}
func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
// todo: figure out how to localize this
let str = NSMutableAttributedString(string: "Followed by ")
switch names.count {
case 1:
str.append(names.first!)
case 2:
str.append(names.first!)
str.append(NSAttributedString(string: " and "))
str.append(names.last!)
default:
for (index, name) in names.enumerated() {
str.append(name)
if index < names.count - 2 {
str.append(NSAttributedString(string: ", "))
} else if index == names.count - 2 {
str.append(NSAttributedString(string: ", and "))
}
}
}
return str
}
func updateTimestamp() {
guard let notification = group.notifications.first else {
fatalError("Missing cached notification")
}
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
} else {
updateTimestampWorkItem = nil
}
}
override func prepareForReuse() {
super.prepareForReuse()
avatarRequests.values.forEach { $0.cancel() }
updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
let first = group.notifications.first!
var str = "Followed by "
str += first.account.displayNameWithoutCustomEmoji
if group.notifications.count > 1 {
str += " and \(group.notifications.count - 1) more"
}
str += ", \(first.createdAt.formatted(.relative(presentation: .numeric)))"
return str
}
set {}
}
}
extension FollowNotificationGroupTableViewCell: SelectableTableViewCell {
func didSelectCell() {
let accountIDs = group.notifications.map { $0.account.id }
switch accountIDs.count {
case 0:
return
case 1:
delegate?.selected(account: accountIDs.first!)
default:
let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController)
vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
delegate?.show(vc)
}
}
}
extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let mastodonController = mastodonController else { return nil }
let accountIDs = self.group.notifications.map { $0.account.id }
return (content: {
if accountIDs.count == 1 {
return ProfileViewController(accountID: accountIDs.first!, mastodonController: mastodonController)
} else {
return AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController)
}
}, actions: {
if accountIDs.count == 1 {
return self.delegate?.actionsForProfile(accountID: accountIDs.first!, source: .view(self)) ?? []
} else {
return []
}
})
}
}
extension FollowNotificationGroupTableViewCell: DraggableTableViewCell {
func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] {
guard group.notifications.count == 1 else {
return []
}
let notification = group.notifications[0]
let provider = NSItemProvider(object: notification.account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: notification.account.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
}

View File

@ -1,84 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="98" id="KGk-i7-Jjw" customClass="FollowNotificationGroupTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="98"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="98"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="g8L-M7-dD6">
<rect key="frame" x="74" y="11" width="230" height="76"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7lu-x4-ldA">
<rect key="frame" x="0.0" y="0.0" width="230" height="30"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="xyB-aZ-YhR">
<rect key="frame" x="0.0" y="0.0" width="205.5" height="30"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="3ns-8D-P1Q"/>
</constraints>
</stackView>
<view contentMode="scaleToFill" horizontalHuggingPriority="249" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="eEp-GR-rtF">
<rect key="frame" x="205.5" y="0.0" width="0.5" height="30"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Iub-HC-orP">
<rect key="frame" x="206" y="0.0" width="24" height="30"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Followed by Person 1 and Person 2" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bHA-9x-pcO" customClass="MultiSourceEmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="30" width="230" height="46"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="person.fill.badge.plus" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="7gy-KD-YT1">
<rect key="frame" x="34" y="12.5" width="32" height="30"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="gvV-4g-2Xr"/>
<constraint firstAttribute="height" constant="30" id="lS8-fq-ptY"/>
</constraints>
</imageView>
</subviews>
<constraints>
<constraint firstItem="7gy-KD-YT1" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="1Vb-q3-i8P"/>
<constraint firstAttribute="bottomMargin" secondItem="g8L-M7-dD6" secondAttribute="bottom" id="Dzg-eX-ZyM"/>
<constraint firstAttribute="trailingMargin" secondItem="g8L-M7-dD6" secondAttribute="trailing" id="Pg7-9Q-vYV"/>
<constraint firstItem="g8L-M7-dD6" firstAttribute="leading" secondItem="7gy-KD-YT1" secondAttribute="trailing" constant="8" id="dCe-Ie-iRs"/>
<constraint firstItem="g8L-M7-dD6" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" constant="58" id="lWc-MX-lAl"/>
<constraint firstItem="g8L-M7-dD6" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="xUY-IV-Jbu"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="actionLabel" destination="bHA-9x-pcO" id="Woa-25-hgd"/>
<outlet property="avatarStackView" destination="xyB-aZ-YhR" id="DDp-5c-Qdo"/>
<outlet property="timestampLabel" destination="Iub-HC-orP" id="OCV-mm-LXF"/>
</connections>
<point key="canvasLocation" x="131.8840579710145" y="171.42857142857142"/>
</tableViewCell>
</objects>
<resources>
<image name="person.fill.badge.plus" catalog="system" width="128" height="125"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -1,118 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="107" id="Pcu-ap-Xqf" customClass="FollowRequestNotificationTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="107"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" layoutMarginsFollowReadableWidth="YES" tableViewCell="Pcu-ap-Xqf" id="Ulr-P8-MK9">
<rect key="frame" x="0.0" y="0.0" width="320" height="107"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="Cth-1T-Km3">
<rect key="frame" x="74" y="11" width="230" height="85"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="G6v-p7-JbC">
<rect key="frame" x="0.0" y="0.0" width="230" height="30"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="0j2-g5-Y0W">
<rect key="frame" x="0.0" y="0.0" width="30" height="30"/>
<constraints>
<constraint firstAttribute="width" secondItem="0j2-g5-Y0W" secondAttribute="height" multiplier="1:1" id="05S-TD-ePl"/>
<constraint firstAttribute="height" constant="30" id="KCp-Zt-Cm6"/>
</constraints>
</imageView>
<view contentMode="scaleToFill" horizontalHuggingPriority="249" translatesAutoresizingMaskIntoConstraints="NO" id="9WN-Ql-DDL">
<rect key="frame" x="30" y="0.0" width="176" height="30"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Saq-P5-oVH">
<rect key="frame" x="206" y="0.0" width="24" height="30"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Request to follow by Person 1" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="aM6-C6-9QH" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="34" width="230" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="20F-2n-eQx">
<rect key="frame" x="0.0" y="58.5" width="230" height="26.5"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="CMQ-TI-X9k">
<rect key="frame" x="0.0" y="0.0" width="115" height="26.5"/>
<accessibility key="accessibilityConfiguration" label="Accept Request"/>
<state key="normal" title=" Accept" image="checkmark.circle.fill" catalog="system">
<color key="titleColor" systemColor="tintColor"/>
</state>
<connections>
<action selector="acceptButtonPressed" destination="Pcu-ap-Xqf" eventType="touchUpInside" id="hGw-3d-RNi"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="7MW-rY-m5l">
<rect key="frame" x="115" y="0.0" width="115" height="26.5"/>
<state key="normal" title=" Reject" image="xmark.circle.fill" catalog="system">
<color key="titleColor" systemColor="tintColor"/>
</state>
<connections>
<action selector="rejectButtonPressed" destination="Pcu-ap-Xqf" eventType="touchUpInside" id="EP6-Bg-3nC"/>
</connections>
</button>
</subviews>
</stackView>
</subviews>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="person.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="1qX-LD-7ZK">
<rect key="frame" x="36" y="12.5" width="30" height="27"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="UDV-Vb-1bn"/>
<constraint firstAttribute="width" constant="30" id="d5A-cf-hFe"/>
</constraints>
</imageView>
</subviews>
<constraints>
<constraint firstAttribute="trailingMargin" secondItem="Cth-1T-Km3" secondAttribute="trailing" id="9Zn-8U-6HF"/>
<constraint firstItem="Cth-1T-Km3" firstAttribute="top" secondItem="Ulr-P8-MK9" secondAttribute="topMargin" id="EIi-sE-AkN"/>
<constraint firstItem="1qX-LD-7ZK" firstAttribute="top" secondItem="Ulr-P8-MK9" secondAttribute="topMargin" id="GUW-Xm-fLN"/>
<constraint firstItem="Cth-1T-Km3" firstAttribute="leading" secondItem="Ulr-P8-MK9" secondAttribute="leadingMargin" constant="58" id="QvY-68-add"/>
<constraint firstAttribute="bottomMargin" secondItem="Cth-1T-Km3" secondAttribute="bottom" id="aje-GB-qn6"/>
<constraint firstItem="Cth-1T-Km3" firstAttribute="leading" secondItem="1qX-LD-7ZK" secondAttribute="trailing" constant="8" id="qnO-DF-3wu"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="ctM-Hq-1Oz"/>
<connections>
<outlet property="acceptButton" destination="CMQ-TI-X9k" id="xL1-MG-SHi"/>
<outlet property="actionButtonsStackView" destination="20F-2n-eQx" id="Uaj-3F-N05"/>
<outlet property="actionLabel" destination="aM6-C6-9QH" id="UfY-EF-7Ya"/>
<outlet property="avatarImageView" destination="0j2-g5-Y0W" id="3Qj-5q-e73"/>
<outlet property="rejectButton" destination="7MW-rY-m5l" id="ZeH-FG-T7M"/>
<outlet property="stackView" destination="Cth-1T-Km3" id="Elz-8v-AFa"/>
<outlet property="timestampLabel" destination="Saq-P5-oVH" id="d6F-HV-HXs"/>
</connections>
<point key="canvasLocation" x="131.8840579710145" y="174.44196428571428"/>
</tableViewCell>
</objects>
<resources>
<image name="checkmark.circle.fill" catalog="system" width="128" height="123"/>
<image name="person.fill" catalog="system" width="128" height="120"/>
<image name="xmark.circle.fill" catalog="system" width="128" height="123"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="tintColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -1,144 +0,0 @@
//
// PollFinishedTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 4/28/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
class PollFinishedTableViewCell: UITableViewCell {
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController? { delegate?.apiController }
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var statusContentLabel: UILabel!
@IBOutlet weak var pollView: StatusPollView!
var notification: Pachyderm.Notification?
private var updateTimestampWorkItem: DispatchWorkItem?
override func awakeFromNib() {
super.awakeFromNib()
timestampLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
timestampLabel.adjustsFontForContentSizeCategory = true
displayNameLabel.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)!
displayNameLabel.adjustsFontForContentSizeCategory = true
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
func updateUI(notification: Pachyderm.Notification) {
guard let statusID = notification.status?.id,
let status = delegate?.apiController.persistentContainer.status(for: statusID),
let poll = status.poll else {
return
}
self.notification = notification
updateTimestamp()
displayNameLabel.text = notification.account.displayName
displayNameLabel.setEmojis(notification.account.emojis, identifier: notification.account.id)
let doc = try! SwiftSoup.parseBodyFragment(status.content)
statusContentLabel.text = try! doc.text()
pollView.mastodonController = mastodonController
pollView.toastableViewController = delegate
pollView.updateUI(status: status, poll: poll)
}
private func updateTimestamp() {
guard let notification = notification else { return }
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
}
} else {
updateTimestampWorkItem = nil
}
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let notification else { return nil }
var str = "Poll from "
str += notification.account.displayNameWithoutCustomEmoji
str += " finished "
str += notification.createdAt.formatted(.relative(presentation: .numeric))
if let poll = notification.status?.poll,
poll.options.contains(where: { ($0.votesCount ?? 0) > 0 }) {
let winner = poll.options.max(by: { ($0.votesCount ?? 0) < ($1.votesCount ?? 0) })!
str += ", winning option: \(winner.title)"
}
return str
}
set {}
}
}
extension PollFinishedTableViewCell: SelectableTableViewCell {
func didSelectCell() {
guard let delegate = delegate,
let status = notification?.status else {
return
}
delegate.selected(status: status.id)
}
}
extension PollFinishedTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let delegate = delegate,
let statusID = notification?.status?.id,
let status = delegate.apiController.persistentContainer.status(for: statusID) else {
return nil
}
return (content: {
ConversationViewController(for: statusID, state: .unknown, mastodonController: delegate.apiController)
}, actions: {
delegate.actionsForStatus(status, source: .view(self))
})
}
}

View File

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="102" id="KGk-i7-Jjw" customClass="PollFinishedTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="102"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" layoutMarginsFollowReadableWidth="YES" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="102"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="eSw-Oo-Scy">
<rect key="frame" x="72" y="11" width="232" height="80"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="69j-GL-yd7">
<rect key="frame" x="0.0" y="0.0" width="232" height="20.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="A poll has finished" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9He-JX-i6Z">
<rect key="frame" x="0.0" y="0.0" width="208" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Bsi-QS-utc">
<rect key="frame" x="208" y="0.0" width="24" height="20.5"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="Person" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zwM-Iw-Hob" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="24.5" width="232" height="20.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bLL-8K-VWn">
<rect key="frame" x="0.0" y="49" width="232" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ybA-ob-sHe" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="73.5" width="232" height="6.5"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</subviews>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="checkmark.square.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="cqi-cV-ejs">
<rect key="frame" x="34" y="9" width="30" height="34"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="E9e-iF-rqo"/>
<constraint firstAttribute="width" constant="30" id="Efu-VP-pjH"/>
</constraints>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</imageView>
</subviews>
<constraints>
<constraint firstItem="cqi-cV-ejs" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" constant="18" id="8hN-WG-IsT"/>
<constraint firstAttribute="bottomMargin" secondItem="eSw-Oo-Scy" secondAttribute="bottom" id="9Hx-wD-Rfx"/>
<constraint firstItem="eSw-Oo-Scy" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="CxC-Ch-JAx"/>
<constraint firstAttribute="trailingMargin" secondItem="eSw-Oo-Scy" secondAttribute="trailing" id="OPc-Wi-cHD"/>
<constraint firstItem="cqi-cV-ejs" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="jQZ-cM-UuM"/>
<constraint firstItem="eSw-Oo-Scy" firstAttribute="leading" secondItem="cqi-cV-ejs" secondAttribute="trailing" constant="8" id="wMo-VG-1DK"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="displayNameLabel" destination="zwM-Iw-Hob" id="3VF-5X-B94"/>
<outlet property="pollView" destination="ybA-ob-sHe" id="lpi-94-dvu"/>
<outlet property="statusContentLabel" destination="bLL-8K-VWn" id="GZo-ko-eaD"/>
<outlet property="timestampLabel" destination="Bsi-QS-utc" id="ufI-re-iM2"/>
</connections>
<point key="canvasLocation" x="-62.318840579710148" y="-22.767857142857142"/>
</tableViewCell>
</objects>
<resources>
<image name="checkmark.square.fill" catalog="system" width="128" height="114"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@ -1,133 +0,0 @@
//
// StatusUpdatedNotificationTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 11/27/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
class StatusUpdatedNotificationTableViewCell: UITableViewCell {
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var contentLabel: UILabel!
private var notification: Pachyderm.Notification?
private var updateTimestampWorkItem: DispatchWorkItem?
override func awakeFromNib() {
super.awakeFromNib()
timestampLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
timestampLabel.adjustsFontForContentSizeCategory = true
displayNameLabel.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)!
displayNameLabel.adjustsFontForContentSizeCategory = true
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
func updateUI(notification: Pachyderm.Notification) {
guard notification.kind == .update,
let status = notification.status else {
return
}
self.notification = notification
updateTimestamp()
displayNameLabel.text = notification.account.displayName
displayNameLabel.setEmojis(notification.account.emojis, identifier: notification.account.id)
let doc = try! SwiftSoup.parseBodyFragment(status.content)
contentLabel.text = try! doc.text()
}
private func updateTimestamp() {
guard let notification else { return }
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
}
} else {
updateTimestampWorkItem = nil
}
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let notification else { return nil }
var str = "Post from "
str += notification.account.displayNameWithoutCustomEmoji
str += " edited "
str += notification.createdAt.formatted(.relative(presentation: .numeric))
str += ", "
str += contentLabel.text ?? ""
return str
}
set {}
}
}
extension StatusUpdatedNotificationTableViewCell: SelectableTableViewCell {
func didSelectCell() {
guard let delegate,
let status = notification?.status else {
return
}
delegate.selected(status: status.id)
}
}
extension StatusUpdatedNotificationTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let delegate,
let statusID = notification?.status?.id,
let status = delegate.apiController.persistentContainer.status(for: statusID) else {
return nil
}
return (content: {
ConversationViewController(for: statusID, state: .unknown, mastodonController: delegate.apiController)
}, actions: {
delegate.actionsForStatus(status, source: .view(self))
})
}
}

View File

@ -1,88 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="124" id="KGk-i7-Jjw" customClass="StatusUpdatedNotificationTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="124"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="124"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="ZWN-ni-RLP">
<rect key="frame" x="74" y="11" width="230" height="102"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="HLB-Iv-HTR">
<rect key="frame" x="0.0" y="0.0" width="230" height="20.333333333333332"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="A post was edited" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="mBx-jm-6sU">
<rect key="frame" x="0.0" y="0.0" width="206" height="20.333333333333332"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="04d-Lt-yL5">
<rect key="frame" x="206" y="0.0" width="24" height="20.333333333333332"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="Person" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="F5w-FN-c33" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="24.333333333333336" width="230" height="20.333333333333336"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="A4y-Se-5rW">
<rect key="frame" x="0.0" y="48.666666666666657" width="230" height="53.333333333333343"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="pencil" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="3pZ-j9-PPP">
<rect key="frame" x="36" y="11" width="30" height="31"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="FOx-Ib-CH7"/>
<constraint firstAttribute="height" constant="30" id="UNQ-xp-O8B"/>
</constraints>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</imageView>
</subviews>
<constraints>
<constraint firstItem="3pZ-j9-PPP" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="6Aw-t0-5vM"/>
<constraint firstItem="ZWN-ni-RLP" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="88f-jC-7Dk"/>
<constraint firstItem="ZWN-ni-RLP" firstAttribute="leading" secondItem="3pZ-j9-PPP" secondAttribute="trailing" constant="8" id="R68-9I-Bnh"/>
<constraint firstAttribute="bottomMargin" secondItem="ZWN-ni-RLP" secondAttribute="bottom" id="eCm-M2-qlS"/>
<constraint firstItem="ZWN-ni-RLP" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" constant="58" id="r84-Xe-N1F"/>
<constraint firstAttribute="trailingMargin" secondItem="ZWN-ni-RLP" secondAttribute="trailing" id="w0X-u7-BPp"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="contentLabel" destination="A4y-Se-5rW" id="1j0-QT-bzy"/>
<outlet property="displayNameLabel" destination="F5w-FN-c33" id="q3K-Od-YxV"/>
<outlet property="timestampLabel" destination="04d-Lt-yL5" id="VeH-73-9Gh"/>
</connections>
<point key="canvasLocation" x="74.809160305343511" y="16.901408450704228"/>
</tableViewCell>
</objects>
<resources>
<image name="pencil" catalog="system" width="128" height="113"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -0,0 +1,212 @@
//
// ProfileFieldValueView.swift
// Tusker
//
// Created by Shadowfacts on 5/6/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftUI
import SafariServices
class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate?
private static let converter: HTMLConverter = {
var converter = HTMLConverter()
converter.font = .preferredFont(forTextStyle: .body)
converter.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
return converter
}()
private let account: AccountMO
private let field: Account.Field
private var link: (String, URL)?
private let label = EmojiLabel()
private var iconView: UIView?
private var currentTargetedPreview: UITargetedPreview?
init(field: Account.Field, account: AccountMO) {
self.account = account
self.field = field
super.init(frame: .zero)
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
var range = NSRange(location: 0, length: 0)
if converted.length != 0,
let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL {
link = (converted.attributedSubstring(from: range).string, url)
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
label.addInteraction(UIContextMenuInteraction(delegate: self))
label.isUserInteractionEnabled = true
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
guard value != nil else { return }
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
// the .link attribute in a UILabel always makes the color blue >.>
converted.removeAttribute(.link, range: range)
}
}
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.attributedText = converted
label.setEmojis(account.emojis, identifier: account.id)
label.setContentCompressionResistancePriority(.required, for: .vertical)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
let labelTrailingConstraint: NSLayoutConstraint
if field.verifiedAt != nil {
var config = UIButton.Configuration.plain()
config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(scale: .medium)
config.image = UIImage(systemName: "checkmark")
config.baseForegroundColor = .systemGreen
let icon = UIButton(configuration: config)
self.iconView = icon
icon.translatesAutoresizingMaskIntoConstraints = false
icon.setContentHuggingPriority(.defaultHigh, for: .horizontal)
icon.addTarget(self, action: #selector(verifiedIconTapped), for: .touchUpInside)
icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link"
addSubview(icon)
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
])
} else {
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
}
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor),
labelTrailingConstraint,
label.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
var size = label.sizeThatFits(size)
if let iconView {
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
}
return size
}
func setTextAlignment(_ alignment: NSTextAlignment) {
label.textAlignment = alignment
}
func getHashtagOrURL() -> (Hashtag?, URL)? {
guard let (text, url) = link else {
return nil
}
if text.starts(with: "#") {
return (Hashtag(name: String(text.dropFirst()), url: url), url)
} else {
return (nil, url)
}
}
@objc private func linkTapped() {
guard let (hashtag, url) = getHashtagOrURL() else {
return
}
if let hashtag {
navigationDelegate?.selected(tag: hashtag)
} else {
navigationDelegate?.selected(url: url)
}
}
@objc private func verifiedIconTapped() {
guard let navigationDelegate else {
return
}
let view = ProfileFieldVerificationView(
acct: account.acct,
verifiedAt: field.verifiedAt!,
linkText: label.text ?? "",
navigationDelegate: navigationDelegate
)
let host = UIHostingController(rootView: view)
let toPresent: UIViewController
if traitCollection.horizontalSizeClass == .compact || traitCollection.verticalSizeClass == .compact {
toPresent = UINavigationController(rootViewController: host)
toPresent.modalPresentationStyle = .pageSheet
let sheetPresentationController = toPresent.sheetPresentationController!
sheetPresentationController.detents = [
.medium()
]
} else {
host.modalPresentationStyle = .popover
let popoverPresentationController = host.popoverPresentationController!
popoverPresentationController.sourceView = iconView
host.preferredContentSize = host.sizeThatFits(in: CGSize(width: 400, height: CGFloat.infinity))
toPresent = host
}
navigationDelegate.present(toPresent, animated: true)
}
}
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
var toastableViewController: ToastableViewController? {
navigationDelegate
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let (hashtag, url) = getHashtagOrURL(),
let navigationDelegate else {
return nil
}
if let hashtag {
return UIContextMenuConfiguration {
HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController)
} actionProvider: { _ in
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self)))
}
} else {
return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: url)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
return vc
} actionProvider: { _ in
UIMenu(children: self.actionsForURL(url, source: .view(self)))
}
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0)
// the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account
rect.origin.x = 0
rect.origin.y = (bounds.height - rect.height) / 2
let parameters = UIPreviewParameters(textLineRects: [rect as NSValue])
let preview = UITargetedPreview(view: label, parameters: parameters)
currentTargetedPreview = preview
return preview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
return currentTargetedPreview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!)
}
}

View File

@ -0,0 +1,68 @@
//
// ProfileFieldVerificationView.swift
// Tusker
//
// Created by Shadowfacts on 5/6/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import SwiftUI
@MainActor
struct ProfileFieldVerificationView: View {
let acct: String
let verifiedAt: Date
let linkText: String
let navigationDelegate: TuskerNavigationDelegate
var body: some View {
VStack(alignment: .leading, spacing: 8) {
firstLine
secondLine
}
.padding()
.navigationTitle(Text("Verified Link"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
navigationDelegate.dismiss(animated: true)
}
}
}
.environment(\.openURL, OpenURLAction(handler: { url in
// dismiss the sheet/popover first
navigationDelegate.dismiss(animated: true) {
navigationDelegate.selected(url: url)
}
return .handled
}))
}
private var firstLine: Text {
var attrStr: AttributedString = "This link has been verified by your instance, "
var instance = AttributedString(navigationDelegate.apiController!.instanceURL.host!)
instance.font = .body.bold()
attrStr += instance
attrStr += "."
return Text(attrStr)
}
private var secondLine: Text {
var attrStr: AttributedString = "The page at "
var linkStr: AttributedString
if linkText.count > 43 {
linkStr = AttributedString(linkText.prefix(40) + "")
} else {
linkStr = AttributedString(linkText)
}
linkStr.link = URL(string: linkText)
attrStr += linkStr
attrStr += " was confirmed to link back to "
var acctStr = AttributedString("@\(acct)")
acctStr.font = .body.bold()
attrStr += acctStr
attrStr += AttributedString(" as of \(verifiedAt.formatted(date: .abbreviated, time: .shortened)).")
return Text(attrStr)
}
}

View File

@ -8,7 +8,6 @@
import UIKit
import Pachyderm
import SwiftUI
class ProfileFieldsView: UIView {
@ -16,9 +15,19 @@ class ProfileFieldsView: UIView {
private var fields = [Account.Field]()
private let stack = UIStackView()
private var fieldViews: [(EmojiLabel, ProfileFieldValueView)] = []
private var fieldViews: [(EmojiLabel, ProfileFieldValueView, UIView)] = []
private var fieldConstraints: [NSLayoutConstraint] = []
private lazy var dividerLayoutGuide: UILayoutGuide = {
let guide = UILayoutGuide()
addLayoutGuide(guide)
guide.widthAnchor.constraint(equalToConstant: 8).isActive = true
let centerDividerConstraint = guide.centerXAnchor.constraint(equalTo: centerXAnchor)
centerDividerConstraint.priority = .defaultHigh
centerDividerConstraint.isActive = true
return guide
}()
private var dividerXConstraint: NSLayoutConstraint?
private var boundsObservation: NSKeyValueObservation?
private var isUsingSingleColumn: Bool = false
private var needsSingleColumn: Bool {
@ -43,16 +52,9 @@ class ProfileFieldsView: UIView {
}
private func commonInit() {
stack.axis = .vertical
stack.alignment = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
boundsObservation = observe(\.bounds, changeHandler: { [unowned self] _, _ in
self.setNeedsUpdateConstraints()
})
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@ -70,27 +72,53 @@ class ProfileFieldsView: UIView {
}
fields = account.fields
for (name, value) in fieldViews {
for (name, value, fieldContainer) in fieldViews {
name.removeFromSuperview()
value.removeFromSuperview()
fieldContainer.removeFromSuperview()
}
fieldViews = []
for field in account.fields {
for (index, field) in account.fields.enumerated() {
let nameLabel = EmojiLabel()
nameLabel.text = field.name
nameLabel.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)!
nameLabel.adjustsFontForContentSizeCategory = true
nameLabel.numberOfLines = 0
nameLabel.lineBreakMode = .byWordWrapping
nameLabel.showsExpansionTextWhenTruncated = true
nameLabel.setEmojis(account.emojis, identifier: account.id)
nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
nameLabel.setContentHuggingPriority(.defaultHigh, for: .vertical)
let valueView = ProfileFieldValueView(field: field, account: account)
valueView.navigationDelegate = delegate
valueView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
fieldViews.append((nameLabel, valueView))
let container = UIView()
container.translatesAutoresizingMaskIntoConstraints = false
addSubview(container)
nameLabel.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(nameLabel)
valueView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(valueView)
if index % 2 == 0 {
container.backgroundColor = .secondarySystemFill
} else {
container.backgroundColor = .quaternarySystemFill
}
if index == 0 || index == fields.count - 1 {
if fields.count > 1 && index == 0 {
container.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
} else if fields.count > 1 && index == fields.count - 1 {
container.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
}
container.layer.cornerRadius = 8
container.layer.cornerCurve = .continuous
}
fieldViews.append((nameLabel, valueView, container))
}
configureFields()
@ -106,222 +134,83 @@ class ProfileFieldsView: UIView {
NSLayoutConstraint.deactivate(fieldConstraints)
fieldConstraints = []
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
var prevContainer: UIView?
for (name, value, container) in fieldViews {
fieldConstraints.append(contentsOf: [
container.leadingAnchor.constraint(equalTo: leadingAnchor),
container.trailingAnchor.constraint(equalTo: trailingAnchor),
])
if needsSingleColumn {
stack.spacing = 4
var isFirst = true
for (name, value) in fieldViews {
if isFirst {
isFirst = false
} else {
let spacer = UIView()
// don't need any height, since there's 4pts of padding on either side
spacer.heightAnchor.constraint(equalToConstant: 0).isActive = true
stack.addArrangedSubview(spacer)
}
name.textAlignment = .natural
stack.addArrangedSubview(name)
value.setTextAlignment(.natural)
stack.addArrangedSubview(value)
}
} else {
stack.spacing = 8
let dividerLayoutGuide = UILayoutGuide()
addLayoutGuide(dividerLayoutGuide)
fieldConstraints.append(contentsOf: [
dividerLayoutGuide.widthAnchor.constraint(equalToConstant: 8),
name.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
name.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
name.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
value.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
value.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
value.topAnchor.constraint(equalTo: name.bottomAnchor),
value.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
])
for (name, value) in fieldViews {
} else {
name.textAlignment = .right
name.translatesAutoresizingMaskIntoConstraints = false
value.setTextAlignment(.left)
value.translatesAutoresizingMaskIntoConstraints = false
let fieldContainer = UIView()
fieldContainer.addSubview(name)
fieldContainer.addSubview(value)
stack.addArrangedSubview(fieldContainer)
fieldConstraints.append(contentsOf: [
name.leadingAnchor.constraint(equalTo: fieldContainer.leadingAnchor),
container.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
name.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 4),
name.trailingAnchor.constraint(equalTo: dividerLayoutGuide.leadingAnchor),
name.topAnchor.constraint(equalTo: fieldContainer.topAnchor),
name.bottomAnchor.constraint(equalTo: fieldContainer.bottomAnchor),
name.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
name.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
value.leadingAnchor.constraint(equalTo: dividerLayoutGuide.trailingAnchor),
value.trailingAnchor.constraint(equalTo: fieldContainer.trailingAnchor),
value.topAnchor.constraint(equalTo: fieldContainer.topAnchor),
value.bottomAnchor.constraint(equalTo: fieldContainer.bottomAnchor),
name.widthAnchor.constraint(greaterThanOrEqualTo: value.widthAnchor, multiplier: 0.5),
name.widthAnchor.constraint(lessThanOrEqualTo: value.widthAnchor, multiplier: 2),
value.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
value.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
value.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
])
}
let containerTopConstraint = container.topAnchor.constraint(equalTo: prevContainer?.bottomAnchor ?? topAnchor)
fieldConstraints.append(containerTopConstraint)
prevContainer = container
}
if let prevContainer {
let lastContainerBottomConstraint = prevContainer.bottomAnchor.constraint(equalTo: bottomAnchor)
fieldConstraints.append(lastContainerBottomConstraint)
}
NSLayoutConstraint.activate(fieldConstraints)
}
}
private class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate? {
didSet {
textView.navigationDelegate = navigationDelegate
}
}
private let account: AccountMO
private let field: Account.Field
private let textView = ContentTextView()
private var iconView: UIView?
init(field: Account.Field, account: AccountMO) {
self.account = account
self.field = field
super.init(frame: .zero)
textView.isSelectable = false
textView.backgroundColor = .clear
textView.defaultFont = .preferredFont(forTextStyle: .body)
textView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
textView.adjustsFontForContentSizeCategory = true
textView.setTextFromHtml(field.value)
textView.setEmojis(account.emojis, identifier: account.id)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.translatesAutoresizingMaskIntoConstraints = false
addSubview(textView)
let textViewTrailingConstraint: NSLayoutConstraint
if field.verifiedAt != nil {
var config = UIButton.Configuration.plain()
config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(scale: .medium)
config.image = UIImage(systemName: "checkmark")
config.baseForegroundColor = .systemGreen
let icon = UIButton(configuration: config)
self.iconView = icon
icon.translatesAutoresizingMaskIntoConstraints = false
icon.setContentHuggingPriority(.defaultHigh, for: .horizontal)
icon.addTarget(self, action: #selector(verifiedIconTapped), for: .touchUpInside)
icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link"
addSubview(icon)
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([
icon.lastBaselineAnchor.constraint(equalTo: textView.lastBaselineAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
])
} else {
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
}
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
textViewTrailingConstraint,
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setTextAlignment(_ alignment: NSTextAlignment) {
textView.textAlignment = alignment
}
@objc private func verifiedIconTapped() {
guard let navigationDelegate else {
return
}
let view = ProfileFieldVerificationView(
acct: account.acct,
verifiedAt: field.verifiedAt!,
linkText: textView.text,
navigationDelegate: navigationDelegate
)
let host = UIHostingController(rootView: view)
let toPresent: UIViewController
if traitCollection.horizontalSizeClass == .compact || traitCollection.verticalSizeClass == .compact {
toPresent = UINavigationController(rootViewController: host)
toPresent.modalPresentationStyle = .pageSheet
let sheetPresentationController = toPresent.sheetPresentationController!
sheetPresentationController.detents = [
.medium()
]
} else {
host.modalPresentationStyle = .popover
let popoverPresentationController = host.popoverPresentationController!
popoverPresentationController.sourceView = iconView
host.preferredContentSize = host.sizeThatFits(in: CGSize(width: 400, height: CGFloat.infinity))
toPresent = host
}
navigationDelegate.present(toPresent, animated: true)
}
}
@MainActor
private struct ProfileFieldVerificationView: View {
let acct: String
let verifiedAt: Date
let linkText: String
let navigationDelegate: TuskerNavigationDelegate
var body: some View {
VStack(alignment: .leading, spacing: 8) {
firstLine
secondLine
}
.padding()
.navigationTitle(Text("Verified Link"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
navigationDelegate.dismiss(animated: true)
}
}
}
.environment(\.openURL, OpenURLAction(handler: { url in
// dismiss the sheet/popover first
navigationDelegate.dismiss(animated: true) {
navigationDelegate.selected(url: url)
}
return .handled
}))
}
private var firstLine: Text {
var attrStr: AttributedString = "This link has been verified by your instance, "
var instance = AttributedString(navigationDelegate.apiController!.instanceURL.host!)
instance.font = .body.bold()
attrStr += instance
attrStr += "."
return Text(attrStr)
}
private var secondLine: Text {
var attrStr: AttributedString = "The page at "
var linkStr: AttributedString
if linkText.count > 43 {
linkStr = AttributedString(linkText.prefix(40) + "")
} else {
linkStr = AttributedString(linkText)
}
linkStr.link = URL(string: linkText)
attrStr += linkStr
attrStr += " was confirmed to link back to "
var acctStr = AttributedString("@\(acct)")
acctStr.font = .body.bold()
attrStr += acctStr
attrStr += AttributedString(" as of \(verifiedAt.formatted(date: .abbreviated, time: .shortened)).")
return Text(attrStr)
}
override func updateConstraints() {
if !needsSingleColumn,
!fieldViews.isEmpty {
let maxNameWidth = fieldViews.map {
$0.0.sizeThatFits(UIView.layoutFittingCompressedSize).width
}.max()!
let maxValueWidth = fieldViews.map {
$0.1.sizeThatFits(UIView.layoutFittingCompressedSize).width
}.max()!
let defaultWidth = (bounds.width - 8) / 2
dividerXConstraint?.isActive = false
if maxNameWidth > defaultWidth && maxValueWidth < defaultWidth {
dividerXConstraint = dividerLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor, constant: min(maxNameWidth, bounds.width * 2 / 3))
dividerXConstraint!.isActive = true
} else if maxNameWidth < defaultWidth && maxValueWidth > defaultWidth {
dividerXConstraint = dividerLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -min(maxValueWidth, bounds.width * 2 / 3))
dividerXConstraint!.isActive = true
}
}
super.updateConstraints()
}
}

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -62,7 +62,7 @@
</connections>
</button>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq">
<rect key="frame" x="16" y="266" width="398" height="596"/>
<rect key="frame" x="16" y="266" width="382" height="596"/>
<subviews>
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Follows you" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UF8-nI-KVj">
<rect key="frame" x="0.0" y="0.0" width="75.5" height="0.0"/>
@ -78,7 +78,7 @@
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="263.5" width="398" height="128"/>
<rect key="frame" x="0.0" y="263.5" width="382" height="128"/>
<constraints>
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
</constraints>
@ -104,7 +104,7 @@
</subviews>
<constraints>
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/>
<constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
<constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="hnA-3G-B9B"/>
</constraints>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="jwU-EH-hmC">
@ -155,7 +155,7 @@
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
<constraint firstItem="5ja-fK-Fqz" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="jPG-WM-9km"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="oGR-6M-gdd"/>
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" id="ph6-NT-A02"/>
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" constant="16" id="ph6-NT-A02"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="jwU-EH-hmC" secondAttribute="bottom" priority="999" constant="8" id="xDD-rx-gC0"/>
<constraint firstItem="cr8-p9-xkc" firstAttribute="centerY" secondItem="vFa-g3-xIP" secondAttribute="centerY" id="xjr-Hn-Tuk"/>

View File

@ -1,512 +0,0 @@
//
// BaseStatusTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 11/19/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
import AVKit
protocol StatusTableViewCellDelegate: TuskerNavigationDelegate, MenuActionProvider {
// @available(iOS, obsoleted: 16.0)
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell)
}
class BaseStatusTableViewCell: UITableViewCell {
weak var delegate: StatusTableViewCellDelegate? {
didSet {
contentTextView.navigationDelegate = delegate
}
}
var overrideMastodonController: MastodonController?
var mastodonController: MastodonController! { overrideMastodonController ?? delegate?.apiController }
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var metaIndicatorsView: StatusMetaIndicatorsView!
@IBOutlet weak var contentWarningLabel: EmojiLabel!
@IBOutlet weak var collapseButton: UIButton!
@IBOutlet weak var contentTextView: StatusContentTextView!
@IBOutlet weak var cardView: StatusCardView!
@IBOutlet weak var attachmentsView: AttachmentsContainerView!
@IBOutlet weak var pollView: StatusPollView!
@IBOutlet weak var replyButton: UIButton!
@IBOutlet weak var favoriteButton: UIButton!
@IBOutlet weak var reblogButton: UIButton!
@IBOutlet weak var moreButton: UIButton!
private(set) var prevThreadLinkView: UIView?
private(set) var nextThreadLinkView: UIView?
var statusID: String!
private(set) var accountID: String!
private var favorited = false {
didSet {
favoriteButton.tintColor = favorited ? UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1) : .tintColor
}
}
private var reblogged = false {
didSet {
reblogButton.tintColor = reblogged ? UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1) : .tintColor
}
}
private(set) var statusState: CollapseState!
var collapsible = false {
didSet {
collapseButton.isHidden = !collapsible
statusState?.collapsible = collapsible
}
}
private var collapsed = false {
didSet {
statusState?.collapsed = collapsed
}
}
var showStatusAutomatically = false
private var avatarRequest: ImageCache.Request?
private var statusUpdater: Cancellable?
private var accountUpdater: Cancellable?
private var currentPictureInPictureVideoStatusID: String?
private var isGrayscale = false
override func awakeFromNib() {
super.awakeFromNib()
displayNameLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
usernameLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
avatarImageView.layer.masksToBounds = true
avatarImageView.addInteraction(UIDragInteraction(delegate: self))
attachmentsView.delegate = self
collapseButton.layer.masksToBounds = true
collapseButton.layer.cornerRadius = 5
accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!, pollView!]
moreButton.showsMenuAsPrimaryAction = true
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
contentWarningLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed)))
}
open func createObserversIfNecessary() {
if statusUpdater == nil {
statusUpdater = mastodonController.persistentContainer.statusSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] in
if let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: $0) {
self.updateStatusState(status: status)
}
}
}
if accountUpdater == nil {
accountUpdater = mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.accountID }
.sink { [unowned self] in
if let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: $0) {
self.updateUI(account: account)
}
}
}
}
final func updateUI(statusID: String, state: CollapseState) {
createObserversIfNecessary()
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError("Missing cached status")
}
self.statusID = statusID
doUpdateUI(status: status, state: state)
}
func doUpdateUI(status: StatusMO, state: CollapseState) {
self.statusState = state
let account = status.account
self.accountID = account.id
updateUI(account: account)
contentTextView.setTextFrom(status: status)
updateGrayscaleableUI(account: account, status: status)
updateUIForPreferences(account: account, status: status)
cardView.updateUI(status: status)
cardView.isHidden = status.card == nil
cardView.navigationDelegate = delegate
cardView.actionProvider = delegate
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)
}
let reblogDisabled: Bool
if mastodonController.instanceFeatures.boostToOriginalAudience {
// Pleroma allows 'Boost to original audience' for your own private posts
reblogDisabled = status.visibility == .direct || (status.visibility == .private && status.account.id != mastodonController.account?.id)
} else {
reblogDisabled = status.visibility == .private || status.visibility == .direct
}
reblogButton.isEnabled = !reblogDisabled && mastodonController.loggedIn
favoriteButton.isEnabled = mastodonController.loggedIn
replyButton.isEnabled = mastodonController.loggedIn
updateStatusIconsForPreferences(status)
if state.unknown {
// for some reason the height here can't be computed correctly, so we fallback to the old hack of just considering raw length
state.resolveFor(status: status, height: 0, textLength: contentTextView.attributedText.length)
if state.collapsible! && showStatusAutomatically {
state.collapsed = false
}
}
collapsible = state.collapsible!
setCollapsed(state.collapsed!, animated: false)
}
func updateStatusState(status: StatusMO) {
favorited = status.favourited
reblogged = status.reblogged
if favorited {
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
} else {
favoriteButton.accessibilityLabel = NSLocalizedString("Favorite", comment: "favorite button accessibility label")
}
if reblogged {
reblogButton.accessibilityLabel = NSLocalizedString("Undo Reblog", comment: "undo reblog button accessibility label")
} else {
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, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController
pollView.toastableViewController = delegate?.toastableViewController
pollView.updateUI(status: status, poll: status.poll)
}
func updateUI(account: AccountMO) {
usernameLabel.text = "@\(account.acct)"
avatarImageView.image = nil
}
@objc private func preferencesChanged() {
guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID),
let status = mastodonController.persistentContainer.status(for: statusID) else { return }
updateUIForPreferences(account: account, status: status)
}
func updateUIForPreferences(account: AccountMO, status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
switch Preferences.shared.attachmentBlurMode {
case .never:
attachmentsView.contentHidden = false
case .always:
attachmentsView.contentHidden = true
default:
if status.sensitive {
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
} else {
attachmentsView.contentHidden = false
}
}
updateStatusIconsForPreferences(status)
if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI(account: account, status: status)
}
}
func updateStatusIconsForPreferences(_ status: StatusMO) {
metaIndicatorsView.updateUI(status: status)
let reblogButtonImage: UIImage
if Preferences.shared.alwaysShowStatusVisibilityIcon || reblogButton.isEnabled {
reblogButtonImage = UIImage(systemName: "repeat")!
} else {
reblogButtonImage = UIImage(systemName: status.visibility.imageName)!
}
reblogButton.setImage(reblogButtonImage, for: .normal)
}
func updateGrayscaleableUI(account: AccountMO, status: StatusMO) {
isGrayscale = Preferences.shared.grayscaleImages
let accountID = account.id
if let avatarURL = account.avatar {
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self,
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else { return }
DispatchQueue.main.async {
self.avatarImageView.image = transformedImage
}
}
}
if contentTextView.hasEmojis {
contentTextView.setTextFrom(status: status)
}
displayNameLabel.updateForAccountDisplayName(account: account)
}
func setShowThreadLinks(prev: Bool, next: Bool) {
if prev {
if let prevThreadLinkView = prevThreadLinkView {
prevThreadLinkView.isHidden = false
} else {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
prevThreadLinkView = view
addSubview(view)
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 5),
view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
view.topAnchor.constraint(equalTo: topAnchor),
view.bottomAnchor.constraint(equalTo: avatarImageView.topAnchor, constant: -2),
])
}
} else {
prevThreadLinkView?.isHidden = true
}
if next {
if let nextThreadLinkView = nextThreadLinkView {
nextThreadLinkView.isHidden = false
} else {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
nextThreadLinkView = view
addSubview(view)
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 5),
view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
view.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 2),
view.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
} else {
nextThreadLinkView?.isHidden = true
}
}
override func prepareForReuse() {
super.prepareForReuse()
avatarRequest?.cancel()
showStatusAutomatically = false
}
// MARK: - Interaction
@IBAction func collapseButtonPressed() {
setCollapsed(!collapsed, animated: true)
if #available(iOS 16.0, *) {
invalidateIntrinsicContentSize()
} else {
delegate?.statusCellCollapsedStateChanged(self)
}
}
func setCollapsed(_ collapsed: Bool, animated: Bool) {
self.collapsed = collapsed
contentTextView.isHidden = collapsed
cardView.isHidden = cardView.card == nil || collapsed
attachmentsView.isHidden = attachmentsView.attachments.count == 0 || collapsed
pollView.isHidden = pollView.poll == nil || collapsed
let buttonImage = UIImage(systemName: collapsed ? "chevron.down" : "chevron.up")!
if let buttonImageView = collapseButton.imageView {
collapseButton.setImage(buttonImage, for: .normal)
if animated {
buttonImageView.layer.opacity = 0
// this whole hack is necessary because when just rotating buttonImageView, it moves to the left of the button and then animates back to the center
let imageView = UIImageView(image: buttonImageView.image)
imageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalTo: buttonImageView.widthAnchor),
imageView.heightAnchor.constraint(equalTo: buttonImageView.heightAnchor),
imageView.centerXAnchor.constraint(equalTo: collapseButton.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: collapseButton.centerYAnchor),
])
imageView.tintColor = .white
UIView.animate(withDuration: 0.3, delay: 0) {
imageView.transform = CGAffineTransform(rotationAngle: .pi)
} completion: { _ in
imageView.removeFromSuperview()
buttonImageView.layer.opacity = 1
}
}
}
if collapsed {
collapseButton.accessibilityLabel = NSLocalizedString("Expand Status", comment: "expand status button accessibility label")
} else {
collapseButton.accessibilityLabel = NSLocalizedString("Collapse Status", comment: "collapse status button accessibility label")
}
}
@IBAction func replyPressed() {
delegate?.compose(inReplyToID: statusID)
}
@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()
}
}
@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()
}
}
@IBAction func morePressed() {
delegate?.showMoreOptions(forStatus: statusID, source: .view(moreButton))
}
@objc func accountPressed() {
delegate?.selected(account: accountID)
}
}
extension BaseStatusTableViewCell: AttachmentViewDelegate {
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(attachmentsView.getAttachmentView(for:))
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
gallery.avPlayerViewControllerDelegate = self
return gallery
}
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
delegate?.present(vc, animated: animated)
}
}
// todo: This is not ideal. It works when the original cell remains visible and when the cell is reused, but if the cell is dealloc'd
// resuming from PiP won't work because AVPlayerViewController.delegate is a weak reference.
extension BaseStatusTableViewCell: AVPlayerViewControllerDelegate {
func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
// We need to save the current statusID when PiP is initiated, because if the user restores from PiP after this cell has
// been reused, the current value of statusID will not be correct.
currentPictureInPictureVideoStatusID = statusID
}
func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
currentPictureInPictureVideoStatusID = nil
}
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
// Ideally, when PiP is automatically initiated by app closing the gallery should not be dismissed
// and when PiP is started because the user has tapped the button in the player controls the gallery
// gallery should be dismissed. Unfortunately, this doesn't seem to be possible. Instead, the gallery is
// always dismissed and is recreated when restoring the interface from PiP.
return true
}
func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
guard let delegate = delegate,
let playerViewController = playerViewController as? GalleryPlayerViewController,
let id = currentPictureInPictureVideoStatusID,
let status = mastodonController.persistentContainer.status(for: id),
let index = status.attachments.firstIndex(where: { $0.id == playerViewController.attachment?.id }) else {
// returning without invoking completionHandler will dismiss the PiP window
return
}
// We create a new gallery view controller starting at the appropriate index and swap the
// already-playing VC into the appropriate index so it smoothly continues playing.
let sourceViews: [UIImageView?]
if self.statusID == id {
sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
} else {
sourceViews = status.attachments.map { (_) in nil }
}
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
gallery.avPlayerViewControllerDelegate = self
// ensure that all other page VCs are created
gallery.loadViewIfNeeded()
// replace the newly created player for the same attachment with the already-playing one
gallery.pages[index] = playerViewController
gallery.setViewControllers([playerViewController], direction: .forward, animated: false, completion: nil)
// this isn't animated, otherwise the animation plays first and then the PiP window expands
// which looks even weirder than the black background appearing instantly and then the PiP window animating
delegate.present(gallery, animated: false) {
completionHandler(false)
}
}
}
extension BaseStatusTableViewCell: UIDragInteractionDelegate {
func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [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,457 +0,0 @@
//
// TimelineStatusTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 8/16/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Combine
import Pachyderm
class TimelineStatusTableViewCell: BaseStatusTableViewCell {
static let relativeDateFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .numeric
formatter.unitsStyle = .full
return formatter
}()
@IBOutlet weak var reblogLabel: EmojiLabel!
@IBOutlet weak var reblogSpacer: UIView!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var pinImageView: UIImageView!
@IBOutlet weak var actionsContainerView: UIView!
@IBOutlet weak var actionsContainerHeightConstraint: NSLayoutConstraint!
var reblogStatusID: String?
var rebloggerID: String?
var showPinned = false
var showReplyIndicator = true
var updateTimestampWorkItem: DispatchWorkItem?
var rebloggerAccountUpdater: Cancellable?
deinit {
rebloggerAccountUpdater?.cancel()
updateTimestampWorkItem?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
isAccessibilityElement = true
reblogLabel.font = .preferredFont(forTextStyle: .body)
reblogLabel.adjustsFontForContentSizeCategory = true
reblogLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed)))
avatarImageView.addInteraction(UIContextMenuInteraction(delegate: self))
displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
]
]), size: 0)
displayNameLabel.adjustsFontForContentSizeCategory = true
usernameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
usernameLabel.adjustsFontForContentSizeCategory = true
timestampLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
timestampLabel.adjustsFontForContentSizeCategory = true
metaIndicatorsView.primaryAxis = .vertical
metaIndicatorsView.secondaryAxisAlignment = .trailing
contentWarningLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold.rawValue,
]
]), size: 0)
contentWarningLabel.adjustsFontForContentSizeCategory = true
contentTextView.defaultFont = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 16))
contentTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 16, weight: .regular))
contentTextView.adjustsFontForContentSizeCategory = true
// todo: double check this on RTL layouts
replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true
updateActionsVisibility()
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
}
override func createObserversIfNecessary() {
super.createObserversIfNecessary()
if rebloggerAccountUpdater == nil {
rebloggerAccountUpdater = 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)
}
}
}
}
override func doUpdateUI(status: StatusMO, state: CollapseState) {
var status = status
if let rebloggedStatus = status.reblog {
reblogStatusID = statusID
rebloggerID = status.account.id
reblogLabel.isHidden = false
reblogSpacer.isHidden = false
updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus
// necessary b/c statusID is initially set to the reblog status ID in updateUI(statusID:state:)
statusID = rebloggedStatus.id
} else {
reblogStatusID = nil
rebloggerID = nil
reblogLabel.isHidden = true
reblogSpacer.isHidden = true
}
super.doUpdateUI(status: status, state: state)
doUpdateTimestamp(status: status)
timestampLabel.isHidden = showPinned
pinImageView.isHidden = !showPinned
}
override func updateGrayscaleableUI(account: AccountMO, status: StatusMO) {
super.updateGrayscaleableUI(account: account, status: status)
if let rebloggerID = rebloggerID,
reblogLabel.hasEmojis,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
updateRebloggerLabel(reblogger: reblogger)
}
}
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)
}
}
override func updateStatusIconsForPreferences(_ status: StatusMO) {
if showReplyIndicator {
metaIndicatorsView.allowedIndicators = .all
} else {
metaIndicatorsView.allowedIndicators = .all.subtracting(.reply)
}
let oldState = actionsContainerView.isHidden
if oldState != Preferences.shared.hideActionsInTimeline {
updateActionsVisibility()
if #available(iOS 16.0, *) {
invalidateIntrinsicContentSize()
} else {
// not really accurate, but it notifies the vc our height has changed
delegate?.statusCellCollapsedStateChanged(self)
}
}
super.updateStatusIconsForPreferences(status)
}
private func updateActionsVisibility() {
if Preferences.shared.hideActionsInTimeline {
actionsContainerView.isHidden = true
} else {
actionsContainerView.isHidden = false
}
}
private func updateTimestamp() {
// if the mastodonController is nil (i.e. the delegate is nil), then the screen this cell was a part of has been deallocated
// so we bail out immediately, since there's nothing to update
// if the status cannot be found, it may have already been discarded due to not being on screen, so we do nothing
guard let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) else { return }
doUpdateTimestamp(status: status)
}
private func doUpdateTimestamp(status: StatusMO) {
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 = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
} else {
updateTimestampWorkItem = nil
}
}
func reply() {
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)
}
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil
showPinned = false
}
@objc func reblogLabelPressed() {
guard let rebloggerID = rebloggerID else { return }
delegate?.selected(account: rebloggerID)
}
override func replyPressed() {
reply()
}
// MARK: - Accessibility
override var accessibilityAttributedLabel: NSAttributedString? {
get {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
var str: AttributedString = ""
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += AttributedString("Reblogged by \(reblogger.displayNameWithoutCustomEmoji): ")
}
str += AttributedString(status.account.displayNameWithoutCustomEmoji)
str += ", "
if statusState.collapsed ?? false {
if !status.spoilerText.isEmpty {
str += AttributedString(status.spoilerText)
str += ", "
}
str += "collapsed"
} else {
str += AttributedString(contentTextView.attributedText)
if status.attachments.count > 0 {
let includeDescriptions: Bool
switch Preferences.shared.attachmentBlurMode {
case .useStatusSetting:
includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || status.spoilerText.isEmpty
case .always:
includeDescriptions = true
case .never:
includeDescriptions = false
}
if includeDescriptions {
if status.attachments.count == 1 {
let attachment = status.attachments[0]
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment: \(desc)")
} else {
for (index, attachment) in status.attachments.enumerated() {
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment \(index + 1): \(desc)")
}
}
} else {
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count == 1 ? "" : "s")")
}
}
if status.poll != nil {
str += ", poll"
}
}
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
if status.visibility < .unlisted {
str += AttributedString(", \(status.visibility.displayName)")
}
if status.localOnly {
str += ", local"
}
return NSAttributedString(str)
}
set {}
}
override var accessibilityHint: String? {
get {
if statusState.collapsed ?? false {
return "Double tap to expand the post."
} else {
return nil
}
}
set {}
}
override func accessibilityActivate() -> Bool {
if statusState.collapsed ?? false {
collapseButtonPressed()
} else {
didSelectCell()
}
return true
}
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get {
guard let text = contentTextView.attributedText,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
var actions = [
UIAccessibilityCustomAction(name: "Show \(status.account.displayNameWithoutCustomEmoji)", actionHandler: { [unowned self] _ in
self.delegate?.selected(account: status.account.id)
return true
})
]
text.enumerateAttribute(.link, in: NSRange(location: 0, length: text.length)) { value, range, stop in
guard let value = value as? URL else {
return
}
let text = text.attributedSubstring(from: range).string
actions.append(UIAccessibilityCustomAction(name: text) { [unowned self] _ in
self.contentTextView.handleLinkTapped(url: value, text: text)
return true
})
}
return actions
}
set {}
}
}
extension TimelineStatusTableViewCell: SelectableTableViewCell {
func didSelectCell() {
delegate?.selected(status: statusID, state: statusState.copy())
}
}
extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
func leadingSwipeActionsConfiguration() -> UISwipeActionsConfiguration? {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
return UISwipeActionsConfiguration(actions: Preferences.shared.leadingStatusSwipeActions.compactMap { $0.createAction(status: status, container: self) })
}
func trailingSwipeActionsConfiguration() -> UISwipeActionsConfiguration? {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
return UISwipeActionsConfiguration(actions: Preferences.shared.trailingStatusSwipeActions.compactMap { $0.createAction(status: status, container: self) })
}
}
extension TimelineStatusTableViewCell: DraggableTableViewCell {
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 let status = mastodonController.persistentContainer.status(for: statusID),
let accountID = mastodonController.accountInfo?.id,
!pollView.isTracking 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)]
}
}
extension TimelineStatusTableViewCell: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil) {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { (_) in
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? [])
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController,
let delegate = delegate {
animator.preferredCommitStyle = .pop
animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting {
customPresenting.presentFromPreview(presenter: delegate)
} else {
delegate.show(viewController)
}
}
}
}
}
extension TimelineStatusTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
return (
content: { ConversationViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) },
actions: { self.delegate?.actionsForStatus(status, source: .view(self)) ?? [] }
)
}
}
extension TimelineStatusTableViewCell: StatusSwipeActionContainer {
var navigationDelegate: TuskerNavigationDelegate { delegate! }
var toastableViewController: ToastableViewController? { delegate }
var canReblog: Bool {
reblogButton.isEnabled
}
func performReplyAction() {
self.replyPressed()
}
}

View File

@ -1,302 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="269" id="BR5-ZS-LIo" customClass="TimelineStatusTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="393" height="269"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="BR5-ZS-LIo" id="27d-P9-02g">
<rect key="frame" x="0.0" y="0.0" width="393" height="269"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="yNh-ac-v6c">
<rect key="frame" x="16" y="8" width="361" height="253"/>
<subviews>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="751" text="Reblogged by Person" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lDH-50-AJZ" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="361" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="H6C-5s-ICE">
<rect key="frame" x="0.0" y="20.5" width="361" height="4"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="4" id="KdU-GV-9et"/>
</constraints>
</view>
<view contentMode="scaleToFill" verticalHuggingPriority="249" translatesAutoresizingMaskIntoConstraints="NO" id="ve3-Y1-NQH">
<rect key="frame" x="0.0" y="24.5" width="361" height="202.5"/>
<subviews>
<imageView contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="QMP-j2-HLn">
<rect key="frame" x="0.0" y="0.0" width="50" height="50"/>
<accessibility key="accessibilityConfiguration" label="User Avatar">
<bool key="isElement" value="YES"/>
</accessibility>
<gestureRecognizers/>
<constraints>
<constraint firstAttribute="width" constant="50" id="KZ8-d7-8UK"/>
<constraint firstAttribute="height" constant="50" id="nMi-Gq-JyV"/>
</constraints>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="751" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="gIY-Wp-RSk">
<rect key="frame" x="58" y="0.0" width="295" height="202.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="3Sm-P0-ySf">
<rect key="frame" x="0.0" y="0.0" width="295" height="20.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" horizontalCompressionResistancePriority="749" verticalCompressionResistancePriority="752" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="gll-xe-FSr" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="106.5" height="20.5"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/>
</accessibility>
<gestureRecognizers/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="249" verticalHuggingPriority="252" horizontalCompressionResistancePriority="748" verticalCompressionResistancePriority="752" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="j89-zc-SFa">
<rect key="frame" x="110.5" y="0.0" width="156.5" height="20.5"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/>
</accessibility>
<gestureRecognizers/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<imageView hidden="YES" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="pin.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="wtt-8G-Ua1">
<rect key="frame" x="269" y="-0.5" width="0.0" height="22"/>
<color key="tintColor" systemColor="secondaryLabelColor"/>
<accessibility key="accessibilityConfiguration" label="Pinned Status"/>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="752" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="35d-EA-ReR">
<rect key="frame" x="271" y="0.0" width="24" height="20.5"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="height" secondItem="gll-xe-FSr" secondAttribute="height" id="B7p-Pc-fZD"/>
</constraints>
</stackView>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="755" text="Content Warning" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="inI-Og-YiU" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="24.5" width="295" height="20.5"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/>
</accessibility>
<gestureRecognizers/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="252" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="O0E-Vf-XYR" customClass="StatusCollapseButton" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="49" width="295" height="30"/>
<color key="backgroundColor" systemColor="tintColor"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="z84-XW-gP3"/>
</constraints>
<color key="tintColor" systemColor="tintColor"/>
<inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/>
<state key="normal" image="chevron.down" catalog="system">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state>
<buttonConfiguration key="configuration" style="filled" image="chevron.down" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfigurationForImage" scale="large"/>
<color key="baseForegroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</buttonConfiguration>
<connections>
<action selector="collapseButtonPressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="twO-rE-1pQ"/>
</connections>
</button>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="waJ-f5-LKv" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="83" width="295" height="115.5"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="200.5" width="295" height="0.0"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="90" id="khY-jm-CPn"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="200.5" width="295" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="x3b-Zl-9F0" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="202.5" width="295" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</subviews>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qBn-Gk-DCa" customClass="StatusMetaIndicatorsView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="54" width="50" height="22"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="22" placeholder="YES" id="ipd-WE-P20"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="6OU-Ub-VH8"/>
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="qBn-Gk-DCa" secondAttribute="trailing" constant="8" id="AQs-QN-j49"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="qBn-Gk-DCa" secondAttribute="bottom" id="P1i-ZM-TRt"/>
<constraint firstItem="QMP-j2-HLn" firstAttribute="top" secondItem="ve3-Y1-NQH" secondAttribute="top" id="PC4-Bi-QXm"/>
<constraint firstItem="gIY-Wp-RSk" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="top" id="fEd-wN-kuQ"/>
<constraint firstAttribute="trailingMargin" secondItem="gIY-Wp-RSk" secondAttribute="trailing" id="hKk-kO-wFT"/>
<constraint firstItem="qBn-Gk-DCa" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="iLD-VU-ixJ"/>
<constraint firstAttribute="bottom" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="kq7-bk-S8j"/>
<constraint firstItem="qBn-Gk-DCa" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="4" id="tKU-VP-n8P"/>
<constraint firstItem="qBn-Gk-DCa" firstAttribute="width" secondItem="QMP-j2-HLn" secondAttribute="width" id="v1v-Pp-ubE"/>
<constraint firstItem="QMP-j2-HLn" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="zeW-tQ-uJl"/>
</constraints>
<variation key="default">
<mask key="constraints">
<exclude reference="kq7-bk-S8j"/>
</mask>
</variation>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TUP-Nz-5Yh">
<rect key="frame" x="0.0" y="227" width="361" height="26"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rKF-yF-KIa">
<rect key="frame" x="0.0" y="0.0" width="90.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reply"/>
<fontDescription key="fontDescription" type="system" pointSize="18"/>
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
<connections>
<action selector="replyPressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="ljN-Uq-rSV"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
<rect key="frame" x="180.5" y="0.0" width="90.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reblog"/>
<state key="normal" image="repeat" catalog="system"/>
<connections>
<action selector="reblogPressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="0y7-cF-Nsu"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
<rect key="frame" x="271" y="0.0" width="90" height="26"/>
<accessibility key="accessibilityConfiguration" label="More Actions"/>
<state key="normal" image="ellipsis" catalog="system"/>
<connections>
<action selector="morePressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="Nvo-Lw-cQd"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
<rect key="frame" x="90.5" y="0.0" width="90" height="26"/>
<accessibility key="accessibilityConfiguration" label="Favorite"/>
<state key="normal" image="star.fill" catalog="system"/>
<connections>
<action selector="favoritePressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="KUW-UC-40j"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="26" id="1FK-Er-G11"/>
<constraint firstAttribute="bottom" secondItem="rKF-yF-KIa" secondAttribute="bottom" id="KyG-2C-MgN"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="L3w-JH-eeG"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="N7j-f4-gvP"/>
<constraint firstItem="982-J4-NGl" firstAttribute="leading" secondItem="6tW-z8-Qh9" secondAttribute="trailing" id="VQo-DJ-C7L"/>
<constraint firstItem="982-J4-NGl" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="W53-1a-fKu"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="leading" secondItem="rKF-yF-KIa" secondAttribute="trailing" id="WPd-A2-6Ju"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="x0t-TR-jJ4" secondAttribute="width" id="X7m-pJ-oje"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="leading" secondItem="TUP-Nz-5Yh" secondAttribute="leading" placeholder="YES" id="aFR-Ew-99S"/>
<constraint firstAttribute="bottom" secondItem="982-J4-NGl" secondAttribute="bottom" id="eXy-3h-51w"/>
<constraint firstAttribute="bottom" secondItem="x0t-TR-jJ4" secondAttribute="bottom" id="euN-Nf-rwh"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="leading" secondItem="x0t-TR-jJ4" secondAttribute="trailing" id="oAK-VG-bbp"/>
<constraint firstAttribute="bottom" secondItem="6tW-z8-Qh9" secondAttribute="bottom" id="tpf-Q3-V3l"/>
<constraint firstAttribute="trailing" secondItem="982-J4-NGl" secondAttribute="trailing" id="uQG-FZ-F7u"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="982-J4-NGl" secondAttribute="width" id="vir-iq-biv"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="6tW-z8-Qh9" secondAttribute="width" id="vqw-d7-VtZ"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="wWH-J7-egM"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstItem="ve3-Y1-NQH" firstAttribute="width" secondItem="yNh-ac-v6c" secondAttribute="width" id="xN6-cs-Tnn"/>
</constraints>
<variation key="default">
<mask key="constraints">
<exclude reference="xN6-cs-Tnn"/>
</mask>
</variation>
</stackView>
</subviews>
<constraints>
<constraint firstItem="yNh-ac-v6c" firstAttribute="top" secondItem="27d-P9-02g" secondAttribute="top" constant="8" id="BV4-cX-hOq"/>
<constraint firstAttribute="bottom" secondItem="yNh-ac-v6c" secondAttribute="bottom" constant="8" id="Bjn-HM-IXF"/>
<constraint firstItem="yNh-ac-v6c" firstAttribute="leading" secondItem="27d-P9-02g" secondAttribute="leadingMargin" id="Y9H-aJ-Nab"/>
<constraint firstAttribute="trailingMargin" secondItem="yNh-ac-v6c" secondAttribute="trailing" id="etD-1m-QVM"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="actionsContainerHeightConstraint" destination="1FK-Er-G11" id="rkH-TL-9rr"/>
<outlet property="actionsContainerView" destination="TUP-Nz-5Yh" id="B5c-tl-Sbw"/>
<outlet property="attachmentsView" destination="nbq-yr-2mA" id="MAs-nv-cNN"/>
<outlet property="avatarImageView" destination="QMP-j2-HLn" id="73F-6g-drx"/>
<outlet property="cardView" destination="LKo-VB-XWl" id="Ypd-Cr-fie"/>
<outlet property="collapseButton" destination="O0E-Vf-XYR" id="oTb-VA-JHD"/>
<outlet property="contentTextView" destination="waJ-f5-LKv" id="Tyd-9N-WxW"/>
<outlet property="contentWarningLabel" destination="inI-Og-YiU" id="TmT-Fq-HVG"/>
<outlet property="displayNameLabel" destination="gll-xe-FSr" id="dAN-AD-XMb"/>
<outlet property="favoriteButton" destination="x0t-TR-jJ4" id="jE8-4t-FVW"/>
<outlet property="metaIndicatorsView" destination="qBn-Gk-DCa" id="Hd4-6j-lvT"/>
<outlet property="moreButton" destination="982-J4-NGl" id="GwC-R2-qSn"/>
<outlet property="pinImageView" destination="wtt-8G-Ua1" id="igV-Q0-3h9"/>
<outlet property="pollView" destination="x3b-Zl-9F0" id="aZF-5R-yOi"/>
<outlet property="reblogButton" destination="6tW-z8-Qh9" id="k4G-ZY-yNO"/>
<outlet property="reblogLabel" destination="lDH-50-AJZ" id="asF-Ea-qOg"/>
<outlet property="reblogSpacer" destination="H6C-5s-ICE" id="LEq-6z-z1E"/>
<outlet property="replyButton" destination="rKF-yF-KIa" id="bx6-Co-4KB"/>
<outlet property="timestampLabel" destination="35d-EA-ReR" id="NEL-KM-hOJ"/>
<outlet property="usernameLabel" destination="j89-zc-SFa" id="fgg-kb-b9s"/>
</connections>
<point key="canvasLocation" x="-848.79999999999995" y="79.610194902548727"/>
</tableViewCell>
</objects>
<resources>
<image name="arrowshape.turn.up.left.fill" catalog="system" width="128" height="104"/>
<image name="chevron.down" catalog="system" width="128" height="70"/>
<image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="pin.fill" catalog="system" width="116" height="128"/>
<image name="repeat" catalog="system" width="128" height="98"/>
<image name="star.fill" catalog="system" width="128" height="116"/>
<systemColor name="labelColor">
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="secondarySystemBackgroundColor">
<color red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="tintColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>