Compare commits

..

6 Commits

9 changed files with 363 additions and 31 deletions

View File

@ -49,7 +49,12 @@ public final class Account: AccountProtocol, Decodable {
self.avatarStatic = try? container.decode(URL.self, forKey: .avatarStatic) self.avatarStatic = try? container.decode(URL.self, forKey: .avatarStatic)
self.header = try? container.decode(URL.self, forKey: .header) self.header = try? container.decode(URL.self, forKey: .header)
self.headerStatic = try? container.decode(URL.self, forKey: .headerStatic) self.headerStatic = try? container.decode(URL.self, forKey: .headerStatic)
self.emojis = try container.decode([Emoji].self, forKey: .emojis) // even up-to-date pixelfed instances sometimes lack this, for reasons unclear
if let emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) {
self.emojis = emojis
} else {
self.emojis = []
}
self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? [] self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? []
self.bot = try? container.decode(Bool.self, forKey: .bot) self.bot = try? container.decode(Bool.self, forKey: .bot)

View File

@ -8,8 +8,8 @@
import Foundation import Foundation
public class NotificationGroup: Identifiable, Hashable { public struct NotificationGroup: Identifiable, Hashable {
public let notifications: [Notification] public private(set) var notifications: [Notification]
public let id: String public let id: String
public let kind: Notification.Kind public let kind: Notification.Kind
public let statusState: StatusState? public let statusState: StatusState?
@ -27,34 +27,90 @@ public class NotificationGroup: Identifiable, Hashable {
} }
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool { public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
return lhs.id == rhs.id guard lhs.notifications.count == rhs.notifications.count else {
return false
}
for (a, b) in zip(lhs.notifications, rhs.notifications) where a.id != b.id {
return false
}
return true
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(id) for notification in notifications {
hasher.combine(notification.id)
}
}
private mutating func append(_ notification: Notification) {
notifications.append(notification)
}
private mutating func append(group: NotificationGroup) {
notifications.append(contentsOf: group.notifications)
} }
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [[Notification]]() var groups = [NotificationGroup]()
for notification in notifications { for notification in notifications {
if allowedTypes.contains(notification.kind) { if allowedTypes.contains(notification.kind) {
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id { if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) {
groups[groups.count - 1].append(notification) groups[groups.count - 1].append(notification)
continue continue
} else if groups.count >= 2 { } else if groups.count >= 2 {
let secondToLastGroup = groups[groups.count - 2] let secondToLastGroup = groups[groups.count - 2]
if allowedTypes.contains(groups[groups.count - 1][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id { if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) {
groups[groups.count - 2].append(notification) groups[groups.count - 2].append(notification)
continue continue
} }
} }
} }
groups.append([notification]) groups.append(NotificationGroup(notifications: [notification])!)
} }
return groups.map { return groups
NotificationGroup(notifications: $0)! }
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
}
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
guard !first.isEmpty else {
return second
} }
guard !second.isEmpty else {
return first
}
var merged = first
var second = second
merged.reserveCapacity(second.count)
while let firstGroupFromSecond = second.first,
allowedTypes.contains(firstGroupFromSecond.kind) {
second.removeFirst()
guard let lastGroup = merged.last,
allowedTypes.contains(lastGroup.kind) else {
merged.append(firstGroupFromSecond)
break
}
if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) {
merged[merged.count - 1].append(group: firstGroupFromSecond)
} else if merged.count >= 2 {
let secondToLastGroup = merged[merged.count - 2]
if allowedTypes.contains(secondToLastGroup.kind), canMerge(notification: firstGroupFromSecond.notifications.first!, into: secondToLastGroup) {
merged[merged.count - 2].append(group: firstGroupFromSecond)
}
} else {
merged.append(firstGroupFromSecond)
}
}
merged.append(contentsOf: second)
return merged
} }
} }

View File

@ -0,0 +1,235 @@
//
// NotificationGroupTests.swift
//
//
// Created by Shadowfacts on 4/26/22.
//
import XCTest
@testable import Pachyderm
class NotificationGroupTests: XCTestCase {
let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
let statusA = """
{
"id": "1",
"created_at": "2019-11-23T07:28:34Z",
"account": {
"id": "2",
"username": "bar",
"acct": "bar",
"display_name": "bar",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@bar",
"uri": "https://example.com/@bar",
},
"url": "https://example.com/@bar/1",
"uri": "https://example.com/@bar/1",
"content": "",
"emojis": [],
"reblogs_count": 0,
"favourites_count": 0,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"media_attachments": [],
"mentions": [],
"tags": [],
}
}
"""
lazy var likeA1Data = """
{
"id": "1",
"type": "favourite",
"created_at": "2019-11-23T07:29:18Z",
"account": {
"id": "1",
"username": "foo",
"acct": "foo",
"display_name": "foo",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@foo",
"uri": "https://example.com/@foo",
},
"status": \(statusA)
""".data(using: .utf8)!
lazy var likeA1 = try! decoder.decode(Notification.self, from: likeA1Data)
lazy var likeA2Data = """
{
"id": "2",
"type": "favourite",
"created_at": "2019-11-23T07:30:00Z",
"account": {
"id": "2",
"username": "baz",
"acct": "baz",
"display_name": "baz",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@baz",
"uri": "https://example.com/@baz",
},
"status": \(statusA)
""".data(using: .utf8)!
lazy var likeA2 = try! decoder.decode(Notification.self, from: likeA2Data)
let statusB = """
{
"id": "2",
"created_at": "2019-11-23T07:28:34Z",
"account": {
"id": "2",
"username": "bar",
"acct": "bar",
"display_name": "bar",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@bar",
"uri": "https://example.com/@bar",
},
"url": "https://example.com/@bar/1",
"uri": "https://example.com/@bar/1",
"content": "",
"emojis": [],
"reblogs_count": 0,
"favourites_count": 0,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"media_attachments": [],
"mentions": [],
"tags": [],
}
}
"""
lazy var likeBData = """
{
"id": "3",
"type": "favourite",
"created_at": "2019-11-23T07:29:18Z",
"account": {
"id": "1",
"username": "foo",
"acct": "foo",
"display_name": "foo",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@foo",
"uri": "https://example.com/@foo",
},
"status": \(statusB)
""".data(using: .utf8)!
lazy var likeB = try! decoder.decode(Notification.self, from: likeBData)
lazy var mentionBData = """
{
"id": "4",
"type": "mention",
"created_at": "2019-11-23T07:29:18Z",
"account": {
"id": "1",
"username": "foo",
"acct": "foo",
"display_name": "foo",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@foo",
"uri": "https://example.com/@foo",
},
"status": \(statusB)
""".data(using: .utf8)!
lazy var mentionB = try! decoder.decode(Notification.self, from: mentionBData)
func testGroupSimple() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
}
func testGroupWithOtherGroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
])
}
func testDontGroupWithUngroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2])!,
])
}
func testMergeSimpleGroups() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeA2])!
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2])!
])
}
func testMergeGroupsWithOtherGroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeB])!
let group3 = NotificationGroup(notifications: [likeA2])!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
])
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
XCTAssertEqual(merged2, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
])
}
func testDontMergeWithUngroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [mentionB])!
let group3 = NotificationGroup(notifications: [likeA2])!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2])!,
])
}
}

View File

@ -28,6 +28,16 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES">
<Testables> <Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PachydermTests"
BuildableName = "PachydermTests"
BlueprintName = "PachydermTests"
ReferencedContainer = "container:Pachyderm">
</BuildableReference>
</TestableReference>
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction

View File

@ -116,7 +116,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
rootVC.sceneDidEnterBackground() rootVC.sceneDidEnterBackground()
} }
try! scene.session.mastodonController?.persistentContainer.viewContext.save() try? scene.session.mastodonController?.persistentContainer.viewContext.save()
} }
private func handlePendingCrashReport(_ report: PLCrashReport, session: UISceneSession) { private func handlePendingCrashReport(_ report: PLCrashReport, session: UISceneSession) {

View File

@ -140,11 +140,14 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
self.older = older self.older = older
} }
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes) let olderGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) { self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
var snapshot = currentSnapshot() let existingGroups = currentSnapshot().itemIdentifiers
snapshot.appendItems(groups, toSection: .notifications) let merged = NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes)
var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>()
snapshot.appendSections([.notifications])
snapshot.appendItems(merged, toSection: .notifications)
completion(.success(snapshot)) completion(.success(snapshot))
} }
} }
@ -173,15 +176,14 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
self.newer = newer self.newer = newer
} }
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes) let newerGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) { self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
var snapshot = currentSnapshot() let existingGroups = currentSnapshot().itemIdentifiers
if let first = snapshot.itemIdentifiers(inSection: .notifications).first { let merged = NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes)
snapshot.insertItems(groups, beforeItem: first) var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>()
} else { snapshot.appendSections([.notifications])
snapshot.appendItems(groups, toSection: .notifications) snapshot.appendItems(merged, toSection: .notifications)
}
completion(.success(snapshot)) completion(.success(snapshot))
} }
} }

View File

@ -120,10 +120,21 @@ class ProfileViewController: UIPageViewController {
let req = Client.getAccount(id: accountID) let req = Client.getAccount(id: accountID)
mastodonController.run(req) { [weak self] (response) in mastodonController.run(req) { [weak self] (response) in
guard let self = self else { return } guard let self = self else { return }
guard case let .success(account, _) = response else { fatalError() } switch response {
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (account) in case .success(let account, _):
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (account) in
DispatchQueue.main.async {
self.updateAccountUI()
}
}
case .failure(let error):
DispatchQueue.main.async { DispatchQueue.main.async {
self.updateAccountUI() let config = ToastConfiguration(from: error, with: "Loading", in: self) { [unowned self] (toast) in
toast.dismissToast(animated: true)
self.loadAccount()
}
self.showToast(configuration: config, animated: true)
} }
} }
} }
@ -271,3 +282,6 @@ extension ProfileViewController: TabbedPageViewController {
selectPage(at: currentIndex - 1, animated: true) selectPage(at: currentIndex - 1, animated: true)
} }
} }
extension ProfileViewController: ToastableViewController {
}

View File

@ -182,12 +182,12 @@ class ProfileHeaderView: UIView {
@objc private func updateUIForPreferences() { @objc private func updateUIForPreferences() {
// todo: mastodonController should never be nil, but ProfileHeaderViews are getting leaked // todo: mastodonController should never be nil, but ProfileHeaderViews are getting leaked
guard let mastodonController = mastodonController else { guard let mastodonController = mastodonController,
// nil if prefs changed before own account is loaded
let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) else {
return return
} }
guard let account = mastodonController.persistentContainer.account(for: accountID) else {
fatalError("Missing cached account \(accountID!)")
}
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)

View File

@ -12,6 +12,7 @@ class ToastView: UIView {
let configuration: ToastConfiguration let configuration: ToastConfiguration
private var panRecognizer: UIPanGestureRecognizer!
private var shrinkAnimator: UIViewPropertyAnimator? private var shrinkAnimator: UIViewPropertyAnimator?
private var recognizedGesture = false private var recognizedGesture = false
private var handledLongPress = false private var handledLongPress = false
@ -101,9 +102,10 @@ class ToastView: UIView {
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
]) ])
let pan = UIPanGestureRecognizer(target: self, action: #selector(panRecognized)) panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
addGestureRecognizer(pan) addGestureRecognizer(panRecognizer)
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(longPressRecognized)) let longPress = UILongPressGestureRecognizer(target: self, action: #selector(longPressRecognized))
longPress.delegate = self
addGestureRecognizer(longPress) addGestureRecognizer(longPress)
} }
@ -266,3 +268,11 @@ class ToastView: UIView {
} }
} }
extension ToastView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// if another recognizer can recognize simulatenously (e.g., table view cell drag initiation) it should require the toast one to fail
// otherwise long-pressing on a toast results in the drag beginning instead
return true
}
}