Compare commits
14 Commits
f775527d63
...
7449688bfe
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 7449688bfe | |
Shadowfacts | 63612b2fb0 | |
Shadowfacts | 8e010c7fa5 | |
Shadowfacts | 3181c47fde | |
Shadowfacts | a133955489 | |
Shadowfacts | 7551c79715 | |
Shadowfacts | 5a4e387026 | |
Shadowfacts | 00945a0028 | |
Shadowfacts | 2b9d384f8f | |
Shadowfacts | 90efee3f20 | |
Shadowfacts | 574d1f9134 | |
Shadowfacts | 25e82d828f | |
Shadowfacts | 2eb9e63724 | |
Shadowfacts | d85f74f365 |
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -56,6 +56,7 @@ private struct ViewControllerPresenter: UIViewControllerRepresentable {
|
|||
|
||||
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
||||
isPresented = false
|
||||
didPresent = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// UIViewController+StatusTableViewCellDelegate.swift
|
||||
// UIViewController+Delegate.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/27/18.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {}
|
||||
}
|
||||
|
||||
}
|
|
@ -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),
|
||||
])
|
||||
|
|
|
@ -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 {
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 []
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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!)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>
|
Loading…
Reference in New Issue